Skip to content

fix(eip8130): canonical sender_auth encodings (tx-hash/gas malleability) - #4439

Open
chunter-cb wants to merge 2 commits into
eip8130-integrationfrom
feat/eip8130-auth-canonicalization
Open

fix(eip8130): canonical sender_auth encodings (tx-hash/gas malleability)#4439
chunter-cb wants to merge 2 commits into
eip8130-integrationfrom
feat/eip8130-auth-canonicalization

Conversation

@chunter-cb

Copy link
Copy Markdown
Contributor

Summary

Closes an auth-encoding malleability + gas-griefing vector on the EIP-8130 pipeline.

sender_auth/payer_auth cannot be covered by the sender/payer signature hashes (a signature can't sign over itself), yet every auth byte is billed as EIP-2028 payload gas (Eip8130IntrinsicGas::payload_cost folds over the full signed encoding). Because two distinct byte encodings were accepted for the same authorization, any relayer could mutate the auth blob to:

  • mint a second valid transaction hash for the same signer (txid malleability), and
  • inflate sender-intrinsic gas, shrinking the execution-gas budget so signed call phases OOG-revert after inclusion — nonce consumed, fee paid, earlier phases committed — all without the signing key.

Root causes fixed

  1. EOA sender v — the empty-sender path recovered through alloy_primitives::Signature::try_from, whose normalize_v maps {0,1,27,28} to the same parity. So v could be flipped to a non-canonical byte and still recover the same signer. Now enforce a strict 65-byte length and v ∈ {27,28} (Electrum notation) before parsing, matching the k1 authenticator's existing recover_k1 check. Applied in the shared recover_eoa_sender_with, so the checked and unchecked paths agree (no path can accept a tx another rejects). Honest tooling (Signature::as_bytes27 + y_parity) is unaffected.

  2. WebAuthnabi_decode_params ignores trailing / non-canonical ABI bytes, which decode to the same value and still pass the P-256 check, but change the tx hash and inflate payload gas. Now require the input to equal its canonical ABI re-encoding (decoded.abi_encode_params() != data → reject). Covers sender and payer auth via the shared dispatch. P-256 already enforced a fixed 129-byte length, so no trailing bytes were possible there.

Tests

  • recover_eoa_sender_rejects_noncanonical_v: rewriting v from {27,28} to the {0,1} encoding of the same parity is rejected on both recovery paths.
  • webauthn_rejects_trailing_bytes: appending an unsigned trailing byte to an otherwise-valid blob is rejected.

Test plan

  • cargo test -p base-execution-eip8130 (187 passed)
  • cargo test -p base-common-consensus --all-features (eip8130 suite green; new test passes)
  • cargo clippy -p base-execution-eip8130 clean (default features)
  • Consider follow-up: broader consensus/e2e test asserting mempool↔inclusion symmetry under auth mutation.

Notes / parity

The tx-level sender/payer signatures and intrinsic-gas accounting are node/protocol concerns, not Keystore.sol surface, so there is no contract-parity regression. The WebAuthn canonical-ABI check is intentionally stricter than OZ abi.decode (which tolerates trailing bytes), but only rejects malleated blobs — honest abi.encode output always matches its canonical re-encoding — so no honest transaction is rejected.

…ash/gas malleability

sender_auth cannot be covered by the sender/payer signature hashes (a
signature cannot sign over itself), yet every auth byte is billed as
EIP-2028 payload gas. Combined with non-canonical auth encodings, a relayer
could mutate the auth blob to mint a second valid transaction hash and shrink
the execution-gas budget for the same signer without the key.

Two encodings were accepted for the same authorization:

- EOA sender path: alloy's parser normalizes v in {0,1,27,28} to the same
  parity, so v could be flipped to a non-canonical byte (txid malleability).
  Enforce v in {27,28} (Electrum notation) and a strict 65-byte length before
  parsing, matching the k1 authenticator's existing check. Applied to both the
  checked and unchecked recovery paths so all entry points agree.

- WebAuthn: abi_decode_params ignores trailing/non-canonical ABI bytes, which
  decode to the same value (and pass the P-256 check) but change the tx hash
  and inflate payload gas. Require the input to equal its canonical ABI
  re-encoding, rejecting only malleated blobs (honest abi.encode output is
  unaffected). Covers both sender and payer auth via the shared dispatch.

Adds consensus tests asserting that changing an unsigned authentication byte
(EOA v -> {0,1}; WebAuthn trailing byte) invalidates the transaction.
@github-actions

Copy link
Copy Markdown
Contributor

Review Summary

This PR correctly identifies and fixes two real auth-encoding malleability vectors in the EIP-8130 pipeline. The changes are minimal, well-targeted, and thoroughly documented.

Changes Reviewed

1. EOA sender v canonicalization (signed.rs): Adds a pre-parse guard in recover_eoa_sender_with requiring sender_auth to be exactly 65 bytes with v in {27, 28}. This closes the gap where alloy Signature::try_from would accept and normalize v in {0, 1} to the same parity, enabling a relayer to flip the v-byte and produce a distinct (but valid) transaction hash.

2. WebAuthn ABI canonicalization (dispatch.rs): After ABI-decoding the WebAuthn blob, re-encodes and compares against the original input to reject non-canonical ABI encodings (trailing bytes, non-minimal dynamic offsets). The abi_encode_params() allocation is negligible relative to the P-256 + SHA-256 work already in this path.

Assessment

No issues found. Both fixes are correct:

  • The EOA v-check is placed in the shared recover_eoa_sender_with, so both checked and unchecked recovery paths are protected. This is consistent with RecoveredActorId::recover_k1 which already enforced v in {27, 28}.
  • The payer path is not affected by the EOA fix because payer auth routes through AuthenticatorDispatch::authenticate -> recover_k1 (which already had the strict v-check) or the WebAuthn path (now protected by the ABI canonicalization).
  • The WebAuthn round-trip check (decoded.abi_encode_params() != data) is the standard technique for enforcing canonical ABI encoding and correctly rejects any relay-mutable framing while accepting all honestly-produced blobs.
  • Tests cover the key mutation scenarios: non-canonical v rewriting and trailing-byte appending.

@chunter-cb
chunter-cb marked this pull request as ready for review August 17, 2026 17:22
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