Skip to content

On-chain K11 gate for agent accept (passkey-account master UserOp) #225

@hanwencheng

Description

@hanwencheng

Context

Today the master's "accept pairing · Touch ID" button (parent-control → pairing) does a deployer-signed registerAgentDevice via scripts/heima-agent-create.sh — there is no real on-chain K11 gate. The button label promises Touch ID, but the dev master is an EOA (scripts/heima-register-first-master.sh, deployer-signs), so the agent-device binding is authorized by the deployer key, not by the operator's Secure-Enclave passkey.

Most of the ERC-4337 P-256 smart-account infra to fix this already landed in #171/#200 (closes #164): contracts E0–E6 + E8 + live mainnet infra (E7 + the cutover remain — see the plan-mapping section below). What's missing is making the master be a passkey-account and routing the browser accept through it, so registerAgentDevice is authorized by an on-chain-verified K11 (WebAuthn P-256 / Touch ID) signature instead of the deployer EOA.

This was split out of the #216 wire work after the interim "server-verified K11 gate" approach was rejected in favor of doing the gate on-chain (the real authority boundary per arch.md §11).

Where this sits in the #164 ERC-4337 plan — this is E7 + the cutover, NOT a new design

P256Account is the ERC-4337 plan (docs/plan/chain/erc4337-master-account.md, "Solution A"); this epic continues that plan, it does not re-invent it. Plan status: E0–E6 + E8 ✅ — EntryPoint v0.7 + CREATE2 account factory live (E1), production P256Account (E2), SidecarRegistry/AgentKeysScope master-writes thinned to msg.sender == masterAccount with setScopeWithWebauthnsetScope in code (E3/E4), guardian M-of-N recovery (E5), broker-co-signed VerifyingPaymaster sponsorship (E6), and an E8 mechanism proof that ran green on Heima mainnet (a passkey-only UserOp landed addSigner via handleOps). What remains, per the plan, is E7 (ceremony binding)"CLI hardware register WIRED; browser frontend + coordinated cutover remain" — and the cutover (redeploy factory+registry+scope to account-auth; flip heima-scope-set.sh setScopeWithWebauthnsetScope).

This epic = E7's browser item + the cutover, applied to the agent-accept mutation, with register+scope batched.

It reuses #200's sponsored-UserOp machinery — same envelope, batched callData. #200 ("#164 sponsored ERC-4337 register") landed Stage A: the broker crates/agentkeys-broker-server/src/sponsor.rs co-sign core — a VerifyingPaymaster-sponsored UserOp, broker-EIP-191-co-signed (the J1-gated Sybil gate) = gas-free. The accept batch UserOp is the same sponsored envelope as #200's register; only the inner callData changes from a single registerAgentDevice to executeBatch([register, setScope]). #200 explicitly deferred Stage B (EntryPoint.handleOps submission — needs an EVM client); E8 proved direct handleOps works and scripts/erc4337-bundler.sh is the always-on layer (not stood up live). This epic depends on Stage B for submission.

Today's gap (why this isn't done): the daemon accept (register_pairingheima-agent-create.sh --from-pubkey, crates/agentkeys-daemon/src/ui_bridge.rs) is a deployer-signed cast send registerAgentDevice that bypasses the entire 4337 stack — no UserOp, no passkey, no sponsor. E7 replaces that with the sponsored, batched, K11-signed UserOp.

Scope

  • Onboarding creates/uses a passkey-account master, not an EOA. The K11 WebAuthn P-256 pubkey (already enrolled via the daemon's /v1/k11/enroll/{begin,finish}) becomes the smart-account's authorized signer. Deploy the ERC-4337 P-256 smart-account (ERC-4337 P-256 smart-account master (#164): plan + contracts E0–E8 + live mainnet infra & demo #171 contracts) for the master at onboarding; persist its address in the master session (rides Persist + rehydrate the master session across daemon restart #220's persist/rehydrate).
  • Browser accept triggers a real WebAuthn assertion (Touch ID) over the accept UserOp hash — navigator.credentials.get() with the enrolled K11 credential. No assertion ⇒ no register (the gate the button label already promises).
  • Accept submits ONE atomic batch UserOp — P.2 (register) + P.3 (scope) together (see the design note below). The daemon builds P256Account.executeBatch([registerAgentDevice, setScope]), attaches the single P-256 WebAuthn signature, and submits via EntryPoint.handleOps. This replaces both the deployer-signed heima-agent-create.sh shell-out in register_pairing AND the separate heima-scope-set.sh scope step (crates/agentkeys-daemon/src/ui_bridge.rs).
  • Requested scope flows from claim → accept. The per-namespace memory:<ns> set the operator approves is the setScope argument in the batch. (Depends on Wire web-app agent pairing to the real claim → register → scope flow #214 wiring the requested scope through the claim form, today empty.)

Design note — atomic P.2 + P.3 in a single block (the "one block" win)

All building blocks already exist on-chain; batching is daemon-side UserOp assembly with zero contract changes:

  • P256Account.executeBatch(address[] dest, uint256[] value, bytes[] func) (src/P256Account.sol:191) runs N inner calls from one UserOp. Its _call (src/P256Account.sol:203) re-throws on any inner revert, so the batch is all-or-nothing.
  • Both inner targets are already msg.sender == master-gatedSidecarRegistry.registerAgentDevice (src/SidecarRegistry.sol:271) and AgentKeysScope.setScope (src/AgentKeysScope.sol:82). When the master account fans out via executeBatch_call, msg.sender is the master account for both, so both guards pass unchanged. (Use setScope, the msg.sender-gated variant — NOT setScopeWithWebauthn, which is the EOA/cast path that carries the assertion in calldata.)
  • One K11 signature gates the whole batch. The assertion validates the UserOp at P256Account validation, not per inner call → a single Touch ID covers register + scope.
EntryPoint.handleOps([ UserOp{
  sender:   masterAccount,
  callData: executeBatch(
    [SIDECAR_REGISTRY,           AGENTKEYS_SCOPE],
    [0,                          0],
    [registerAgentDevice(dkh, op, actor, linkCode, popSig),
     setScope(op, actor, /* per-ns memory:<ns> bits */ ...)]),
  signature: <K11 WebAuthn P-256 assertion over userOpHash>
}])

Result: 2 txs → 1, 2 blocks → 1, 2 Touch IDs → 1, and atomicity — the partial-pairing failure (device registered, scope grant fails, agent stuck at 0 namespaces) becomes structurally impossible. Batching is NOT possible on the EOA path (an EOA tx has a single to), which is the additional reason this rides the passkey-account migration rather than shipping standalone.

Acceptance

  • Clicking accept in the web UI raises a real Touch ID prompt; cancelling it aborts the whole batch (observable: no registerAgentDevice tx, pending request unchanged, scope unchanged).
  • A single on-chain tx (one EntryPoint.handleOps, one block) performs BOTH registerAgentDevice and setScope, sent from the passkey-account master, its UserOp carrying a P-256 signature that EntryPoint + the account's P-256 verifier accept — verifiable on-chain (the signer is the K11 pubkey, not the deployer EOA).
  • Atomicity proven: a forced setScope revert (e.g. malformed scope) rolls back the registerAgentDevice in the same tx — no half-paired agent (harness negative step).
  • scripts/verify-heima-contracts.sh + a harness step prove the end-to-end: enroll K11 → deploy passkey-account master → accept (Touch ID) → one batched UserOp lands register+scope, authorized by K11; the agent immediately shows its granted memory:<ns> namespaces.
  • The interim server-verified gate is NOT built (explicitly rejected — gate lives on-chain).

Dependencies / relations

Effort

~XL — spans onboarding (passkey-account deploy), browser WebAuthn assertion over a UserOp hash, daemon executeBatch UserOp assembly + EntryPoint.handleOps submission, and a harness proof (incl. the atomic-rollback negative). Best done as its own epic after the #216/#214 wire stabilizes. The batch itself adds ~no contract work — executeBatch + both msg.sender == master guards already exist.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/identityHDKD actor tree, K-key inventory, identity ceremony

    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