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
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