Skip to content

Spike: EIP-7702 detect-and-refuse — trigger shape + shared chokepoint (PRD-02 #46) #58

Open

Description

@hellno

Sub-issue of #46 (PRD-02). De-risks the EIP-7702 refusal path (#46 risk R1-b) and the shared 7702-auth chokepoint cross-linked to #33. Throwaway spike — output is a decision + test vectors, not merged code. This issue is the complete, self-contained charter: point a fresh agent at it.

Why this spike exists

#46 must "refuse EIP-7702 delegation by default" (DELEGATION_REFUSED) and show a distinct alarming screen. But a 7702 authorization is neither EIP-191 (personal_sign) nor EIP-712 (eth_signTypedData_v4) — it's a distinct (chain_id, address, nonce) tuple signed over the 0x05 magic. So it's unknown how one even reaches PRD-02's signing surface, which means the delegation_refused acceptance test has no defined trigger yet. #33 (session keys) is the same primitive's allow path; per the #46↔#33 cross-link they must share one chokepoint, not two that drift.

Context you can trust (already verified — don't re-litigate)

  • ✅ No new dependency. A throwaway probe compiled clean (EXIT=0):
    use alloy::eips::eip7702::Authorization;          // { chain_id: U256, address: Address, nonce: u64 }
    let h = Authorization { chain_id, address, nonce }.signature_hash();  // keccak256(0x05 ‖ rlp([chain_id,address,nonce]))
    alloy-eip7702 is already in Cargo.lock via alloy-eips; the eips feature is on (crates/deckard-core/Cargo.toml:38). Use alloy::eips::eip7702::{Authorization, SignedAuthorization} directly — do not add a dep.
  • The shape we refuse: the tuple (chain_id, address, nonce) signed over keccak256(0x05 ‖ rlp([chain_id, address, nonce])), riding in a type-4 (SET_CODE, 0x04) tx authorization_list. The auth is signed off-chain — that off-chain signature is what a malicious dapp tries to extract.
  • EIP-7702 session keys v1: chain-enforced per-tx cap + expiry + allowlist, no bundler #33's frozen invariants (the chokepoint must carry these; this spike only exercises the refuse subset): never sign chain_id == 0; delegate MUST be on a hardcoded, versioned audited-delegate allowlist (user/dapp-supplied delegates rejected); self-sponsored grants sign auth nonce +1; un-delegation to address(0) clears code not storage. The nonce+1 / allow path is EIP-7702 session keys v1: chain-enforced per-tx cap + expiry + allowlist, no bundler #33, out of scope here.
  • anvil scaffolding exists (crates/deckard-signerd/tests/common/mod.rs:148-221: start_anvil() / start_anvil_fork()); 7702 needs --hardfork prague. Skip-gracefully when anvil is absent (see anvil_e2e.rs:33).
  • References: spikes/eip1193-railgun/ and spikes/helios-walkaway/ — reuse their standalone-[workspace] Cargo.toml shape + gitignore pattern.

The question to settle

Through PRD-02's signing surface (SignMessage::PersonalSign / SignMessage::TypedDataV4), can a 7702 auth reach the daemon — and in what exact representation? Then: what's the minimal backend-neutral predicate that refuses it (recognize 0x05; chain_id == 0 always; delegate ∉ allowlist always; refuse-by-default), and where does it live so #46 + #33 share it?

Tasks (in order)

  • Pin the alloy 7702 surface — Authorization, SignedAuthorization, signature_hash(), RLP encoding, 0x05 magic, at the locked version. Write down the exact preimage.
  • Survey the trigger shape (the crux) — how, if at all, does a dapp deliver a 7702 auth for signing via standard provider methods? Confirm it's not EIP-191 / EIP-712; document any experimental wallet_*/eth_signAuthorization-style methods + their request JSON. Conclude with one of:
    • (a) No standard path → PRD-02's refusal is a smuggle guard: detect a 0x05-magic preimage (or raw 32-byte hash) presented through personal_sign/eth_sign and refuse.
    • (b) A method exists → PRD-02 needs an explicit SignMessage::Authorization7702 { … } wire variant — specify its fields.
  • Prove the mechanism on anvil (--hardfork prague): sign an Authorization, send the type-4 tx, assert the EOA gains code (eth_getCode non-empty), then un-delegate to address(0) and assert code clears. (Seeds EIP-7702 session keys v1: chain-enforced per-tx cap + expiry + allowlist, no bundler #33's tests later. Uses anvil dev keys only — no mainnet, no real funds.)
  • Build + prove the refusal predicate — throwaway refuse_7702_authorization(auth, allowlist) -> Decision + a vector table: chain_id == 0 → refuse; delegate ∉ empty allowlist → refuse; a MetaMask-style delegate → still refuse; a Porto-style delegate → still refuse. Prove the verdict does not depend on which backend's delegate is used (backend-neutrality).
  • Sketch the chokepoint API + recommend the trigger — propose the public surface of deckard-core/src/eip7702_auth.rs (is_authorization(...), refuse(...), a versioned empty AUDITED_DELEGATES), naming what PRD-02: Clear-signing v2 + message-signing intents #46 calls (refuse-all) vs what EIP-7702 session keys v1: chain-enforced per-tx cap + expiry + allowlist, no bundler #33 later adds (allowlist contents + allow path). Recommend the delegation_refused trigger shape (2a vs 2b).
  • Emit vectors.json of signed authorizations + expected refusal verdicts (mirror crates/deckard-signerd/tests/fixtures/cow/vectors.json) — seeds PRD-02: Clear-signing v2 + message-signing intents #46's delegation_refused acceptance test.

Constraints

  • Runnable spike code (if any) lands as a standalone crate at spikes/eip7702-refusal/ with its own [workspace]; .gitignore target/ + Cargo.lock. Do not edit anything under crates/ or the app.
  • Refuse/read only — never sign a real 7702 authorization with a funded key.
  • No allowlist contents, no nonce+1/grant logic, no UI — those are EIP-7702 session keys v1: chain-enforced per-tx cap + expiry + allowlist, no bundler #33 / PRD-02 proper.
  • Never log or Debug-print a key or signature (Zeroizing).

Done when

Trigger answered (a vs b) with the exact request shape and a YES/NO on "does a 7702 auth reach PRD-02's surface via standard methods?"; a runnable anvil bin that sets and clears a delegation; vectors.json; a short report + the eip7702_auth.rs API sketch with the #46-refuse-all vs #33-allow-path split. No production code merged into crates/.

Report format (return this)

<spike_report>
  <verdict>trigger: NO-STANDARD-PATH (smuggle guard) / METHOD-EXISTS (needs wire variant)</verdict>
  <trigger_shape><!-- exact request JSON / preimage we detect + refuse --></trigger_shape>
  <refusal_predicate_backend_neutral>YES / NO + evidence</refusal_predicate_backend_neutral>
  <chokepoint_api><!-- eip7702_auth.rs public surface; #46 refuse-all vs #33 allow-path --></chokepoint_api>
  <anvil_proof><!-- delegate set + cleared, with the cmds --></anvil_proof>
  <no_new_dep>confirmed (alloy eips) / NEW DEP NEEDED (why)</no_new_dep>
  <what_worked/>
  <what_failed/>
  <recommendation><!-- one paragraph for #46's delegation_refused test + the shared module --></recommendation>
  <artifacts><!-- spike crate path + how to run + vectors.json --></artifacts>
</spike_report>

Refs: #46, #33.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity-relevantsigningMessage/typed-data/transaction signing surface

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions