The vulnerability (live, current contracts)
SidecarRegistry.registerFirstMasterDevice is unauthenticated first-call-wins:
- No binding between
operatorOmni and msg.sender — it blindly sets operatorMasterWallet[operatorOmni] = msg.sender (crates/agentkeys-chain/src/SidecarRegistry.sol:118-123).
- It already accepts an
attestation param but ignores it (:143 — attestation; // accepted but only emitted via event topics).
- The doc comment rationalizes "no K11 assertion is required (chicken-and-egg)" (
:102-103) — that shortcut is the bug.
Attack: an attacker watches the mempool, copies the victim's operatorOmni from a pending registerFirstMasterDevice calldata, and front-runs with their own msg.sender. If they land first, operatorMasterWallet[victimOmni] becomes the attacker → the real operator is permanently locked out of their own omni. (Surfaced by the codex review on #162 — docs/plan/web-flow/wire-real-paths-security-review.md, CRITICAL.)
Why this is fixable INDEPENDENTLY (do before #163 / #164)
Proposed fix — Approach A (recommended): K11 self-attestation at bootstrap
Use the already-present attestation param. Require the caller to prove possession of the K11 key being registered by verifying a P-256 signature over a challenge that binds the sender, via the existing K11Verifier:
challenge = H( "agentkeys-bootstrap-v1" || chainId || address(this)
|| msg.sender || operatorOmni || actorOmni || deviceKeyHash
|| k11PubX || k11PubY )
require(k11Verifier.verifyAssertion(challenge, attestation, k11PubX, k11PubY));
A front-runner who copies the victim's operatorOmni + attestation but uses their own msg.sender fails: the recomputed challenge contains the attacker's sender, so the victim's P-256 signature won't verify. They can't forge a victim-K11 signature, and using their own K11 key yields a different operatorOmni. → front-run defeated. This is the authorization_proof arch.md §9/§10 intended (self-attestation for the first device — not a prior-master attestation, so no chicken-and-egg).
Alternative — Approach B (simpler, weaker)
require(operatorOmni == sha256("agentkeys" || "evm" || lowercaseHex(msg.sender))) — an attacker's sender yields a different omni, so the victim's omni is unsquattable. Cheaper (no P-256), but does not prove K11 possession (a self-harming first-master could register a k11Pub they don't control). Recommend A.
Scope
Interim operational mitigation (not the fix)
Until the contract ships, submit the bootstrap tx via a non-gossiping RPC / private inclusion so it isn't exposed to a public mempool. Codex: "private tx is temporary hardening, not the auth model."
Relationships
The vulnerability (live, current contracts)
SidecarRegistry.registerFirstMasterDeviceis unauthenticated first-call-wins:operatorOmniandmsg.sender— it blindly setsoperatorMasterWallet[operatorOmni] = msg.sender(crates/agentkeys-chain/src/SidecarRegistry.sol:118-123).attestationparam but ignores it (:143—attestation; // accepted but only emitted via event topics).:102-103) — that shortcut is the bug.Attack: an attacker watches the mempool, copies the victim's
operatorOmnifrom a pendingregisterFirstMasterDevicecalldata, and front-runs with their ownmsg.sender. If they land first,operatorMasterWallet[victimOmni]becomes the attacker → the real operator is permanently locked out of their own omni. (Surfaced by the codex review on #162 —docs/plan/web-flow/wire-real-paths-security-review.md, CRITICAL.)Why this is fixable INDEPENDENTLY (do before #163 / #164)
cast-based bootstrap (the harness + wire-demo path,scripts/heima-device-register.sh) — not just the future web flow. It is a live vuln today.msg.senderis (an EOA today, the smart-account address later).Proposed fix — Approach A (recommended): K11 self-attestation at bootstrap
Use the already-present
attestationparam. Require the caller to prove possession of the K11 key being registered by verifying a P-256 signature over a challenge that binds the sender, via the existingK11Verifier:A front-runner who copies the victim's
operatorOmni+attestationbut uses their ownmsg.senderfails: the recomputed challenge contains the attacker's sender, so the victim's P-256 signature won't verify. They can't forge a victim-K11 signature, and using their own K11 key yields a differentoperatorOmni. → front-run defeated. This is theauthorization_proofarch.md §9/§10 intended (self-attestation for the first device — not a prior-master attestation, so no chicken-and-egg).Alternative — Approach B (simpler, weaker)
require(operatorOmni == sha256("agentkeys" || "evm" || lowercaseHex(msg.sender)))— an attacker's sender yields a different omni, so the victim's omni is unsquattable. Cheaper (no P-256), but does not prove K11 possession (a self-harming first-master could register a k11Pub they don't control). Recommend A.Scope
registerFirstMasterDeviceon the K11 self-attestation (reuseK11Verifier); drop the "no K11 at bootstrap" shortcut.scripts/heima-device-register.sh/heima-register-first-master.sh(+ the daemon ui-bridgeregister-masterpath) — reuse the existingagentkeys k11 assert/k11_webauthnmachinery.SidecarRegistryon Heima → updatedocs/spec/deployed-contracts.md+scripts/operator-workstation.env+ re-runverify-heima-contracts.sh.Interim operational mitigation (not the fix)
Until the contract ships, submit the bootstrap tx via a non-gossiping RPC / private inclusion so it isn't exposed to a public mempool. Codex: "private tx is temporary hardening, not the auth model."
Relationships
crates/agentkeys-chain/src/SidecarRegistry.sol:100-144,K11Verifier.sol; reviewdocs/plan/web-flow/wire-real-paths-security-review.md.