Skip to content

fix(chain): require K11 self-attestation at first-master bootstrap (closes #165)#166

Merged
hanwencheng merged 1 commit into
mainfrom
claude/fix-bootstrap-frontrun
Jun 2, 2026
Merged

fix(chain): require K11 self-attestation at first-master bootstrap (closes #165)#166
hanwencheng merged 1 commit into
mainfrom
claude/fix-bootstrap-frontrun

Conversation

@hanwencheng
Copy link
Copy Markdown
Member

The vulnerability (CRITICAL, #165)

SidecarRegistry.registerFirstMasterDevice was unauthenticated first-call-wins: it set operatorMasterWallet[operatorOmni] = msg.sender with no binding between operatorOmni and the sender, and its attestation param was accepted but ignored (SidecarRegistry.sol:143, pre-fix). An attacker watching the mempool could copy the victim's operatorOmni and front-run with their own sender → permanent operator lockout. Surfaced by the codex adversarial review on #162.

This is a live vuln in the current EOA/cast bootstrap, independent of the web flow (#163) and the ERC-4337 migration (#164).

The fix — Approach A: K11 self-attestation bound to msg.sender

registerFirstMasterDevice now requires a K11 P-256 self-attestation, verified against the to-be-registered pubkey (k11PubX/k11PubY — there's no prior device at bootstrap) over:

challenge = keccak256(abi.encode(
  OP_REGISTER_1ST_MASTER, operatorOmni, actorOmni, deviceKeyHash,
  k11PubX, k11PubY, roles, msg.sender, block.chainid, address(this)))

Why it defeats the front-run: the challenge commits msg.sender. A front-runner replaying the victim's captured assertion with their own sender → the contract recomputes a different challenge → the embedded clientDataJSON challenge no longer matches → verifyAssertion rejects. The attacker can't forge the operator's K11 signature, and using their own K11 key yields a different operatorOmni. No chicken-and-egg: the attesting key is the key being registered (a self-attestation), enrolled by bootstrap time (arch.md §9 stage 2).

Survives ERC-4337 (#164) unchanged — the binding is to msg.sender (an EOA today, the smart-account address later).

What's in this PR

  • SidecarRegistry.sol — add OP_REGISTER_1ST_MASTER; replace the ignored bytes attestation with a K11Assertion selfAttestation; verify via the existing K11Verifier + store the read signCount.
  • AgentKeysV1.t.sol — new test_RegisterFirstMaster_RejectsBogusSelfAttestation (unauthenticated path closed) and test_RegisterFirstMaster_RejectsFrontRunWithDifferentSender (captured assertion is non-transferable to another sender; the legit operator still bootstraps); existing happy-path + helper updated to mock the verifier (real P-256 is covered in K11Verifier.t.sol / P256Verifier.t.sol). forge test: 41 passed, 0 failed.
  • heima-register-first-master.sh — header note documenting the new ABI + the self-attestation challenge + the coordinated-redeploy activation (see below). The live cast call is left on the old ABI so it keeps working against the currently-deployed registry.

Activation (coordinated redeploy — follow-up, documented in #165)

The contract isn't redeployed by this PR. To activate: redeploy SidecarRegistry on Heima + flip heima-register-first-master.sh's cast call to the new ABI + generate the self-attestation (mirror scripts/heima-scope-set.sh --webauthn) + update docs/spec/deployed-contracts.md + scripts/operator-workstation.env + re-run verify-heima-contracts.sh. This step needs a live authenticator, so it's a deliberate operator action, not part of this code PR. The harness skips first-master in CI, so nothing breaks meanwhile.

Scope notes

Closes #165.

🤖 Generated with Claude Code

, anti-front-run)

registerFirstMasterDevice was unauthenticated first-call-wins: an attacker could
copy the victim's operatorOmni from the mempool and front-run with their own
msg.sender, permanently locking the operator out (operatorMasterWallet[omni] =
attacker). The 'attestation' bytes param was accepted but ignored.

Fix (Approach A from #165 / the security review): require a K11 P-256 SELF-attestation
verified against the to-be-registered pubkey (k11PubX/k11PubY) over a challenge that
binds msg.sender + operator/actor omni + deviceKeyHash + k11Pub + chainid + contract.
A captured assertion is non-transferable: a different sender → different challenge →
the embedded clientDataJSON challenge no longer matches → verifyAssertion rejects; and
an attacker cannot forge the operator's K11 signature. No chicken-and-egg — the
attesting key IS the key being registered (a self-attestation), enrolled by bootstrap
time (arch.md §9 stage 2). The binding is to msg.sender, so it survives the ERC-4337
migration unchanged (EOA today, smart-account address later).

- SidecarRegistry.sol: add OP_REGISTER_1ST_MASTER; replace the ignored `bytes attestation`
  with a `K11Assertion` self-attestation; verify + store the read signCount.
- AgentKeysV1.t.sol: +RejectsBogusSelfAttestation (unauthenticated path closed) and
  +RejectsFrontRunWithDifferentSender (captured assertion non-transferable; legit
  operator still bootstraps); update happy-path + helper to mock the verifier (real
  P-256 is covered in K11Verifier.t.sol / P256Verifier.t.sol). 41 forge tests pass.
- heima-register-first-master.sh: header note — the live cast call is the old ABI;
  flip it to the new ABI + self-attestation IN THE SAME CHANGE that redeploys
  SidecarRegistry (coordinated; needs a live authenticator). Harness skips first-master.

Activation = redeploy SidecarRegistry on Heima + update deployed-contracts.md +
operator-workstation.env + verify-heima-contracts.sh (documented in #165).
@hanwencheng hanwencheng merged commit d3475fb into main Jun 2, 2026
7 checks passed
hanwencheng added a commit that referenced this pull request Jun 2, 2026
…4337, #168 Cancun, #166 bootstrap)

The chain side this plan waited on has landed; reconcile the web-wiring plan with it
and correct two now-false facts.

- Top status banner: ERC-4337 P-256 master account is IMPLEMENTED (#171, Solution A) —
  EntryPoint v0.7 + P256Account + factory + VerifyingPaymaster live on Heima mainnet; #164
  closed. Heima is Cancun, not London (#168). Bootstrap front-run fixed (#166). Defers to
  docs/plan/chain/erc4337-master-account.md.
- §11 (gating): retitled '✅ RESOLVED by #171 (historical)' + a correction note; the
  'does-NOT-auto-fix' block rewritten as 'all addressed' (bootstrap #166; full-intent
  structural via userOpHash; agent-bind/multi-device #171 E3/E4/E5); fixed the false
  'London / no PUSH0' claim → Cancun + pure-Solidity P-256 (~707k gas, no RIP-7212).
- §4.3 + §12 X4: the K11→chain bridge is now an ERC-4337 UserOp (passkey-signs userOpHash
  → broker-gated bundler / handleOps → P256Account); reference erc4337-webauthn-sign.py +
  erc4337-master-e8.sh. Retired the cast/--assertion-file path.
- §5a/§5b: onboarding stage 2 derives the P256Account (CREATE2 from passkey); stage 4 =
  one initCode+registerFirstMasterDevice UserOp (= chain-plan E7, routed through #163);
  bind/grant via UserOp; setScopeWithWebauthn→setScope (E3). §6 W2 + §9 risk updated.
- security-review.md: header marks every finding addressed (#166/#171); Cancun.
- data-model.md phone-first note + arch.md §22c.3: 'decided/London/delegate' → 'implemented
  #171 / Cancun #168 / passkey-signed UserOp'.

Incorporates #163 comments (codex prerequisites + Kailai's Cancun/P-256 feasibility).
hanwencheng added a commit that referenced this pull request Jun 2, 2026
…162)

* docs(web-flow): plan to wire parent-control UI to real backends (learn from wire demo)

Add docs/plan/web-flow/wire-real-paths.md — an execution plan for turning every
narrated / in-memory-stub path in the parent-control UI + daemon ui-bridge into the
SAME real calls harness/phase1-wire-demo.sh makes (broker auth, broker cap-mint,
on-chain cast writes, S3-backed memory worker).

Key decisions captured:
- Daemon-as-orchestrator: browser → daemon ui-bridge only; daemon makes the real
  broker/chain/worker calls (the data-model.md seam).
- Reuse what exists: the daemon's proxy.rs broker client (reqwest + bearer +
  fail-closed) for broker calls; shell out to the existing agentkeys CLI +
  scripts/heima-*.sh (cast) for chain writes — no new Rust chain client.
- The load-bearing K11 bridge: browser does navigator.credentials WebAuthn → daemon
  injects the assertion into the chain tx via a new --assertion-file mode on
  heima-scope-set.sh (avoids a double Touch ID).
- Per-flow wiring tables (onboarding §9 stages 0–4, pairing §10.2, memory) mapping
  UI surface → data-model.md endpoint → real backend call → arch ref.
- Sequenced phases W0–W6, harness-parity test, and reconciliation of stale specs
  (superseded bootstrap endpoints, --upgrade no-op).

Cross-linked from issue-9step-flow.md as the execution detail for P2.1–P2.4.

* docs(web-flow): phone-first host model + verified gating decision + WASM lift scope

Amends the wiring plan for the phone-first reality (most operators have only a
phone, no desktop), keeping one consistent implementation across hosts:

- wire-real-paths.md §0.5 (host-model decision): factor the master-plane logic into
  one portable agentkeys-core hosted as WASM (web) / native lib (mobile, via UniFFI) /
  daemon (desktop), all behind the same lib/client AgentKeysClient contract. The daemon
  is demoted to one host, not a requirement; the broker is the only always-on component;
  the master plane is event-driven + biometric-gated (push-woken).
- wire-real-paths.md §11 (gating decision, VERIFIED): read SidecarRegistry.sol +
  AgentKeysScope.sol. Every master write is msg.sender-bound to the operator secp256k1
  key; the K11 P-256 assertion is an additional gate, not a substitute. => no relayer /
  key-free path without a contract change. Phone holds the secp256k1 key in the Keychain
  (SE is P-256-only, so it seals the K11 passkey, not the EVM key); browser/WASM cannot
  custody it and must delegate the broadcast. Fork (A) keep msg.sender-bound vs (B) move
  to assertion-only auth — recommend (A) for the phone MVP.
- wire-real-paths.md §12 (WASM lift scope): agentkeys-core carve-out, wasm-bindgen
  exports, CoreBackend, WebAuthn interop, chain-write delegation — shared with the future
  mobile UniFFI shell.
- data-model.md: flag the browser-direct prohibition as desktop-first, relaxed for the
  master plane (chain writes still constrained per §11).
- arch.md §22c.3: master control-plane host-model paragraph (defers to the plan).

* docs(web-flow): adversarial security review of §11 gating (codex) — (A) not sound as written

Codex adversarial review of the §11 gating decision, verified against the contracts.
Verdict: fork (A) is the right direction (contracts are NOT assertion-only-safe) but
NOT sound as written. Findings (full doc: wire-real-paths-security-review.md):
- CRITICAL: registerFirstMasterDevice unauthenticated first-call-wins → front-runnable
  operator lockout (SidecarRegistry.sol:100-123).
- HIGH: registerAgentDevice/revokeAgentDevice are msg.sender-only, no K11 (:214-251) —
  a compromised master EVM key binds rogue agents with no biometric.
- HIGH: add-master K11 challenge omits newActorOmni + K11 cred/pubkey/attestation (:167-193).
- HIGH: 'phone holds the key' = software secp256k1 root (not SE-sealed) — weaker than the
  K11 hardware promise; model it as a first-class key.
- HIGH: single global operatorMasterWallet (:66) ⇒ multi-device/recovery story incomplete.
- HIGH: browser→host delegation needs a native confirmation that re-derives the challenge.
- MEDIUM: AgentKeysScope doesn't update WebAuthn signCount; fork (B) unsafe until full-intent
  K11 binding lands on every path.
Confirms the relayer analysis: non-custodial relayer impossible under (A) without
meta-tx/ERC-4337 (EIP-2771 sponsors gas but still needs the secp key).
§11 recommendation updated to reflect 'not sound as written' + required-changes checklist.

* docs(web-flow): record ERC-4337 P-256 smart-account master as the confirmed decision

Supersedes the §11 fork-A-as-MVP framing. The master becomes an ERC-4337 smart account
whose validateUserOp verifies a P-256 (K11/passkey) signature; a bundler broadcasts, an
optional paymaster sponsors gas. Resolves the codex findings:
- removes the software-secp256k1 root (clients sign UserOps with the SE-sealed passkey only);
- key-free + relayer in one (no custodial relayer; bundler + paymaster);
- account address is the stable master → multiple passkeys + quorum recovery (multi-device gap);
- web + mobile become symmetric full masters → the browser→host delegation hop dissolves;
- reuses existing on-chain P-256 verify (K11Verifier.sol).
Residual (folded into the contract-hardening issue): authenticated first-master bootstrap
(CRITICAL), full-intent binding in validateUserOp, and Heima EntryPoint + Solidity-P-256
(London-level, no RIP-7212). Updated §0.5 table, §11 (decision block), §12 (X4/scope),
the security-review doc, and arch.md §22c.3.

* docs(web-flow): revise #162 plan for the landed chain work (#171 ERC-4337, #168 Cancun, #166 bootstrap)

The chain side this plan waited on has landed; reconcile the web-wiring plan with it
and correct two now-false facts.

- Top status banner: ERC-4337 P-256 master account is IMPLEMENTED (#171, Solution A) —
  EntryPoint v0.7 + P256Account + factory + VerifyingPaymaster live on Heima mainnet; #164
  closed. Heima is Cancun, not London (#168). Bootstrap front-run fixed (#166). Defers to
  docs/plan/chain/erc4337-master-account.md.
- §11 (gating): retitled '✅ RESOLVED by #171 (historical)' + a correction note; the
  'does-NOT-auto-fix' block rewritten as 'all addressed' (bootstrap #166; full-intent
  structural via userOpHash; agent-bind/multi-device #171 E3/E4/E5); fixed the false
  'London / no PUSH0' claim → Cancun + pure-Solidity P-256 (~707k gas, no RIP-7212).
- §4.3 + §12 X4: the K11→chain bridge is now an ERC-4337 UserOp (passkey-signs userOpHash
  → broker-gated bundler / handleOps → P256Account); reference erc4337-webauthn-sign.py +
  erc4337-master-e8.sh. Retired the cast/--assertion-file path.
- §5a/§5b: onboarding stage 2 derives the P256Account (CREATE2 from passkey); stage 4 =
  one initCode+registerFirstMasterDevice UserOp (= chain-plan E7, routed through #163);
  bind/grant via UserOp; setScopeWithWebauthn→setScope (E3). §6 W2 + §9 risk updated.
- security-review.md: header marks every finding addressed (#166/#171); Cancun.
- data-model.md phone-first note + arch.md §22c.3: 'decided/London/delegate' → 'implemented
  #171 / Cancun #168 / passkey-signed UserOp'.

Incorporates #163 comments (codex prerequisites + Kailai's Cancun/P-256 feasibility).
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.

[CRITICAL] Harden registerFirstMasterDevice against bootstrap front-run (K11 self-attestation)

1 participant