feat(eip8130): enshrined authenticator dispatch (Authenticate step)#3467
feat(eip8130): enshrined authenticator dispatch (Authenticate step)#3467chunter-cb wants to merge 2 commits into
Conversation
Add `base-execution-eip8130`, a stateless crate implementing step 2
("Authenticate") of the EIP-8130 validation flow: route a signing hash +
authentication blob to the canonical authenticator, verify the signature, and
return the resolved actorId.
Enshrines the canonical authenticator set as native Rust keyed by the
`Eip8130Contracts` CREATE2 addresses:
- native secp256k1 ecrecover (EOA / ECRECOVER sentinel; raw semantics)
- K1 authenticator contract (OZ ECDSA.recover: v in {27,28}, reject high-s)
- P-256 raw (low-s enforced; actorId = keccak256(x||y))
- WebAuthn (mirrors OZ WebAuthn.verify, requireUV = false)
- Delegate (depth-1; verifies nested signature and surfaces the nested-actor
authorization obligation for the stateful authorize stage)
Enshrinement is a protocol fast-path, not a precompile: it does not shadow the
authenticator addresses, so ordinary EVM calls still hit the deployed contracts.
The native logic must stay byte-identical to those contracts (pinned via the
registry init_code_hash constants); a differential parity test is a follow-up.
15 self-consistent crypto + reject-path tests.
| # crypto | ||
| k256.workspace = true | ||
| p256.workspace = true |
There was a problem hiding this comment.
Production code imports k256::ecdsa::{RecoveryId, Signature, VerifyingKey} and p256::ecdsa::{Signature, VerifyingKey, signature::hazmat::PrehashVerifier}, which require the ecdsa feature on both crates. Currently ecdsa is only enabled in [dev-dependencies] (line 34-35), not here in [dependencies].
This compiles today only because Cargo feature unification merges the ecdsa feature from other workspace crates (e.g. base-common-precompiles, base-proof-tee-registrar). If those crates are ever removed, or if this crate is compiled in a minimal workspace / published standalone, the build will fail.
| # crypto | |
| k256.workspace = true | |
| p256.workspace = true | |
| k256 = { workspace = true, features = ["ecdsa"] } | |
| p256 = { workspace = true, features = ["ecdsa"] } |
| /// P-256 raw authenticator. `data = r(32) || s(32) || x(32) || y(32) || pre_hash(1)`. | ||
| /// `actorId = keccak256(x || y)`. | ||
| fn p256(hash: B256, data: &[u8]) -> Result<B256, AuthError> { | ||
| if data.len() != 129 { | ||
| return Err(AuthError::MalformedAuth); | ||
| } | ||
| let (r, s, x, y) = (&data[0..32], &data[32..64], &data[64..96], &data[96..128]); | ||
| Self::p256_verify(hash.as_slice(), r, s, x, y)?; | ||
| Ok(keccak256([x, y].concat())) |
There was a problem hiding this comment.
The doc comment says data = r(32) || s(32) || x(32) || y(32) || pre_hash(1) and the length check enforces exactly 129 bytes, but data[128] (the pre_hash byte) is never read or validated. Two concerns:
-
Parity risk: if the deployed P256 authenticator contract actually inspects this byte (e.g. to select prehash mode), the enshrined path silently ignores it, which could produce divergent accept/reject behavior between the EVM and native paths.
-
Dead input: if the byte is genuinely unused by the contract too, consider documenting that it's a padding/reserved byte that must be present but is ignored, so future readers don't wonder whether it's a bug.
| /// Narrows a `clientDataJSON` index from `uint256` to `usize`, rejecting | ||
| /// implausibly large values. | ||
| fn index(value: &U256) -> Result<usize, AuthError> { | ||
| if *value > U256::from(u32::MAX) { | ||
| return Err(AuthError::MalformedAuth); | ||
| } | ||
| Ok(value.as_limbs()[0] as usize) | ||
| } |
There was a problem hiding this comment.
The guard *value > U256::from(u32::MAX) allows values up to u32::MAX (4 GiB), but then extracts with value.as_limbs()[0] as usize. On a 32-bit target, usize is 32 bits and as_limbs()[0] is a u64, so values between u32::MAX + 1 and u64::MAX that pass the check would silently truncate. This isn't a practical concern for this crate (it won't run on 32-bit), but for robustness you could use:
usize::try_from(value.as_limbs()[0]).map_err(|_| AuthError::MalformedAuth)which is correct on all platforms and eliminates the separate range check entirely.
| base64.workspace = true | ||
|
|
||
| # alloy | ||
| alloy-primitives = { workspace = true, features = ["k256"] } |
There was a problem hiding this comment.
The k256 feature on alloy-primitives enables secp256k1 key/signature integration (e.g. alloy_primitives::Signature::recover_address_from_prehash). This crate doesn't use any of that — it does ecrecover directly via the k256 crate. Unless this feature is needed transitively by alloy-sol-types decoding, consider dropping it:
| alloy-primitives = { workspace = true, features = ["k256"] } | |
| alloy-primitives.workspace = true |
- Enable the `ecdsa` feature explicitly on `k256`/`p256` in [dependencies] instead of relying on workspace feature unification (the dev-dependency duplicates are now redundant and removed). - Drop the unused `k256` feature on `alloy-primitives` (ecrecover is done via the `k256` crate directly). - Document that the P-256 `pre_hash` byte (data[128]) is reserved: the deployed contract requires its presence but never reads it, and we mirror that for parity. - Make `index()` pointer-width-correct via `usize::try_from` on all targets.
|
Addressed the review in
|
Review Summary —
|
✅ base-std fork tests: all 616 passedbase/base is fully in sync with the base-std spec.
|
Summary
Adds
base-execution-eip8130, the first piece of the EIP-8130 stateful validation pipeline that works for both mempool admission and block inclusion. It implements step 2 of the validation flow — Authenticate — as a stateless, pure function:The canonical authenticator set is enshrined as native Rust, keyed by the canonical CREATE2 addresses from
Eip8130Contracts(the registry added in #3440, which this stacks on):ECRECOVERsentinel, EOA path)bytes20(recovered)v ∈ {0,1,27,28}, anysK1_AUTHENTICATORcontractbytes20(recovered)ECDSA.recover: requiresv ∈ {27,28}, rejects high-sP256_AUTHENTICATORkeccak256(x‖y)senforced (OZP256.verify)WEBAUTHN_AUTHENTICATORkeccak256(x‖y)WebAuthn.verify,requireUV = falseDELEGATE_AUTHENTICATORbytes20(delegate)REVOKED_AUTHENTICATORand any non-canonical address are hard-rejected.This is a protocol fast-path, not an EVM precompile. It does not shadow the authenticator addresses: ordinary EVM
CALL/STATICCALLto those addresses still execute the real deployed contract bytecode (e.g.AccountConfiguration.verifySignature()/ wallet code / non-8130 chains). The native code here is invoked only by the protocol's own AA validation path.Consequence: the native implementation MUST produce byte-identical
actorIdresults to the deployed contracts. The enshrined logic is pinned to a contract version via the*_INIT_CODE_HASHconstants inEip8130Contracts— a bytecode change shifts the canonical address (caught by the registry drift test) and requires re-validating parity here. A differential parity test against the deployed contracts (via the EVM) is a planned follow-up.Scope (what this PR is not)
This crate is stateless: no storage reads, no EVM. The stateful Authorize step is layered on top in the next PR —
actor_configlookup, the implicit-EOA rule, scope/expiry, and (for delegate) authorizing the nested actor against the delegated account's config in SIGNATURE context.For the delegate authenticator, dispatch verifies the nested signature and returns a
DispatchOutcome::Delegated { delegate_account, nested_authenticator, nested_actor_id, .. }obligation for the authorize stage to discharge against state.Open question for review
The registry lists both the native
ECRECOVERsentinel (address(1)) and the deployedK1_AUTHENTICATORcontract. The spec's canonical k1 is the native sentinel, but the deployed K1 contract uses OZECDSA.recover(stricterv, low-s). This PR routes them to different semantics to stay faithful to each. If we want a single k1 behavior, that should be decided before block-validation wiring.Test plan
cargo test -p base-execution-eip8130— 15 self-consistent crypto + reject-path tests (ecrecover/K1 strictness divergence, high-srejection, P-256, WebAuthn challenge-binding, delegate obligation + depth-1/non-canonical rejection, revoked/non-canonical/malformed-length rejects)cargo clippy -p base-execution-eip8130 --all-targets -- -D warningscargo fmt --checkStacked on #3440 (
Eip8130Contractsregistry).