Skip to content

Validate contract signatures against the verifying Safe's EIP-1271 convention - #2726

Open
Uxío (Uxio0) wants to merge 2 commits into
mainfrom
uxio/pla-1907-eip1271-safe-version
Open

Validate contract signatures against the verifying Safe's EIP-1271 convention#2726
Uxío (Uxio0) wants to merge 2 commits into
mainfrom
uxio/pla-1907-eip1271-safe-version

Conversation

@Uxio0

Copy link
Copy Markdown
Member

Validate contract signatures against the verifying Safe's EIP-1271 convention

Problem

SafeSignatureContract.is_valid tries both EIP-1271 entrypoints and returns True if either answers:

  1. isValidSignature(bytes32,bytes) with safe_hash
  2. isValidSignature(bytes,bytes) with safe_hash_preimage

On-chain only one is ever called, and the version of the Safe doing the verifying decides which:

  • Below 1.5.0 (1.3.0, 1.4.x) — checkNSignatures calls the legacy isValidSignature(data, contractSignature) with the whole preimage (Safe.sol:315 in v1.4.1, GnosisSafe.sol:285 in v1.3.0 — identical branches, the only v1.4.1 addition is the GS027 preimage assertion).
  • From 1.5.0checkContractSignature calls isValidSignature(bytes32 dataHash, ...) only (Safe.sol:270), and its CompatibilityFallbackHandler no longer declares the legacy overload at all.

So a signature can be "valid" here and still revert GS024 on the Safe that has to verify it. Consumers that store it end up below threshold with nothing reported at write time.

What this changes

safe_version on is_valid. Only the entrypoint that version calls on-chain is checked. It goes on the abstract SafeSignature / SafeSignatureAsync and on all ten implementations, the same way safe_address already works: validation-time context that only one signature type reads and the rest ignore.

safe_version=None keeps the current behaviour. Existing callers are unaffected — the library cannot always know the verifying Safe's version, so the permissive default stays. Callers that do know should pass it.

uses_bytes32_eip1271() is the single home for the version rule and for its failure mode. VERSION() is an arbitrary on-chain string, so it raises CannotCheckEIP1271ContractSignature (an existing, until now unused, SafeSignatureException subclass) instead of leaking packaging's InvalidVersion and making every consumer reimplement the same translation.

The cross-version case comes out for free

A verifying Safe below 1.5.0 with an owner Safe on 1.5.0 can never work: the verifying Safe calls the legacy overload, which a 1.5.0 signer does not declare. Today is_valid accepts it through attempt 1, which is a permanent false accept. Keying only off the verifying Safe's version rejects it with no extra logic, because we call the entrypoint that will actually be used and it reverts.

Verifying Safe Owner Safe Called on-chain Works Before After
pre-1.5.0 pre-1.5.0 (bytes preimage, sig) yes accepts preimage and hash preimage only
pre-1.5.0 1.5.0+ (bytes preimage, sig), overload missing never accepts rejected
1.5.0+ pre-1.5.0 (bytes32 hash, sig) yes accepts hash and preimage hash only
1.5.0+ 1.5.0+ (bytes32 hash, sig) yes accepts hash only hash only

Tests

TestSafeContractSignature and TestSafeContractSignatureAsync cover v1.3.0, v1.4.1 and v1.5.0 against the deployed contracts:

  • the version-correct nesting is accepted, the other is rejected
  • safe_version=None still accepts both on a pre-1.5.0 signer (back-compat guard)
  • a 1.5.0 owner Safe is rejected for a 1.4.1 verifier, and the test pins that without a version it is still accepted, which is the false accept above

277 passed, 12 skipped in safe_eth/safe/tests/.

Notes for the reviewer

  • No production code in this repo calls is_valid, so the change is additive to the public API.
  • The mypy pre-commit hook is language: system and fails locally with Executable mypy not found. Ran separately with the hook's flags: both changed files clean. The one error reported, safe_eth/eth/tests/utils.py:56, is pre-existing in a file this PR does not touch.

Consumed by safe-global/safe-queue-service PLA-1907, which needs a release of this.

…nvention

`SafeSignatureContract.is_valid` tried both EIP-1271 entrypoints and returned
True if either answered. On-chain only one is ever called, and the version of
the Safe doing the verifying decides which: below 1.5.0 `checkNSignatures`
calls the legacy `isValidSignature(bytes,bytes)` with the preimage, from 1.5.0
`checkContractSignature` calls `isValidSignature(bytes32,bytes)` with the hash,
and the 1.5.0 CompatibilityFallbackHandler no longer declares the legacy
overload at all. So a signature could be valid here and still revert GS024 on
the Safe that has to verify it.

Add `safe_version` to `is_valid` so only the entrypoint that version calls is
checked. It goes on the abstract `SafeSignature` / `SafeSignatureAsync` and on
every implementation, the same way `safe_address` already works: validation
context that only one signature type reads and the rest ignore.
`safe_version=None` keeps the old both-entrypoints behaviour, so existing
callers are unaffected.

Add `uses_bytes32_eip1271()` as the single home for the version rule and for
its failure mode. `VERSION()` is an arbitrary on-chain string, so it raises
`CannotCheckEIP1271ContractSignature` instead of leaking packaging's
`InvalidVersion` and making every consumer reimplement the translation.
@Uxio0
Uxío (Uxio0) requested a review from a team as a code owner September 2, 2026 09:35
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T09:39:16.591973Z a4a7e52 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4a7e5212f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread safe_eth/safe/safe_signature.py
`_check_eip1271` accepted either magic value whichever entrypoint was called. The
two interfaces declare different ones and the Safe compares against its own,
exactly: `ISignatureValidator.EIP1271_MAGIC_VALUE` is 0x20c13b0b up to 1.4.1 and
0x1626ba7e from 1.5.0.

Every Safe fallback handler returns the value matching the entrypoint, so no Safe
signer hits this. An owner can be any EIP-1271 contract though, and one that
answers the other interface's value was reported valid here and then reverted
GS024 on the verifying Safe.

Pass the expected value per entrypoint and compare against it alone, sync and
async.
@Uxio0

Copy link
Copy Markdown
Member Author

Good catch, and it holds. Checked the constants in safe-smart-account:

Verifying Safe Calls Requires
v1.3.0 / v1.4.1 isValidSignature(bytes,bytes) 0x20c13b0b
v1.5.0 isValidSignature(bytes32,bytes) 0x1626ba7e

Both are named ISignatureValidator.EIP1271_MAGIC_VALUE, but they are different values per version (ISignatureValidator.sol:6 in v1.3.0/v1.4.1, :7 in v1.5.0), and the Safe compares against its own one exactly — Safe.sol:315 in v1.4.1, Safe.sol:270 in v1.5.0.

Worth noting no Safe signer can hit this: the v1.4.1 handler returns UPDATED_MAGIC_VALUE (0x1626ba7e) from the bytes32 entrypoint and EIP1271_MAGIC_VALUE from the legacy one, and the v1.5.0 handler returns 0x1626ba7e. So the pairing always matches for a Safe owner. But an owner can be any EIP-1271 contract, and one answering the other interface's value was reported valid here and then reverted GS024 on-chain — the same false accept this PR is about, one layer down.

Fixed in f85e4eb: _check_eip1271 takes the expected value and compares against it alone, sync and async.

On testing it: the existing suite already covers the pairing being right, since a crossed pairing would fail every contract-signature test at 1.4.1 or 1.5.0. Reaching the rejection needs a signer no Safe can be, so there is a stubbed fallback handler returning the legacy value from the bytes32 entrypoint, asserted valid before the stub and invalid under it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant