Skip to content

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

@hanwencheng

Description

@hanwencheng

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 (:143attestation; // 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 #162docs/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

  • Contract: gate registerFirstMasterDevice on the K11 self-attestation (reuse K11Verifier); drop the "no K11 at bootstrap" shortcut.
  • Bootstrap callers produce the attestation: scripts/heima-device-register.sh / heima-register-first-master.sh (+ the daemon ui-bridge register-master path) — reuse the existing agentkeys k11 assert / k11_webauthn machinery.
  • Tests: front-run repro (attacker can't reuse the victim's attestation with a different sender; can't bootstrap the victim's omni); happy-path bootstrap still works.
  • Redeploy SidecarRegistry on Heima → update docs/spec/deployed-contracts.md + scripts/operator-workstation.env + re-run verify-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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions