From 7f78d0431a26006fad4a871d36879487f4e2cf4c Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 11:12:02 +0800 Subject: [PATCH 01/19] =?UTF-8?q?agentkeys:=20ERC-4337=20P-256=20master=20?= =?UTF-8?q?plan=20(#164)=20+=20correct=20stale=20London=E2=86=92Cancun=20c?= =?UTF-8?q?omments=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/agentkeys-chain/src/P256Verifier.sol | 11 +- .../agentkeys-chain/src/SidecarRegistry.sol | 3 +- .../agentkeys-chain/test/P256Verifier.t.sol | 2 +- docs/plan/chain/erc4337-master-account.md | 109 ++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 docs/plan/chain/erc4337-master-account.md diff --git a/crates/agentkeys-chain/src/P256Verifier.sol b/crates/agentkeys-chain/src/P256Verifier.sol index 41d3592f..0d64b83e 100644 --- a/crates/agentkeys-chain/src/P256Verifier.sol +++ b/crates/agentkeys-chain/src/P256Verifier.sol @@ -5,11 +5,14 @@ pragma solidity ^0.8.20; /// @notice Verifies WebAuthn / FIDO2 authenticator (K11) assertions on chain /// until Heima ships an EIP-7212 / RIP-7212 P-256 precompile. /// -/// @dev Heima is at London EVM level (verified 2026-05-19: mixHash=null, -/// withdrawalsRoot=null, blobGasUsed=null) — no native P-256 -/// precompile at 0x100 or 0x0b. This contract performs the verify +/// @dev Heima executes at Cancun EVM level (verified on-chain 2026-06-02 per +/// #168 — PUSH0 + TSTORE/TLOAD run; the earlier "London" call introspected +/// block-header format, which signals the consensus layer, not opcode +/// capability) — and has no native P-256 precompile at 0x100 (RIP-7212) +/// or 0x0b. This contract performs the verify /// in pure Solidity using Jacobian coordinates + Shamir's trick -/// double-scalar multiplication. Roughly ~700k gas per verify; +/// double-scalar multiplication. ~707k gas per verify (measured on +/// Heima mainnet 2026-06-02); /// acceptable because K11 mutations are master-only and rare /// (scope grant/revoke, multi-master pairing, recovery). Per-call /// hot paths (broker cap-mint, worker cap-verify) never invoke this. diff --git a/crates/agentkeys-chain/src/SidecarRegistry.sol b/crates/agentkeys-chain/src/SidecarRegistry.sol index 5fbd5877..ed295e52 100644 --- a/crates/agentkeys-chain/src/SidecarRegistry.sol +++ b/crates/agentkeys-chain/src/SidecarRegistry.sol @@ -9,7 +9,8 @@ import {K11Verifier} from "./K11Verifier.sol"; /// /// @dev Stage-2 (#90) hardening: /// - K11 assertions are P-256 verified ON CHAIN via [K11Verifier] + -/// [P256Verifier] (Heima is at London EVM, no EIP-7212 precompile). +/// [P256Verifier] (Heima executes Cancun; no EIP-7212/RIP-7212 P-256 +/// precompile, so on-chain P-256 is pure-Solidity). See #168. /// - K11 assertion challenge is bound to (operation_kind || operator || /// params || chainid || operatorNonce[operator]) so a captured K11 /// sig cannot be replayed for a different operation. diff --git a/crates/agentkeys-chain/test/P256Verifier.t.sol b/crates/agentkeys-chain/test/P256Verifier.t.sol index 91fbd198..eb64d2c8 100644 --- a/crates/agentkeys-chain/test/P256Verifier.t.sol +++ b/crates/agentkeys-chain/test/P256Verifier.t.sol @@ -88,7 +88,7 @@ contract P256VerifierTest is Test { uint256 gasUsed = gasBefore - gasleft(); console.log("P256 verify gas:", gasUsed); assertTrue(ok); - // London EVM block gas limit is ~30M; we want comfortably under that. + // Heima's block gas limit is ~30M; we want comfortably under that. assertLt(gasUsed, 2_000_000, "verify must fit under 2M gas"); } } diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md new file mode 100644 index 00000000..f4949688 --- /dev/null +++ b/docs/plan/chain/erc4337-master-account.md @@ -0,0 +1,109 @@ +# ERC-4337 P-256 smart-account master (plan to resolve #164) + +**Status:** plan (pre-code). Authored 2026-06-02 after a hard-confirmation deploy spike on **Heima mainnet**. +**Decision:** **Solution A — account-only / full-intent** (locked). The master becomes an ERC-4337 smart account whose `validateUserOp` verifies a **P-256 (K11/passkey) signature** over the `userOpHash`; a **bundler** broadcasts UserOps and an optional **paymaster** sponsors gas. `SidecarRegistry.master` becomes the smart-account address. +**Supersedes:** the §11 fork-A-vs-B framing in [`../web-flow/wire-real-paths.md`](../web-flow/wire-real-paths.md) and the EOA `msg.sender`-bound model. +**Unblocks:** the chain-write half (X4) of [#163](https://github.com/litentry/agentKeys/issues/163). +**Source of truth:** [`docs/arch.md`](../../arch.md) §6 (key inventory), §9 (master bootstrap), §10 (per-actor ceremonies). Arch.md is updated as the E-phases land (E3/E7), not before. + +--- + +## 0. Why (carried from #164, with the falsified bits corrected) + +ERC-4337 with a P-256 account is the only model that simultaneously: +- **Removes the software-secp256k1 root** — every client signs UserOps with the **SE-sealed K11/P-256 passkey alone**; no exportable secp256k1 key on any device. (Resolves the security-review HIGH #4.) +- **Key-free + relayer in one** — a bundler broadcasts and a paymaster can pay gas → no HEI on device, **no custodial relayer**. +- **Stable master + multi-device + recovery** — the account address is the durable master across device swaps; multiple authorized passkeys + quorum/social recovery live in the account. (Resolves HIGH #5: the single global `operatorMasterWallet`.) +- **Web + mobile symmetric** — each registers its own passkey as an account signer and signs UserOps directly; the browser→host delegate-broadcast hop (and its confused-deputy risk, HIGH #6) **dissolves**. +- **Reuses on-chain P-256** — the account staticcalls the already-deployed `P256Verifier`. + +### The reframe that makes Solution A correct (not just convenient) + +The passkey signs the **`userOpHash`**, which the EntryPoint computes over `sender + nonce + callData + accountGasLimits + preVerificationGas + gasFees + paymasterAndData`, then bound to `entryPoint + chainId`. Because `callData` **is** the full function intent (target omni, scope bits, device hash, …), **the P-256 signature over `userOpHash` is a provably-complete full-intent commitment** — it cannot omit a field. This: +- **Resolves the HIGH "full-intent binding" finding structurally** (the security review found two omitted-field bugs in the hand-rolled `abi.encode` challenges; Solution A deletes that code class). +- Lets us **retire** the bespoke per-op `keccak256` challenge construction, `operatorNonce`, `scopeNonce`, and `signCount` in `SidecarRegistry`/`AgentKeysScope`, replacing them with **`msg.sender == masterAccount` + the EntryPoint 2D nonce**. + +Defense-in-depth note: for **intent binding**, account-only is strictly ≥ keeping the in-contract K11 verify, since the redundant layer would only be as strong as the weakest hand-rolled challenge. The one uncorrelated benefit of an in-contract re-check (catching an account-*logic* bug) is **not** adopted globally; recovery (E5) stays an independent guardian-authorized path for a different reason (a lost primary passkey must not be required to recover). + +--- + +## 1. VERIFIED feasibility (Heima mainnet spike, 2026-06-02) + +Deployed + exercised the real flow end-to-end on Heima mainnet (chainId **212013**) from the harness deploy wallet. **Nothing below is theoretical.** + +| Result | Evidence | +|---|---| +| Canonical **eth-infinitism EntryPoint v0.7** compiles for Cancun (solc 0.8.23, 11.8KB runtime) and deploys + runs | live `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` | +| A minimal **P256Account** `validateUserOp` staticcalls the **live** P256Verifier and the UserOp executes | UserOp tx `0x17939004d3ba7a8a5451fa69b815d581486e95ee3601c4e6ee557eb5b2a7d88a`, status 1, `Counter.number()==1` | +| Works via **direct `handleOps`** (no bundler) — bundler is just automation over this | the spike used a raw `handleOps` call | +| Live P256Verifier verifies a valid vector / rejects a tampered one | `0xda5b772f9d6c09abe80414eea908612df9b54749` → `0x..01` / `0x..00` | +| Full passkey UserOp gas | **730,242** (~0.018 HEI @ 25 gwei) | + +Spike artifacts (mainnet): P256Account `0x8897ee99434F6c9D8711565EE59c61a03DA0Cc98` (minimal, raw-P256 — **not** the production account), Counter `0x2861D0194d9B263D42eBd956f3aD336185b27C4E` (throwaway). The EntryPoint is the exact audited v0.7 bytecode; E1 decides adopt-this vs redeploy-at-a-deterministic-address. + +--- + +## 2. Heima-specific constraints (all verified) + +1. **EVM = Cancun, not London** (#168). The account + EntryPoint may use ≤Cancun opcodes at runtime. The `evm_version="london"` pin is only a `forge script` header-validation workaround — **deploy EntryPoint/factory via `forge create`/raw bytecode** (the spike did; it does not trip the `prevrandao` check) so we ship the exact audited bytecode. +2. **EntryPoint v0.7** (not v0.8). v0.8's headline (EIP-7702) requires Prague *and* re-introduces a secp256k1 EOA root — counter to this design. v0.8's EIP-712 `userOpHash` is for EOA/hardware-wallet signers, not WebAuthn. See the two tracked issues at the end. +3. **No canonical EntryPoint / no deterministic CREATE2 deployer on Heima** → self-deploy both. The account factory carries its own CREATE2 (an opcode — no Arachnid proxy needed) for deterministic account addresses. +4. **No `debug` namespace** on Heima's Frontier RPC (`debug_traceCall`/`debug_traceTransaction` → method-not-found). Standard bundlers need this for ERC-7562 validation → run a **private, self-hosted bundler in `--unsafe` mode**, fed only by authenticated clients via the broker (not a public alt-mempool). Acceptable because the broker is already the gatekeeper. +5. **P-256 verify ≈ 707k gas** with our current `P256Verifier` (measured on mainnet — the repo header's "~700k" is right; #163's "~421k" was Daimo's verifier). Options: accept it (trivial at 25 gwei), **swap to the Daimo verifier (~421k, ~40% cut)**, or push for RIP-7212 (~3.4k, runtime upgrade — see issues). +6. **ExistentialDeposit ≈ 0.1 HEI** (verified behaviorally; confirm the exact `Balances::ExistentialDeposit`). EVM value transfers must keep every account ≥ ED: + - `depositTo` below ED to a zero-balance EntryPoint fails with `OutOfFund`; ≥ ED succeeds. + - A new account loses ~0.1 HEI one-time on first funding. + - **Funding rule:** pre-deposit the account's EntryPoint balance generously so `missingAccountFunds == 0` (no per-op value transfer in `validateUserOp` — this is what made the spike UserOp pass); keep EntryPoint + paymaster ≥ ED; budget ~0.1 HEI per new master account. + +--- + +## 3. Implementation order (E0–E8) + +Each phase is independently shippable, idempotent where it mutates chain state (per the repo's idempotent-remote-setup rule), and ends green on a check. + +| # | Phase | What | Gate | +|---|---|---|---| +| **E0** | Threat-model delta | Write the `userOpHash`-is-full-intent argument; ED-aware funding security; bundler trust model (unsafe-mode private mempool); what Solution A deletes and why it's safe. | review sign-off | +| **E1** | Heima 4337 infra | Deploy **EntryPoint v0.7** (`forge create`/raw bytecode) — adopt the spike's live one or redeploy deterministic; deploy **account factory** (CREATE2, salt = passkey pubkey). Confirm exact ED constant. Record in [`deployed-contracts.md`](../../spec/deployed-contracts.md) + `operator-workstation.env`; extend `verify-heima-contracts.sh`. | verify script green | +| **E2** | Production `P256Account` | `validateUserOp` staticcalls P256Verifier over `userOpHash`; **WebAuthn `clientDataJSON` wrapping** (spike used raw P256); **multi-passkey signer set**; EntryPoint+verifier behind a config interface (forward-compat hedge for a future EntryPoint/verifier swap); ED-aware `_payPrefund`. Decide **immutable vs upgradeable-proxy** (security tradeoff: upgrade authority = new attack surface). | forge tests (valid / tampered / wrong-signer / replay) | +| **E3** | Registry thinning | `SidecarRegistry`/`AgentKeysScope` master writes → `msg.sender == masterAccount`; `operatorMasterWallet[omni] = account`. **Retire** the per-op K11 challenge + `operatorNonce`/`scopeNonce`/`signCount`. **Keep #166's bootstrap self-attestation.** Update arch.md §6/§10. | forge tests; negative replay/substitution | +| **E4** | Agent bind/revoke via account | route `registerAgentDevice`/`revokeAgentDevice` through the account so they inherit passkey gating (closes the HIGH "agent-bind has no biometric" finding) with **no new challenge code**. | negative: non-account sender → revert | +| **E5** | Recovery module | multi-passkey enroll + M-of-N social recovery in the account; **supersede** registry `revokeMasterDevice`/`recoveryThreshold` quorum. Recovery is an **independent guardian-authorized path** (must not require the lost primary passkey). | add/revoke-passkey + quorum tests | +| **E6** | Bundler + paymaster | self-host **unsafe-mode bundler** (rundler or eth-infinitism reference) on the broker host, fed by authenticated clients; optional **ED-aware VerifyingPaymaster** funded from the deploy wallet (key-free + gasless). | a no-secp256k1 device lands a UserOp through the bundler | +| **E7** | Bootstrap migration | first-master onboarding as one `initCode + registerFirstMasterDevice` UserOp; the deterministic account address (CREATE2 from pubkey) is known pre-deploy, so #166's self-attestation binds it; one-time ~0.1 HEI ED funding; document migration of any existing `operatorMasterWallet` EOAs. | front-run negative test under 4337 | +| **E8** | Harness acceptance | extend a demo: a phone with **no secp256k1 key** completes onboarding + a scope grant by passkey-signing UserOps; the bundler lands the txs; `isServiceInScope(...)==true`; a second passkey is added; a lost device is revoked. | green/red per step | + +--- + +## 4. How #164 interacts with the existing invariants + +- **#166 survives** (the CRITICAL bootstrap item is already satisfied). The self-attestation binds `msg.sender`, which becomes the account address; under 4337 the front-run property *strengthens* (account is CREATE2-bound to the passkey pubkey, so an attacker can deploy the account but cannot make it call the registry without the operator's passkey). E7 revisits whether the explicit self-attestation is still needed once the account model lands, or is subsumed. +- **Four-layer isolation (issue #90) is unchanged** — #164 changes *how master writes are authorized*, not the cap-mint / worker chain-verify / IAM PrincipalTag / per-data-class bucket layers. No demo step in `harness/v2-stage3-demo.sh` regresses; E8 adds the passkey-UserOp path on top. +- **The cap layer is untouched** — agents still use K10 + broker caps off-chain; the passkey/UserOp path is master-plane only, low-frequency. + +--- + +## 5. UX cost (per the gesture analysis) + +The per-operation Touch ID is a property of "passkey = the only key," not of Solution A. Reads and all agent-runtime traffic require **no** biometric. Master writes cost one gesture each; `executeBatch` keeps bind+grant at **one** gesture (arch §10.2's "one operator gesture" preserved) while now biometric-gating bind too. Recovery is M gestures by nature. A future session-key module can batch a burst of admin actions under one gesture. + +--- + +## 6. Risks & open questions + +- **Bundler in unsafe mode** loses ERC-7562 trace validation. Mitigation: private mempool, broker-authenticated submission only, single trusted bundler. Revisit if Frontier ships `debug_traceCall`. +- **ED funding discipline** must be encoded in the paymaster + factory + onboarding, or value moves fail with `OutOfFund`. Confirm the exact ED constant in E1. +- **Account upgradeability** (E2) is a genuine security fork — immutable (EntryPoint swap = new address = re-register) vs upgradeable-proxy (flexible, but upgrade authority is an attack surface). Decide with the threat model in E0. +- **Recovery without the account** (E5) — the guardian path must not depend on the account's own `validateUserOp`. +- **P-256 gas** ~707k is fine now; the Daimo swap (~421k) is a cheap, zero-chain-work option if validation volume grows. + +--- + +## 7. Relationships + +- **Plan of record amended:** [`../web-flow/wire-real-paths.md`](../web-flow/wire-real-paths.md) §11/§12 — the Cancun + verified-feasibility revisions land via the patch accompanying this plan. +- **Blocks:** [#163](https://github.com/litentry/agentKeys/issues/163) X4. +- **Resolves:** [#164](https://github.com/litentry/agentKeys/issues/164). +- **Builds on:** [#166](https://github.com/litentry/agentKeys/pull/166) (bootstrap self-attestation). +- **Chain-upgrade follow-up:** [#170](https://github.com/litentry/agentKeys/issues/170) — *evaluate* RIP-7212 (P-256 precompile, ~707k→~3.4k gas, Pectra-independent). EntryPoint v0.8 / EIP-7702 is **not** pursued (requires Prague and re-adds a secp256k1 root — rationale in §2; the v0.8-defer tracker #169 was closed as non-actionable). +- **Contracts:** `crates/agentkeys-chain/src/{SidecarRegistry,AgentKeysScope,K11Verifier,P256Verifier}.sol`. From 7dfe55e842da7271eff4d6e68e3b48de5a4e09b5 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 11:19:10 +0800 Subject: [PATCH 02/19] =?UTF-8?q?agentkeys:=20ERC-4337=20P256Account=20+?= =?UTF-8?q?=20CREATE2=20factory=20(#164=20E1/E2)=20=E2=80=94=20WebAuthn-ga?= =?UTF-8?q?ted=20master,=2014=20tests=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/agentkeys-chain/src/IERC4337.sol | 29 +++ crates/agentkeys-chain/src/P256Account.sol | 184 +++++++++++++++++ .../src/P256AccountFactory.sol | 61 ++++++ crates/agentkeys-chain/test/P256Account.t.sol | 188 ++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 crates/agentkeys-chain/src/IERC4337.sol create mode 100644 crates/agentkeys-chain/src/P256Account.sol create mode 100644 crates/agentkeys-chain/src/P256AccountFactory.sol create mode 100644 crates/agentkeys-chain/test/P256Account.t.sol diff --git a/crates/agentkeys-chain/src/IERC4337.sol b/crates/agentkeys-chain/src/IERC4337.sol new file mode 100644 index 00000000..70430774 --- /dev/null +++ b/crates/agentkeys-chain/src/IERC4337.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +/// @notice The two ERC-4337 v0.7 surfaces our account implements, vendored from +/// eth-infinitism/account-abstraction@v0.7.0. The full EntryPoint is +/// deployed separately and verified live on Heima mainnet — see +/// docs/plan/chain/erc4337-master-account.md §1. Vendoring keeps the +/// chain crate dependency-free (solc 0.8.20 pin) while staying ABI-exact +/// with the canonical v0.7 EntryPoint (field order/types are consensus). +struct PackedUserOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + bytes32 accountGasLimits; + uint256 preVerificationGas; + bytes32 gasFees; + bytes paymasterAndData; + bytes signature; +} + +interface IAccount { + /// @return validationData 0 = signature valid; 1 = SIG_VALIDATION_FAILED. + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) external returns (uint256 validationData); +} diff --git a/crates/agentkeys-chain/src/P256Account.sol b/crates/agentkeys-chain/src/P256Account.sol new file mode 100644 index 00000000..e0c6ff0b --- /dev/null +++ b/crates/agentkeys-chain/src/P256Account.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {IAccount, PackedUserOperation} from "./IERC4337.sol"; + +/// @notice Subset of [K11Verifier] the account needs. Declared with `bytes memory` +/// so the account can pass decoded (memory) assertion bytes; the ABI +/// selector is identical to the deployed `bytes calldata` verifier. +interface IK11Verifier { + function verifyAssertion( + bytes32 expectedChallenge, + bytes32 expectedRpIdHash, + bytes memory authenticatorData, + bytes memory clientDataJSON, + uint256 challengeLocation, + uint256 r, + uint256 s, + uint256 pubX, + uint256 pubY + ) external view returns (bool); +} + +/// @title P256Account — ERC-4337 v0.7 master account gated by WebAuthn (K11) passkeys. +/// @notice The master authority for an operator (arch.md §6/§10), resolving #164. +/// `validateUserOp` verifies a WebAuthn assertion whose challenge **is** +/// the `userOpHash`, via the on-chain [K11Verifier]. Because `userOpHash` +/// commits the entire UserOp (callData + nonce + chainId + entryPoint), +/// the passkey signature is a provably-complete full-intent authorization +/// — no hand-rolled per-op challenge, and no secp256k1 key on any device. +/// @dev Replay is the EntryPoint 2D nonce (no WebAuthn signCount here — see +/// the plan's Solution A rationale). Multi-passkey signer set; recovery +/// quorum is a later phase (#164 E5). The verifier (P-256) is reused from +/// the deployed K11Verifier, so no new crypto. +contract P256Account is IAccount { + uint256 internal constant SIG_OK = 0; + uint256 internal constant SIG_FAIL = 1; + + struct Signer { + uint256 pubX; + uint256 pubY; + bytes32 rpIdHash; + bool active; + } + + address public immutable entryPoint; + address public immutable k11Verifier; + + /// @notice credIdHash => authorized passkey. + mapping(bytes32 => Signer) public signers; + /// @notice Count of active signers; the account refuses to drop to zero. + uint256 public activeSignerCount; + + event SignerAdded(bytes32 indexed credIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash); + event SignerRemoved(bytes32 indexed credIdHash); + event Executed(address indexed dest, uint256 value, bytes data); + + error NotEntryPoint(); + error NotEntryPointOrSelf(); + error SignerExists(bytes32 credIdHash); + error UnknownSigner(bytes32 credIdHash); + error LastSigner(); + error LengthMismatch(); + + constructor( + address _entryPoint, + address _k11Verifier, + bytes32 credIdHash, + uint256 pubX, + uint256 pubY, + bytes32 rpIdHash + ) { + entryPoint = _entryPoint; + k11Verifier = _k11Verifier; + _addSigner(credIdHash, pubX, pubY, rpIdHash); + } + + receive() external payable {} + + // ─── ERC-4337 validation ───────────────────────────────────────────── + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) external returns (uint256 validationData) { + if (msg.sender != entryPoint) revert NotEntryPoint(); + validationData = _validateSignature(userOp.signature, userOpHash); + _payPrefund(missingAccountFunds); + } + + /// @dev signature = abi.encode(credIdHash, authenticatorData, clientDataJSON, + /// challengeLocation, r, s). The pubkey/rpIdHash come from the stored + /// signer; the challenge is the userOpHash (full-intent commitment). + function _validateSignature(bytes calldata signature, bytes32 userOpHash) + internal + view + returns (uint256) + { + ( + bytes32 credIdHash, + bytes memory authenticatorData, + bytes memory clientDataJSON, + uint256 challengeLocation, + uint256 r, + uint256 s + ) = abi.decode(signature, (bytes32, bytes, bytes, uint256, uint256, uint256)); + + Signer storage signer = signers[credIdHash]; + if (!signer.active) return SIG_FAIL; + + bool ok = IK11Verifier(k11Verifier).verifyAssertion( + userOpHash, + signer.rpIdHash, + authenticatorData, + clientDataJSON, + challengeLocation, + r, + s, + signer.pubX, + signer.pubY + ); + return ok ? SIG_OK : SIG_FAIL; + } + + function _payPrefund(uint256 missingAccountFunds) internal { + if (missingAccountFunds != 0) { + (bool success,) = payable(msg.sender).call{value: missingAccountFunds}(""); + (success); // EntryPoint reverts the op if the prefund is unmet + } + } + + // ─── Execution (passkey-gated via EntryPoint, or self-call from a UserOp) ── + function execute(address dest, uint256 value, bytes calldata func) external { + _requireEntryPointOrSelf(); + _call(dest, value, func); + } + + function executeBatch( + address[] calldata dest, + uint256[] calldata value, + bytes[] calldata func + ) external { + _requireEntryPointOrSelf(); + if (dest.length != func.length || dest.length != value.length) revert LengthMismatch(); + for (uint256 i = 0; i < dest.length; ++i) { + _call(dest[i], value[i], func[i]); + } + } + + function _call(address dest, uint256 value, bytes calldata func) internal { + (bool ok, bytes memory ret) = dest.call{value: value}(func); + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + emit Executed(dest, value, func); + } + + // ─── Signer management (passkey-gated via EntryPoint/self) ──────────── + function addSigner(bytes32 credIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash) external { + _requireEntryPointOrSelf(); + _addSigner(credIdHash, pubX, pubY, rpIdHash); + } + + function removeSigner(bytes32 credIdHash) external { + _requireEntryPointOrSelf(); + if (!signers[credIdHash].active) revert UnknownSigner(credIdHash); + if (activeSignerCount <= 1) revert LastSigner(); + signers[credIdHash].active = false; + activeSignerCount -= 1; + emit SignerRemoved(credIdHash); + } + + function _addSigner(bytes32 credIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash) internal { + if (signers[credIdHash].active) revert SignerExists(credIdHash); + signers[credIdHash] = Signer({pubX: pubX, pubY: pubY, rpIdHash: rpIdHash, active: true}); + activeSignerCount += 1; + emit SignerAdded(credIdHash, pubX, pubY, rpIdHash); + } + + function _requireEntryPointOrSelf() internal view { + if (msg.sender != entryPoint && msg.sender != address(this)) revert NotEntryPointOrSelf(); + } +} diff --git a/crates/agentkeys-chain/src/P256AccountFactory.sol b/crates/agentkeys-chain/src/P256AccountFactory.sol new file mode 100644 index 00000000..8412149c --- /dev/null +++ b/crates/agentkeys-chain/src/P256AccountFactory.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {P256Account} from "./P256Account.sol"; + +/// @title P256AccountFactory — CREATE2 factory for passkey-gated master accounts. +/// @notice The account address is deterministic in (initial passkey, salt), so +/// the bootstrap ceremony (arch.md §9, #164 E7) can bind the master +/// address BEFORE the account is deployed — an attacker can deploy the +/// account but cannot make it act without the operator's passkey. +/// @dev No external deterministic-deployer proxy is needed: CREATE2 is an +/// opcode the factory uses directly (Heima has no 0x4e59… proxy). +contract P256AccountFactory { + address public immutable entryPoint; + address public immutable k11Verifier; + + event AccountCreated(address indexed account, bytes32 indexed credIdHash, bytes32 salt); + + constructor(address _entryPoint, address _k11Verifier) { + entryPoint = _entryPoint; + k11Verifier = _k11Verifier; + } + + /// @notice Deploy (or return, if already deployed) the account for an initial + /// passkey. Idempotent — safe to call from a UserOp's initCode. + function createAccount( + bytes32 credIdHash, + uint256 pubX, + uint256 pubY, + bytes32 rpIdHash, + bytes32 salt + ) external returns (address) { + address predicted = getAddress(credIdHash, pubX, pubY, rpIdHash, salt); + if (predicted.code.length > 0) return predicted; + P256Account acct = + new P256Account{salt: salt}(entryPoint, k11Verifier, credIdHash, pubX, pubY, rpIdHash); + emit AccountCreated(address(acct), credIdHash, salt); + return address(acct); + } + + /// @notice The deterministic account address for an initial passkey + salt. + function getAddress( + bytes32 credIdHash, + uint256 pubX, + uint256 pubY, + bytes32 rpIdHash, + bytes32 salt + ) public view returns (address) { + bytes32 initCodeHash = keccak256( + abi.encodePacked( + type(P256Account).creationCode, + abi.encode(entryPoint, k11Verifier, credIdHash, pubX, pubY, rpIdHash) + ) + ); + return address( + uint160( + uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash))) + ) + ); + } +} diff --git a/crates/agentkeys-chain/test/P256Account.t.sol b/crates/agentkeys-chain/test/P256Account.t.sol new file mode 100644 index 00000000..19be0964 --- /dev/null +++ b/crates/agentkeys-chain/test/P256Account.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {P256Account} from "../src/P256Account.sol"; +import {P256AccountFactory} from "../src/P256AccountFactory.sol"; +import {PackedUserOperation} from "../src/IERC4337.sol"; + +contract Counter { + uint256 public number; + + function increment() external { + number += 1; + } +} + +/// @dev Stand-in for the deployed K11Verifier. Real WebAuthn/P-256 verification +/// is covered by K11Verifier.t.sol / P256Verifier.t.sol and the Heima +/// mainnet spike (#164 plan §1); here we exercise the account's LOGIC. +contract MockK11Verifier { + bool public result = true; + + function setResult(bool r) external { + result = r; + } + + function verifyAssertion( + bytes32, + bytes32, + bytes memory, + bytes memory, + uint256, + uint256, + uint256, + uint256, + uint256 + ) external view returns (bool) { + return result; + } +} + +contract P256AccountTest is Test { + address constant ENTRYPOINT = address(0xE427); + MockK11Verifier k11; + P256AccountFactory factory; + Counter counter; + + bytes32 constant CRED = keccak256("cred-1"); + bytes32 constant CRED2 = keccak256("cred-2"); + uint256 constant PUBX = uint256(keccak256("pubx")); + uint256 constant PUBY = uint256(keccak256("puby")); + bytes32 constant RPID = keccak256("litentry.org"); + + function setUp() public { + k11 = new MockK11Verifier(); + factory = new P256AccountFactory(ENTRYPOINT, address(k11)); + counter = new Counter(); + } + + function _deploy() internal returns (P256Account) { + return P256Account(payable(factory.createAccount(CRED, PUBX, PUBY, RPID, bytes32(0)))); + } + + function _op(bytes32 cred) internal pure returns (PackedUserOperation memory op) { + op.signature = abi.encode(cred, hex"aa", hex"bb", uint256(0), uint256(1), uint256(2)); + } + + function test_FactoryDeterministicAndIdempotent() public { + address predicted = factory.getAddress(CRED, PUBX, PUBY, RPID, bytes32(0)); + address a = factory.createAccount(CRED, PUBX, PUBY, RPID, bytes32(0)); + assertEq(a, predicted, "address must match prediction"); + assertEq(factory.createAccount(CRED, PUBX, PUBY, RPID, bytes32(0)), a, "idempotent"); + assertGt(a.code.length, 0, "deployed"); + } + + function test_FactoryAddressDependsOnPasskey() public view { + assertTrue( + factory.getAddress(CRED, PUBX, PUBY, RPID, bytes32(0)) + != factory.getAddress(CRED, PUBX + 1, PUBY, RPID, bytes32(0)), + "different passkey -> different address" + ); + } + + function test_InitialSigner() public { + P256Account acct = _deploy(); + assertEq(acct.activeSignerCount(), 1); + (uint256 x, uint256 y, bytes32 rp, bool active) = acct.signers(CRED); + assertEq(x, PUBX); + assertEq(y, PUBY); + assertEq(rp, RPID); + assertTrue(active); + } + + function test_ValidateUserOp_Success() public { + P256Account acct = _deploy(); + k11.setResult(true); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED), bytes32(uint256(0x1234)), 0), 0); + } + + function test_ValidateUserOp_BadSig() public { + P256Account acct = _deploy(); + k11.setResult(false); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED), bytes32(uint256(0x1234)), 0), 1); + } + + function test_ValidateUserOp_UnknownSigner() public { + P256Account acct = _deploy(); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED2), bytes32(uint256(0x1234)), 0), 1); + } + + function test_ValidateUserOp_OnlyEntryPoint() public { + P256Account acct = _deploy(); + vm.expectRevert(P256Account.NotEntryPoint.selector); + acct.validateUserOp(_op(CRED), bytes32(uint256(0x1234)), 0); + } + + function test_Execute_FromEntryPoint() public { + P256Account acct = _deploy(); + vm.prank(ENTRYPOINT); + acct.execute(address(counter), 0, abi.encodeWithSelector(Counter.increment.selector)); + assertEq(counter.number(), 1); + } + + function test_Execute_Unauthorized() public { + P256Account acct = _deploy(); + vm.expectRevert(P256Account.NotEntryPointOrSelf.selector); + acct.execute(address(counter), 0, abi.encodeWithSelector(Counter.increment.selector)); + } + + function test_ExecuteBatch() public { + P256Account acct = _deploy(); + address[] memory dest = new address[](2); + uint256[] memory val = new uint256[](2); + bytes[] memory fn = new bytes[](2); + dest[0] = address(counter); + dest[1] = address(counter); + fn[0] = abi.encodeWithSelector(Counter.increment.selector); + fn[1] = abi.encodeWithSelector(Counter.increment.selector); + vm.prank(ENTRYPOINT); + acct.executeBatch(dest, val, fn); + assertEq(counter.number(), 2); + } + + function test_AddSigner_GatedAndUsable() public { + P256Account acct = _deploy(); + vm.expectRevert(P256Account.NotEntryPointOrSelf.selector); + acct.addSigner(CRED2, PUBX, PUBY, RPID); + + vm.prank(ENTRYPOINT); + acct.addSigner(CRED2, PUBX, PUBY, RPID); + assertEq(acct.activeSignerCount(), 2); + + k11.setResult(true); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED2), bytes32(uint256(1)), 0), 0, "new passkey validates"); + } + + function test_RemoveSigner_LockoutProtection() public { + P256Account acct = _deploy(); + vm.prank(ENTRYPOINT); + vm.expectRevert(P256Account.LastSigner.selector); + acct.removeSigner(CRED); + } + + function test_RemoveSigner_Works() public { + P256Account acct = _deploy(); + vm.startPrank(ENTRYPOINT); + acct.addSigner(CRED2, PUBX, PUBY, RPID); + acct.removeSigner(CRED); + vm.stopPrank(); + assertEq(acct.activeSignerCount(), 1); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED), bytes32(uint256(1)), 0), 1, "removed signer rejected"); + } + + function test_PayPrefund() public { + P256Account acct = _deploy(); + vm.deal(address(acct), 1 ether); + k11.setResult(true); + uint256 epBefore = ENTRYPOINT.balance; + vm.prank(ENTRYPOINT); + acct.validateUserOp(_op(CRED), bytes32(uint256(1)), 0.1 ether); + assertEq(ENTRYPOINT.balance, epBefore + 0.1 ether, "prefund forwarded to EntryPoint"); + } +} From 2ebd1e3eb938597d72c298814d35d417a4d6088c Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 11:20:36 +0800 Subject: [PATCH 03/19] agentkeys: ERC-4337 threat-model delta (#164 E0) + plan cross-link --- docs/plan/chain/erc4337-master-account.md | 2 +- docs/plan/chain/erc4337-threat-model.md | 95 +++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 docs/plan/chain/erc4337-threat-model.md diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md index f4949688..dff76e6e 100644 --- a/docs/plan/chain/erc4337-master-account.md +++ b/docs/plan/chain/erc4337-master-account.md @@ -63,7 +63,7 @@ Each phase is independently shippable, idempotent where it mutates chain state ( | # | Phase | What | Gate | |---|---|---|---| -| **E0** | Threat-model delta | Write the `userOpHash`-is-full-intent argument; ED-aware funding security; bundler trust model (unsafe-mode private mempool); what Solution A deletes and why it's safe. | review sign-off | +| **E0** | Threat-model delta | The `userOpHash`-is-full-intent argument; ED-aware funding security; bundler trust model (unsafe-mode private mempool); what Solution A deletes and why it's safe → [`erc4337-threat-model.md`](erc4337-threat-model.md) ✅ drafted. | review sign-off | | **E1** | Heima 4337 infra | Deploy **EntryPoint v0.7** (`forge create`/raw bytecode) — adopt the spike's live one or redeploy deterministic; deploy **account factory** (CREATE2, salt = passkey pubkey). Confirm exact ED constant. Record in [`deployed-contracts.md`](../../spec/deployed-contracts.md) + `operator-workstation.env`; extend `verify-heima-contracts.sh`. | verify script green | | **E2** | Production `P256Account` | `validateUserOp` staticcalls P256Verifier over `userOpHash`; **WebAuthn `clientDataJSON` wrapping** (spike used raw P256); **multi-passkey signer set**; EntryPoint+verifier behind a config interface (forward-compat hedge for a future EntryPoint/verifier swap); ED-aware `_payPrefund`. Decide **immutable vs upgradeable-proxy** (security tradeoff: upgrade authority = new attack surface). | forge tests (valid / tampered / wrong-signer / replay) | | **E3** | Registry thinning | `SidecarRegistry`/`AgentKeysScope` master writes → `msg.sender == masterAccount`; `operatorMasterWallet[omni] = account`. **Retire** the per-op K11 challenge + `operatorNonce`/`scopeNonce`/`signCount`. **Keep #166's bootstrap self-attestation.** Update arch.md §6/§10. | forge tests; negative replay/substitution | diff --git a/docs/plan/chain/erc4337-threat-model.md b/docs/plan/chain/erc4337-threat-model.md new file mode 100644 index 00000000..8527550f --- /dev/null +++ b/docs/plan/chain/erc4337-threat-model.md @@ -0,0 +1,95 @@ +# ERC-4337 P-256 master — threat-model delta (#164 E0) + +**Status:** security analysis (pre-code-review). Companion to [`erc4337-master-account.md`](erc4337-master-account.md). Covers what changes in the trust model when the master moves from an EOA `msg.sender` to a **Solution A** ERC-4337 P-256 account, what the migration **deletes** and why that is safe, and the review checklist that gates the E1 mainnet deploy + E3 registry rewrite. + +--- + +## 1. The load-bearing argument: `userOpHash` is the full-intent commitment + +Under ERC-4337 v0.7 the EntryPoint computes `userOpHash = keccak256(abi.encode(hash(packedUserOpWithoutSig), entryPoint, chainId))`, where the inner hash commits `sender, nonce, initCode, callData, accountGasLimits, preVerificationGas, gasFees, paymasterAndData`. The passkey signs a WebAuthn assertion whose **challenge is the `userOpHash`** (verified on-chain by `K11Verifier` inside `P256Account.validateUserOp`). + +Therefore one passkey signature authorizes **exactly** this call (`callData` = target + selector + args), on **this** account (`sender`), at **this** nonce, **this** chain, **this** EntryPoint. It is impossible to omit a state-changing field — the signature covers the whole operation by construction. + +**Consequence:** this is strictly ≥ the legacy hand-rolled `keccak256(abi.encode(op, operator, ...))` challenges in `SidecarRegistry`/`AgentKeysScope`, whose security floor was the weakest hand-written challenge (the codex review found two omitted-field bugs). Solution A removes that whole bug class. + +--- + +## 2. What the migration deletes — and why each deletion is safe + +| Deleted (legacy) | Replaced by | Why safe | +|---|---|---| +| Per-op `keccak256` K11 challenge construction (add-master, scope, revoke) | passkey sig over `userOpHash` | `userOpHash` commits the full calldata — can't omit a field | +| `operatorNonce` / `scopeNonce` | EntryPoint **2D nonce** (`getNonce(sender,key)`) | battle-tested across the 4337 ecosystem; consumed atomically in `handleOps` | +| WebAuthn `signCount` anti-clone | EntryPoint nonce | signCount was inconsistent (registry updated it, scope didn't — codex MEDIUM); nonce is authoritative. (Synced passkeys make signCount best-effort anyway.) | +| `msg.sender == operatorMasterWallet(EOA)` | `msg.sender == masterAccount` (the smart account) | the account only acts after a passkey sig in `validateUserOp` | + +**Not deleted:** #166's bootstrap self-attestation (it is the bootstrap proof; see §6). + +--- + +## 3. P256Account-specific threats + +| Threat | Mitigation (status) | +|---|---| +| **Cross-account / cross-chain replay** of a passkey sig | `userOpHash` commits `sender + chainId + entryPoint` → a sig for account A / chain X never validates elsewhere. ✓ inherent | +| **Same-op replay** | EntryPoint nonce consumed on first inclusion. ✓ inherent | +| **Signature malleability** (P-256 `(r,s)` vs `(r,n-s)`) | Not exploitable for replay (nonce consumed regardless of which form lands). Off-chain signer normalizes low-s. **Review:** confirm `P256Verifier` accepts both forms but the nonce closes replay. | +| **Caller spoofing `validateUserOp`** | `require(msg.sender == entryPoint)`. ✓ implemented | +| **Unauthorized `execute` / signer mgmt** | `_requireEntryPointOrSelf()` on `execute`/`executeBatch`/`addSigner`/`removeSigner`. ✓ implemented + tested | +| **Signer-set lockout** (remove last key) | `removeSigner` reverts `LastSigner` when `activeSignerCount <= 1`. ✓ implemented + tested | +| **credId collision / unknown signer** | lookup by `credIdHash`; inactive → `SIG_VALIDATION_FAILED`. ✓ tested | +| **ERC-7562 validation-rule violations** (banned opcodes / disallowed storage in `validateUserOp`) | `validateUserOp` only reads own storage (`signers`), staticcalls the verifier (pure math), and pays prefund. No `TIMESTAMP`/`NUMBER`/external-storage reads. **Review:** re-check against ERC-7562 even though we run unsafe-mode (portability + future safe-mode). | +| **Prefund griefing** | `missingAccountFunds` forwarded best-effort; EntryPoint reverts the op if unpaid. We pre-deposit so it is 0 (§5). ✓ | + +--- + +## 4. Trust model changes + +- **Bundler (new trusted component).** Heima's Frontier RPC has no `debug_traceCall`, so the bundler runs **`--unsafe`** (no ERC-7562 trace validation). We mitigate by running a **single, private, broker-authenticated** bundler — not a public alt-mempool. A malicious/buggy bundler can censor or reorder our UserOps but **cannot forge one** (no passkey) and cannot alter effects (`userOpHash` binds calldata). Anyone can also submit `handleOps` directly (the spike did), so bundler downtime is not hard censorship. +- **EntryPoint (new trusted singleton).** Standard audited v0.7 bytecode; the account guards `msg.sender == entryPoint`. A compromised/incorrect EntryPoint is catastrophic — hence pin the exact audited bytecode and record its address (E1). +- **Paymaster (optional).** If used, it sponsors gas; must be ED-aware (§5) and rate-limited to its own deposit. Out of scope until E6. +- **Concentration of trust in `validateUserOp`.** Solution A's one critical path. Mitigated by: small surface, reuse of the already-audited `K11Verifier`/`P256Verifier`, full unit coverage, and an **independent guardian recovery path** (E5) that does not depend on `validateUserOp`. + +--- + +## 5. ExistentialDeposit (~0.1 HEI) as a security constraint + +Heima rejects EVM value transfers that would leave an account below ED (verified: a 0.05 HEI `depositTo` to a zero-balance EntryPoint failed `OutOfFund`; ≥ ED succeeded). Security implications: + +- **Funding discipline:** pre-deposit each account's EntryPoint balance generously so `missingAccountFunds == 0` (no per-op transfer in validation — this is what made the spike UserOp pass). Keep EntryPoint + paymaster ≥ ED at all times. +- **Griefing surface:** an attacker cannot drain via dust, but a poorly-funded account/paymaster self-DoSes (txs fail `OutOfFund`). The factory + onboarding must budget the one-time ~0.1 HEI per new account. +- **Action (E1):** read the exact `Balances::ExistentialDeposit` and encode it as the minimum deposit/funding floor. + +--- + +## 6. Bootstrap (E7) + #166 survival + +The master account address is **CREATE2-deterministic** in the initial passkey pubkey + salt. So: +- The operator knows the address pre-deploy and #166's self-attestation (which commits `msg.sender`) binds it. +- An attacker can permissionlessly deploy the account via the factory, but **cannot make it call `registerFirstMasterDevice`** (no passkey → `validateUserOp` fails). The front-run is defeated more robustly than under the EOA model. +- E7 decides whether the explicit #166 self-attestation is still needed once the account+CREATE2 binding exists, or is subsumed. + +--- + +## 7. Recovery (E5) must not depend on the primary passkey + +A lost primary device means no `validateUserOp` from that key. Recovery (M-of-N guardian passkeys) must therefore be authorizable **independently of the account's own primary signer** — a guardian-quorum path, not a self-UserOp. This is the one place an in-contract re-check is retained (the targeted defense-in-depth from the plan's auth-model decision). + +--- + +## 8. Unchanged invariants + +- **Four-layer per-actor/per-data-class isolation (#90)** — #164 changes only *how master writes are authorized*, not cap-mint / worker chain-verify / IAM PrincipalTag / per-data-class bucket separation. No `harness/v2-stage3-demo.sh` step regresses. +- **Agents** keep K10 + broker caps off-chain; the passkey/UserOp path is master-plane only. + +--- + +## 9. Review checklist (gates E1 mainnet deploy + E3 registry rewrite) + +- [ ] Codex + human review of `P256Account` / `P256AccountFactory` (this is security-critical; the spike's mainnet account was a throwaway, these are production). +- [ ] Confirm `P256Verifier` malleability behavior + that the nonce closes replay regardless. +- [ ] ERC-7562 opcode/storage audit of `validateUserOp` (even under unsafe-mode). +- [ ] Exact Heima ED constant + funding-floor encoding. +- [ ] E3: prove the registry rewrite keeps the #90 negative tests green + adds account-only positive/negative tests. +- [ ] E7: front-run negative test under the account model. +- [ ] Only deploy production EntryPoint + factory to mainnet **after** the above. From 461320ede828caef8340e63a8b221da4f8ac0149 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 11:54:01 +0800 Subject: [PATCH 04/19] agentkeys: P256Account map verifier/decode reverts to SIG_VALIDATION_FAILED (codex P2) +3 tests --- crates/agentkeys-chain/src/P256Account.sol | 27 ++++++++++++----- crates/agentkeys-chain/test/P256Account.t.sol | 30 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/agentkeys-chain/src/P256Account.sol b/crates/agentkeys-chain/src/P256Account.sol index e0c6ff0b..8f6e5875 100644 --- a/crates/agentkeys-chain/src/P256Account.sol +++ b/crates/agentkeys-chain/src/P256Account.sol @@ -56,6 +56,7 @@ contract P256Account is IAccount { error NotEntryPoint(); error NotEntryPointOrSelf(); + error NotSelf(); error SignerExists(bytes32 credIdHash); error UnknownSigner(bytes32 credIdHash); error LastSigner(); @@ -83,18 +84,31 @@ contract P256Account is IAccount { uint256 missingAccountFunds ) external returns (uint256 validationData) { if (msg.sender != entryPoint) revert NotEntryPoint(); - validationData = _validateSignature(userOp.signature, userOpHash); + // ERC-4337: a bad signature must return SIG_VALIDATION_FAILED, never + // revert, so the EntryPoint/bundler reject the op cleanly. The on-chain + // K11Verifier REVERTS on malformed/mismatched assertions (wrong + // challenge/RP, missing UP/UV flags, bad clientDataJSON), and abi.decode + // reverts on a malformed blob — so run decode+verify via an external + // self-call wrapped in try/catch and map any failure to SIG_FAIL. + try this.checkUserOpSignature(userOp.signature, userOpHash) returns (bool ok) { + validationData = ok ? SIG_OK : SIG_FAIL; + } catch { + validationData = SIG_FAIL; + } _payPrefund(missingAccountFunds); } /// @dev signature = abi.encode(credIdHash, authenticatorData, clientDataJSON, /// challengeLocation, r, s). The pubkey/rpIdHash come from the stored /// signer; the challenge is the userOpHash (full-intent commitment). - function _validateSignature(bytes calldata signature, bytes32 userOpHash) - internal + /// External + self-only so validateUserOp can try/catch its reverts and + /// map them to SIG_VALIDATION_FAILED. View — no state change. + function checkUserOpSignature(bytes calldata signature, bytes32 userOpHash) + external view - returns (uint256) + returns (bool) { + if (msg.sender != address(this)) revert NotSelf(); ( bytes32 credIdHash, bytes memory authenticatorData, @@ -105,9 +119,9 @@ contract P256Account is IAccount { ) = abi.decode(signature, (bytes32, bytes, bytes, uint256, uint256, uint256)); Signer storage signer = signers[credIdHash]; - if (!signer.active) return SIG_FAIL; + if (!signer.active) return false; - bool ok = IK11Verifier(k11Verifier).verifyAssertion( + return IK11Verifier(k11Verifier).verifyAssertion( userOpHash, signer.rpIdHash, authenticatorData, @@ -118,7 +132,6 @@ contract P256Account is IAccount { signer.pubX, signer.pubY ); - return ok ? SIG_OK : SIG_FAIL; } function _payPrefund(uint256 missingAccountFunds) internal { diff --git a/crates/agentkeys-chain/test/P256Account.t.sol b/crates/agentkeys-chain/test/P256Account.t.sol index 19be0964..56455ee1 100644 --- a/crates/agentkeys-chain/test/P256Account.t.sol +++ b/crates/agentkeys-chain/test/P256Account.t.sol @@ -19,11 +19,16 @@ contract Counter { /// mainnet spike (#164 plan §1); here we exercise the account's LOGIC. contract MockK11Verifier { bool public result = true; + bool public doRevert; function setResult(bool r) external { result = r; } + function setRevert(bool r) external { + doRevert = r; + } + function verifyAssertion( bytes32, bytes32, @@ -35,6 +40,8 @@ contract MockK11Verifier { uint256, uint256 ) external view returns (bool) { + // The real K11Verifier reverts on malformed/mismatched assertions; mimic it. + require(!doRevert, "K11: malformed/mismatched"); return result; } } @@ -117,6 +124,29 @@ contract P256AccountTest is Test { acct.validateUserOp(_op(CRED), bytes32(uint256(0x1234)), 0); } + // codex P2: a reverting verifier (malformed/mismatched assertion) must map + // to SIG_VALIDATION_FAILED, not bubble a revert out of validateUserOp. + function test_ValidateUserOp_VerifierRevert_MapsToFail() public { + P256Account acct = _deploy(); + k11.setRevert(true); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED), bytes32(uint256(1)), 0), 1, "verifier revert -> SIG_FAIL"); + } + + function test_ValidateUserOp_MalformedSig_MapsToFail() public { + P256Account acct = _deploy(); + PackedUserOperation memory op; + op.signature = hex"1234"; // not a valid abi.encode tuple -> decode reverts + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(op, bytes32(uint256(1)), 0), 1, "malformed sig -> SIG_FAIL"); + } + + function test_CheckUserOpSignature_OnlySelf() public { + P256Account acct = _deploy(); + vm.expectRevert(P256Account.NotSelf.selector); + acct.checkUserOpSignature(_op(CRED).signature, bytes32(uint256(1))); + } + function test_Execute_FromEntryPoint() public { P256Account acct = _deploy(); vm.prank(ENTRYPOINT); From fc84107b6d6fee7d518dabffa0ef7b3a70526886 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 11:59:27 +0800 Subject: [PATCH 05/19] =?UTF-8?q?agentkeys:=20#164=20docs=20=E2=80=94=20ED?= =?UTF-8?q?/Sybil=20funding=20model=20+=20E3=20design=20+=20build=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan/chain/erc4337-master-account.md | 19 +++++++++++++++++ docs/plan/chain/erc4337-threat-model.md | 25 ++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md index dff76e6e..fc90106e 100644 --- a/docs/plan/chain/erc4337-master-account.md +++ b/docs/plan/chain/erc4337-master-account.md @@ -75,6 +75,25 @@ Each phase is independently shippable, idempotent where it mutates chain state ( --- +## 3.1 Build status (2026-06-02) + +- **E0** ✅ threat-model drafted → [`erc4337-threat-model.md`](erc4337-threat-model.md). +- **E1/E2** ✅ contracts written — `IERC4337.sol`, `P256Account.sol`, `P256AccountFactory.sol` — **codex-reviewed** (1 P2 fixed: verifier/`abi.decode` reverts now map to `SIG_VALIDATION_FAILED` via a try/catch self-call); **17 forge tests green, 0 regressions** (58 total in the crate). +- EntryPoint **v0.7 verified live** on Heima mainnet: `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` (canonical bytecode, landed a UserOp in the spike). +- ⏸️ **E1 mainnet factory deploy is gated** on explicit production-deploy authorization + the §9 review checklist of the threat model. The spike's mainnet contracts were throwaway; the factory is production infra, so it does not auto-deploy. +- **E3 design** below; **E4–E8** pending. + +## 3.2 E3 design — registry thinning + migration + +- **Auth change.** Every `SidecarRegistry`/`AgentKeysScope` master-write changes its guard to `msg.sender == operatorMasterWallet[omni]` where the stored value is now the **account address** (was an EOA). Function signatures **drop the `K11Assertion` param**; `_verifyK11*` / `_verifyAndConsumeK11` / `_verifyQuorum` and `operatorNonce` / `scopeNonce` are **deleted**. The passkey check now happens once, upstream, in the account's `validateUserOp` (which commits the full calldata via `userOpHash`). +- **Functions affected:** `registerAdditionalMasterDevice`, `registerAgentDevice`, `revokeAgentDevice`, `setScopeWithWebauthn`, `revokeScope`, `setRecoveryThreshold`. E4 (agent bind/revoke via account) is the *same* `msg.sender==account` guard — no new code. +- **Bootstrap subtlety — keep an authenticated `operatorOmni`↔passkey binding.** Under the account model the account's `validateUserOp` already commits the `registerFirstMasterDevice(operatorOmni,…)` calldata, so the call proves *the passkey authorized registering this operatorOmni*. But first-call-wins on `operatorOmni` stays front-runnable unless `operatorOmni` is bound to the passkey: #166 relies on "an attacker using their own K11 key yields a different operatorOmni" — i.e. the broker derives `operatorOmni` from the operator identity/key (HDKD). **Decision:** keep #166's self-attestation (or an equivalent on-chain proof tying `operatorOmni` ↔ the registering passkey) in `registerFirstMasterDevice`. The account gate proves "this passkey signed"; it does **not** prove "this passkey owns operatorOmni." Needs its own front-run negative test (E7). +- **Recovery.** `revokeMasterDevice` / `recoveryThreshold` quorum is superseded by the account recovery module (E5, independent guardian path per threat-model §7). **Do not delete it in E3** — keep the existing quorum as the recovery path until E5 lands. +- **Migration.** Existing `operatorMasterWallet[omni]` are cast-bootstrapped EOAs. Cutover = coordinated registry redeploy + re-register each operator's master as their account address via an authenticated ceremony (per #166's activation note) — an operator action, not a silent migration. CI skips first-master, so nothing breaks meanwhile. +- **Test rewrite.** `AgentKeysV1.t.sol` (24 tests) asserts the K11-challenge model; E3 rewrites them to the account-sender model (drop master-write verifier mocks; assert `msg.sender==account` gating + the retained bootstrap binding). This is why E3 ships as its own reviewed increment, with the front-run negative test — not a drive-by edit. + +--- + ## 4. How #164 interacts with the existing invariants - **#166 survives** (the CRITICAL bootstrap item is already satisfied). The self-attestation binds `msg.sender`, which becomes the account address; under 4337 the front-run property *strengthens* (account is CREATE2-bound to the passkey pubkey, so an attacker can deploy the account but cannot make it call the registry without the operator's passkey). E7 revisits whether the explicit self-attestation is still needed once the account model lands, or is subsumed. diff --git a/docs/plan/chain/erc4337-threat-model.md b/docs/plan/chain/erc4337-threat-model.md index 8527550f..747bfe74 100644 --- a/docs/plan/chain/erc4337-threat-model.md +++ b/docs/plan/chain/erc4337-threat-model.md @@ -52,13 +52,28 @@ Therefore one passkey signature authorizes **exactly** this call (`callData` = t --- -## 5. ExistentialDeposit (~0.1 HEI) as a security constraint +## 5. ExistentialDeposit, funding model, and Sybil resistance -Heima rejects EVM value transfers that would leave an account below ED (verified: a 0.05 HEI `depositTo` to a zero-balance EntryPoint failed `OutOfFund`; ≥ ED succeeded). Security implications: +Heima rejects EVM value transfers that would leave an account below the ExistentialDeposit (~0.1 HEI — verified: a 0.05 HEI `depositTo` to a zero-balance EntryPoint failed `OutOfFund`; ≥ ED succeeded; topping an existing account showed the ~0.1 is a one-time creation cost, not a per-tx tax). -- **Funding discipline:** pre-deposit each account's EntryPoint balance generously so `missingAccountFunds == 0` (no per-op transfer in validation — this is what made the spike UserOp pass). Keep EntryPoint + paymaster ≥ ED at all times. -- **Griefing surface:** an attacker cannot drain via dust, but a poorly-funded account/paymaster self-DoSes (txs fail `OutOfFund`). The factory + onboarding must budget the one-time ~0.1 HEI per new account. -- **Action (E1):** read the exact `Balances::ExistentialDeposit` and encode it as the minimum deposit/funding floor. +### Do we fund every new account? No. + +- **A master account never needs its own ED-balance.** ERC-4337 gas is paid from the account's *EntryPoint deposit* — an entry in the EntryPoint's `deposits[account]` mapping (funded via `depositTo(account)`). That HEI lands in the **EntryPoint's** balance, not the account's. The master account exists as a **code-only contract with 0 balance** (verified in the spike). ED only bites an account that *holds native value*; the master never needs to. A new master costs **deploy-gas, not ED**. +- **ED is a one-time infra cost, not per-master.** Only the *first* `depositTo` to a fresh EntryPoint must be ≥ ED. After that, per-account deposits are any size and don't re-trigger ED. Same for the paymaster account (funded once). + +→ **Funding model:** fund the EntryPoint + paymaster once (≥ ED); a **paymaster** then sponsors every master's gas → **zero per-account funding, key-free + gasless.** (Without a paymaster: a small per-master gas deposit — still no ED.) + +### Sybil resistance + +The attack is spamming sponsored UserOps / account creations to drain the paymaster. Defense in depth: + +1. **Sponsorship is gated by the broker's existing operator auth.** The VerifyingPaymaster only sponsors a UserOp the **broker co-signs**, and the broker co-signs only for an **authenticated operator** (valid J1 from email + SIWE + OIDC onboarding). A Sybil with no operator session gets no sponsorship → must self-fund → no drain. Reuses the auth gate we already have; sponsorship is not open to the world. +2. **Becoming a *recognized* master requires authenticated bootstrap (#166/E7).** Anyone can permissionlessly deploy a junk `P256Account` at *their own* gas cost, but it is **inert** — not a registered master, no omni, no scope, no sponsorship. Sybil accounts are harmless noise, not authority. +3. **Per-operator paymaster budgets + rate limits** (broker-enforced) bound abuse by any single (even compromised) operator. + +**Net: Sybil resistance is the broker's operator-auth gate on sponsorship — ED is a one-time liveness cost, not the defense.** + +- **Funding discipline (E1/E6):** pre-deposit generously so `missingAccountFunds == 0` (no per-op transfer in validation — what made the spike UserOp pass); keep EntryPoint + paymaster ≥ ED. Read the exact `Balances::ExistentialDeposit` and encode it as the funding floor. --- From 5e3bfb651e2c45293a69bae2ac9847e0f687c662 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:11:50 +0800 Subject: [PATCH 06/19] =?UTF-8?q?agentkeys:=20ERC-4337=20E3=20=E2=80=94=20?= =?UTF-8?q?thin=20AgentKeysScope=20to=20account-auth=20(retire=20in-contra?= =?UTF-8?q?ct=20K11=20+=20scopeNonce);=2059=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../script/DeployAgentKeysV1.s.sol | 5 +- crates/agentkeys-chain/src/AgentKeysScope.sol | 143 +++--------------- .../agentkeys-chain/src/SidecarRegistry.sol | 14 ++ crates/agentkeys-chain/test/AgentKeysV1.t.sol | 59 ++++---- 4 files changed, 65 insertions(+), 156 deletions(-) diff --git a/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol b/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol index ea18eb14..616f06d1 100644 --- a/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol +++ b/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol @@ -32,7 +32,10 @@ contract DeployAgentKeysV1 is Script { P256Verifier p256 = new P256Verifier(); K11Verifier k11 = new K11Verifier(address(p256)); SidecarRegistry registry = new SidecarRegistry(address(k11)); - AgentKeysScope scope = new AgentKeysScope(address(registry), address(k11)); + // #164 E3: AgentKeysScope no longer verifies K11 itself (scope writes are + // authorized by the operator's ERC-4337 master account). Constructor takes + // only the registry now. + AgentKeysScope scope = new AgentKeysScope(address(registry)); K3EpochCounter epoch = new K3EpochCounter(signerGov); // Audit appendRoot gates on operator-master via the registry (codex M1). CredentialAudit audit = new CredentialAudit(address(registry)); diff --git a/crates/agentkeys-chain/src/AgentKeysScope.sol b/crates/agentkeys-chain/src/AgentKeysScope.sol index f4a062a1..7a76650f 100644 --- a/crates/agentkeys-chain/src/AgentKeysScope.sol +++ b/crates/agentkeys-chain/src/AgentKeysScope.sol @@ -1,29 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.20; -import {K11Verifier} from "./K11Verifier.sol"; - -/// @notice Minimal SidecarRegistry surface AgentKeysScope needs for K11 auth. +/// @notice Minimal SidecarRegistry surface AgentKeysScope needs. interface ISidecarRegistry { - struct DeviceEntry { - bytes32 operatorOmni; - bytes32 actorOmni; - bytes32 k11CredId; - bytes32 k11RpIdHash; - uint256 k11PubX; - uint256 k11PubY; - uint8 tier; - uint8 roles; - uint64 registeredAt; - uint32 lastSignCount; - bool revoked; - } - function operatorMasterWallet(bytes32 operatorOmni) external view returns (address); - function operatorNonce(bytes32 operatorOmni) external view returns (uint256); - function getDevice(bytes32 deviceKeyHash) external view returns (DeviceEntry memory); - function ROLE_SCOPE_MGMT() external view returns (uint8); - function TIER_MASTER() external view returns (uint8); } /// @title AgentKeysScope — per-(operator, agent) scope state @@ -31,17 +11,18 @@ interface ISidecarRegistry { /// Read by the broker on cap-mint AND by workers on cap-verify /// (arch.md §12.4, §13.1, §19). /// -/// @dev Stage-2 (#90) hardening: scope mutations are K11-bound via on-chain -/// P-256 verify against the asserting master's registered K11 pubkey. -/// K11 challenge commits to (operation || operator || agent || services -/// hash || chainid || scopeNonce[op][agent]) so a captured sig cannot -/// be replayed for a different scope target. +/// @dev #164 E3 (Solution A — ERC-4337 P-256 master). Scope mutations are +/// authorized by `msg.sender == operatorMasterWallet(operator)`, where +/// the master is now an ERC-4337 P-256 smart account. The passkey check +/// happens ONCE, upstream, in the account's `validateUserOp` over the +/// `userOpHash` — which commits the entire setScope/revokeScope calldata, +/// so the signature is a provably-complete full-intent authorization. +/// Consequently the per-op on-chain K11 challenge, `scopeNonce`, and the +/// K11Verifier dependency are RETIRED here (they lived in the pre-#164 +/// EOA model). Replay is the EntryPoint 2D nonce. See +/// docs/plan/chain/erc4337-master-account.md §3.2. contract AgentKeysScope { ISidecarRegistry public immutable registry; - K11Verifier public immutable k11Verifier; - - bytes32 public constant OP_SET_SCOPE = keccak256("agentkeys:v1:set-scope"); - bytes32 public constant OP_REVOKE_SCOPE = keccak256("agentkeys:v1:revoke-scope"); struct Scope { bytes32[] services; @@ -54,19 +35,8 @@ contract AgentKeysScope { bool exists; } - struct K11Assertion { - bytes32 attestingDeviceKeyHash; - bytes authenticatorData; - bytes clientDataJSON; - uint256 challengeLocation; - uint256 r; - uint256 s; - } - /// @notice operator_omni → agent_omni → Scope mapping(bytes32 => mapping(bytes32 => Scope)) private scopes; - /// @notice per-(operator, agent) monotonic nonce for anti-replay of K11 - mapping(bytes32 => mapping(bytes32 => uint256)) public scopeNonce; event ScopeUpdated( bytes32 indexed operatorOmni, @@ -82,18 +52,16 @@ contract AgentKeysScope { error OperatorNotRegistered(bytes32 operatorOmni); error NotAuthorized(address caller, address expected); - error InvalidAttestingDevice(bytes32 deviceKeyHash); - error K11VerificationFailed(); - error K11RoleMissing(uint8 required); error ScopeNotSet(bytes32 operatorOmni, bytes32 agentOmni); - constructor(address registryAddr, address k11VerifierAddr) { + constructor(address registryAddr) { registry = ISidecarRegistry(registryAddr); - k11Verifier = K11Verifier(k11VerifierAddr); } - /// @notice Grant or replace an agent's scope. Master-mutation, K11-gated. - function setScopeWithWebauthn( + /// @notice Grant or replace an agent's scope. Master-mutation: authorized by + /// the operator's master account (passkey-gated upstream in the + /// account's validateUserOp; see the contract-level notes). + function setScope( bytes32 operatorOmni, bytes32 agentOmni, bytes32[] calldata services, @@ -101,32 +69,12 @@ contract AgentKeysScope { uint128 maxPerCall, uint128 maxPerPeriod, uint128 maxTotal, - uint32 periodSeconds, - K11Assertion calldata assertion + uint32 periodSeconds ) external { address master = registry.operatorMasterWallet(operatorOmni); if (master == address(0)) revert OperatorNotRegistered(operatorOmni); if (msg.sender != master) revert NotAuthorized(msg.sender, master); - bytes32 servicesDigest = keccak256(abi.encode(services)); - bytes32 expectedChallenge = keccak256( - abi.encode( - OP_SET_SCOPE, - operatorOmni, - agentOmni, - servicesDigest, - readOnly, - maxPerCall, - maxPerPeriod, - maxTotal, - periodSeconds, - block.chainid, - scopeNonce[operatorOmni][agentOmni] - ) - ); - _verifyK11(expectedChallenge, operatorOmni, assertion); - scopeNonce[operatorOmni][agentOmni] += 1; - scopes[operatorOmni][agentOmni] = Scope({ services: services, readOnly: readOnly, @@ -150,12 +98,8 @@ contract AgentKeysScope { ); } - /// @notice Revoke an agent's entire scope. Master-mutation, K11-gated. - function revokeScope( - bytes32 operatorOmni, - bytes32 agentOmni, - K11Assertion calldata assertion - ) external { + /// @notice Revoke an agent's entire scope. Master-mutation (see setScope). + function revokeScope(bytes32 operatorOmni, bytes32 agentOmni) external { address master = registry.operatorMasterWallet(operatorOmni); if (master == address(0)) revert OperatorNotRegistered(operatorOmni); if (msg.sender != master) revert NotAuthorized(msg.sender, master); @@ -163,18 +107,6 @@ contract AgentKeysScope { revert ScopeNotSet(operatorOmni, agentOmni); } - bytes32 expectedChallenge = keccak256( - abi.encode( - OP_REVOKE_SCOPE, - operatorOmni, - agentOmni, - block.chainid, - scopeNonce[operatorOmni][agentOmni] - ) - ); - _verifyK11(expectedChallenge, operatorOmni, assertion); - scopeNonce[operatorOmni][agentOmni] += 1; - delete scopes[operatorOmni][agentOmni]; emit ScopeRevoked(operatorOmni, agentOmni); } @@ -199,41 +131,4 @@ contract AgentKeysScope { } return false; } - - /// @dev Verify K11 assertion against an asserting MASTER device with the - /// SCOPE_MGMT role. Caller is responsible for incrementing the per- - /// (operator, agent) scopeNonce after this returns. - function _verifyK11( - bytes32 expectedChallenge, - bytes32 expectedOperatorOmni, - K11Assertion calldata a - ) internal view { - ISidecarRegistry.DeviceEntry memory entry = registry.getDevice(a.attestingDeviceKeyHash); - if (entry.registeredAt == 0 || entry.revoked) { - revert InvalidAttestingDevice(a.attestingDeviceKeyHash); - } - if (entry.tier != registry.TIER_MASTER()) { - revert InvalidAttestingDevice(a.attestingDeviceKeyHash); - } - if (entry.operatorOmni != expectedOperatorOmni) { - revert InvalidAttestingDevice(a.attestingDeviceKeyHash); - } - uint8 requiredRole = registry.ROLE_SCOPE_MGMT(); - if ((entry.roles & requiredRole) == 0) { - revert K11RoleMissing(requiredRole); - } - - bool ok = k11Verifier.verifyAssertion( - expectedChallenge, - entry.k11RpIdHash, - a.authenticatorData, - a.clientDataJSON, - a.challengeLocation, - a.r, - a.s, - entry.k11PubX, - entry.k11PubY - ); - if (!ok) revert K11VerificationFailed(); - } } diff --git a/crates/agentkeys-chain/src/SidecarRegistry.sol b/crates/agentkeys-chain/src/SidecarRegistry.sol index ed295e52..e869e983 100644 --- a/crates/agentkeys-chain/src/SidecarRegistry.sol +++ b/crates/agentkeys-chain/src/SidecarRegistry.sol @@ -18,6 +18,20 @@ import {K11Verifier} from "./K11Verifier.sol"; /// device requires >= recoveryThreshold[operator] valid K11 sigs /// from distinct registered masters with the RECOVERY role. /// - DeviceEntry stores K11 P-256 pubkey (x, y) for on-chain verify. +/// +/// #164 E3 (Solution A): `operatorMasterWallet` now holds the operator's +/// ERC-4337 P-256 master **account** address (was an EOA). Every +/// `msg.sender == master` check therefore means "a passkey authorized +/// this call" (the account's validateUserOp verified the passkey over +/// the userOpHash, which commits this calldata). This structurally +/// closes the agent-bind/revoke biometric gap — `registerAgentDevice` / +/// `revokeAgentDevice` keep their `msg.sender == master` guard, now +/// passkey-gated via the account (no new K11 code needed). The +/// multi-master + recovery functions (`registerAdditionalMasterDevice`, +/// `revokeMasterDevice`, `setRecoveryThreshold`) and their per-op K11 + +/// `operatorNonce` machinery are RETAINED here pending #164 E5, which +/// folds multi-passkey + quorum recovery into the account itself. See +/// docs/plan/chain/erc4337-master-account.md §3.2. contract SidecarRegistry { // ─── Role bitfield (per device, per arch.md §6.3) ──────────────────── uint8 public constant ROLE_CAP_MINT = 1 << 0; diff --git a/crates/agentkeys-chain/test/AgentKeysV1.t.sol b/crates/agentkeys-chain/test/AgentKeysV1.t.sol index 5f9f5a9a..eaf6f03b 100644 --- a/crates/agentkeys-chain/test/AgentKeysV1.t.sol +++ b/crates/agentkeys-chain/test/AgentKeysV1.t.sol @@ -67,7 +67,7 @@ contract AgentKeysV1Test is Test { p256 = new P256Verifier(); k11 = new K11Verifier(address(p256)); registry = new SidecarRegistry(address(k11)); - scope = new AgentKeysScope(address(registry), address(k11)); + scope = new AgentKeysScope(address(registry)); epoch = new K3EpochCounter(address(this)); audit = new CredentialAudit(address(registry)); } @@ -323,29 +323,44 @@ contract AgentKeysV1Test is Test { registry.revokeMasterDevice(deviceKeyHashMaster, bogus); } - // ─── AgentKeysScope: rejects without K11 ───────────────────────────── - function test_SetScope_RejectsAttacker() public { + // ─── AgentKeysScope (#164 E3: account-authorized, K11 retired) ─────── + function test_SetScope_RejectsNonMaster() public { _registerFirstMaster(); bytes32[] memory services = new bytes32[](0); - AgentKeysScope.K11Assertion memory bogus = _bogusScopeAssertion(deviceKeyHashMaster); vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector(AgentKeysScope.NotAuthorized.selector, attacker, master) ); - scope.setScopeWithWebauthn( - operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, bogus - ); + scope.setScope(operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0); } - function test_SetScope_RejectsInvalidK11() public { + /// @notice #164 E3: scope writes are authorized by `msg.sender == master + /// account` (the passkey check happened upstream in the account's + /// validateUserOp). The master sets + revokes scope directly. + function test_SetScope_MasterSucceedsAndRevokes() public { _registerFirstMaster(); - bytes32[] memory services = new bytes32[](0); - AgentKeysScope.K11Assertion memory bogus = _bogusScopeAssertion(deviceKeyHashMaster); + bytes32 svc = keccak256("memory"); + bytes32[] memory services = new bytes32[](1); + services[0] = svc; + vm.prank(master); - vm.expectRevert(); - scope.setScopeWithWebauthn( - operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, bogus + scope.setScope(operatorOmni, actorOmniAgentA, services, false, 100, 1000, 10000, 86400); + assertTrue(scope.isServiceInScope(operatorOmni, actorOmniAgentA, svc)); + + vm.prank(master); + scope.revokeScope(operatorOmni, actorOmniAgentA); + assertFalse(scope.isServiceInScope(operatorOmni, actorOmniAgentA, svc)); + } + + function test_RevokeScope_RejectsWhenUnset() public { + _registerFirstMaster(); + vm.prank(master); + vm.expectRevert( + abi.encodeWithSelector( + AgentKeysScope.ScopeNotSet.selector, operatorOmni, actorOmniAgentA + ) ); + scope.revokeScope(operatorOmni, actorOmniAgentA); } // ─── K3EpochCounter (unchanged from PR #87) ────────────────────────── @@ -575,22 +590,4 @@ contract AgentKeysV1Test is Test { }); } - function _bogusScopeAssertion(bytes32 attestingDevice) - internal - pure - returns (AgentKeysScope.K11Assertion memory) - { - bytes memory authData = new bytes(37); - bytes memory cdj = bytes( - '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}' - ); - return AgentKeysScope.K11Assertion({ - attestingDeviceKeyHash: attestingDevice, - authenticatorData: authData, - clientDataJSON: cdj, - challengeLocation: 36, - r: 1, - s: 1 - }); - } } From ccf9af0bc3f8b23761943cefe9f59bfd80617afb Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:14:56 +0800 Subject: [PATCH 07/19] agentkeys: record #164 E1 live addresses (EntryPoint v0.7 + factory) + E3 status + cutover ripples --- docs/plan/chain/erc4337-master-account.md | 11 +++++++---- docs/spec/deployed-contracts.md | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md index fc90106e..02ff7a9e 100644 --- a/docs/plan/chain/erc4337-master-account.md +++ b/docs/plan/chain/erc4337-master-account.md @@ -78,10 +78,13 @@ Each phase is independently shippable, idempotent where it mutates chain state ( ## 3.1 Build status (2026-06-02) - **E0** ✅ threat-model drafted → [`erc4337-threat-model.md`](erc4337-threat-model.md). -- **E1/E2** ✅ contracts written — `IERC4337.sol`, `P256Account.sol`, `P256AccountFactory.sol` — **codex-reviewed** (1 P2 fixed: verifier/`abi.decode` reverts now map to `SIG_VALIDATION_FAILED` via a try/catch self-call); **17 forge tests green, 0 regressions** (58 total in the crate). -- EntryPoint **v0.7 verified live** on Heima mainnet: `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` (canonical bytecode, landed a UserOp in the spike). -- ⏸️ **E1 mainnet factory deploy is gated** on explicit production-deploy authorization + the §9 review checklist of the threat model. The spike's mainnet contracts were throwaway; the factory is production infra, so it does not auto-deploy. -- **E3 design** below; **E4–E8** pending. +- **E2** ✅ `IERC4337.sol`, `P256Account.sol`, `P256AccountFactory.sol` — **codex-reviewed** (1 P2 fixed: verifier/`abi.decode` reverts now map to `SIG_VALIDATION_FAILED` via a try/catch self-call); **17 account tests green**. +- **E1** ✅ **deployed live on Heima mainnet 2026-06-02** (recorded in [`deployed-contracts.md`](../../spec/deployed-contracts.md)): + - `EntryPoint` v0.7 = `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` (canonical bytecode; landed a UserOp in the spike). + - `P256AccountFactory` = `0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3` (CREATE2 determinism smoke-verified on mainnet: `getAddress` == `createAccount`). +- **E3** ✅ **implemented** — `AgentKeysScope` thinned to account-auth (in-contract K11 + `scopeNonce` retired; `setScopeWithWebauthn`→`setScope`); registry agent-bind closed structurally (master = account); master-device/recovery K11 retained pending E5. **59 crate tests green** (net −91 lines). +- ⏭️ **Cutover ripples (not yet done):** the broker's scope-mint call + `harness/scripts/heima-scope-set.sh` use `setScopeWithWebauthn(... assertion)` — update to `setScope(...)` (no assertion) at redeploy; `AgentKeysScope` now deploys with 1 constructor arg; `operator-workstation.env` gets `ENTRYPOINT_ADDRESS_HEIMA` + `P256_ACCOUNT_FACTORY_ADDRESS_HEIMA` via `heima-bring-up.sh env_set`; `verify-heima-contracts.sh` extended to check the EntryPoint + factory. arch.md §10/§12 update lands at cutover (registry/scope redeploy). +- **E4** folded into E3 (structural). **E5–E8** pending. ## 3.2 E3 design — registry thinning + migration diff --git a/docs/spec/deployed-contracts.md b/docs/spec/deployed-contracts.md index c7f7688d..5dfcd38c 100644 --- a/docs/spec/deployed-contracts.md +++ b/docs/spec/deployed-contracts.md @@ -17,6 +17,13 @@ Same addresses are mirrored into [`scripts/operator-workstation.env`](../../scri | `P256Verifier` | `0xda5b772f9d6c09abe80414eea908612df9b54749` | (pre-deployed verifier) | | `K11Verifier` | `0x5a441431f08e0f5f5ed10659620cb4e0e814e627` | (pre-deployed verifier) | +**ERC-4337 master infra (#164 E1, deployed 2026-06-02)** — foundation plumbing for the P-256 smart-account master ([plan](../plan/chain/erc4337-master-account.md)). **NOT yet the live master-auth:** the registry/scope cutover to account-authorization (#164 E3/E7) is a later coordinated redeploy; these are inert until masters are registered as accounts. Deployer `0xdE64…63Bc`. + +| Contract | Address | Notes | +|---|---|---| +| `EntryPoint` (ERC-4337 v0.7) | `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` | canonical eth-infinitism v0.7 bytecode; landed a UserOp end-to-end in the spike | +| `P256AccountFactory` | `0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3` | CREATE2 factory; `constructor(entryPoint, k11Verifier)`; wired to the live `K11Verifier` | + **Historical v1 deploy** (superseded by v2 above; preserved for cross-reference of old txs): | Contract | Address | Bytecode | From b66f4d2e167e8fb121634e04e1a89988b2f5a471 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:27:44 +0800 Subject: [PATCH 08/19] =?UTF-8?q?agentkeys:=20consolidate=20deployed=20add?= =?UTF-8?q?resses=20into=20docs/contracts.md=20+=20arch.md=20=C2=A75=20anc?= =?UTF-8?q?hor;=20deployed-contracts.md=E2=86=92redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- docs/arch.md | 2 + docs/contracts.md | 72 ++++++++++++++++ docs/spec/deployed-contracts.md | 148 +------------------------------- 4 files changed, 78 insertions(+), 146 deletions(-) create mode 100644 docs/contracts.md diff --git a/CLAUDE.md b/CLAUDE.md index 7c13e50d..c7e0e41c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,7 +257,7 @@ Determine the real opcode level any time by *executing* a probe on a dev chain ( ## Deployed contract registry -Live v2 stage-1 contract addresses on each chain are kept in [`docs/spec/deployed-contracts.md`](docs/spec/deployed-contracts.md). The same addresses are also written to `scripts/operator-workstation.env` (via `env_set` in `scripts/heima-bring-up.sh` step 6) for shell-script consumption — those env-file entries are the operational source of truth and `deployed-contracts.md` is the human-readable canonical record (deployer, deploy date, block, explorer links, ABI summary). +Live contract addresses on each chain (Heima mainnet v2 set, the ERC-4337 master infra #164, historical v1) are kept in [`docs/contracts.md`](docs/contracts.md) — the single canonical registry, indexed from `arch.md` §5. (The old `docs/spec/deployed-contracts.md` is now a redirect to it.) The same addresses are also written to `scripts/operator-workstation.env` (via `env_set` in `scripts/heima-bring-up.sh` step 6) for shell-script consumption — those env-file entries are the operational source of truth and `docs/contracts.md` is the human-readable canonical record (deployer, deploy date, block, explorer links, ABI summary). Verify all contracts are live + functional any time: diff --git a/docs/arch.md b/docs/arch.md index 9897fc4f..94f2ea5d 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -199,6 +199,8 @@ flowchart TB Pinned to disambiguate the same value showing up under different labels across components. **Use the canonical column** in every new doc, runbook, CLI output, and commit message; the alias column lists every spelling that exists today so a reader chasing one of them can find their way back. Per `CLAUDE.md` → "Terminology-source-of-truth rule", if you introduce a name not in this table, either add the alias row here or rename the call site to match the canonical name in the same change. +> **Deployed addresses** for every contract named here (per chain — Heima mainnet v2 set, the ERC-4337 master infra #164, historical v1) live in [`contracts.md`](contracts.md), the canonical address registry. Mirrored to `scripts/operator-workstation.env` for tooling. + | Canonical name | Identity | Aliases seen in the codebase / docs | |---|---|---| | `actor_omni` | **The durable per-actor cryptographic anchor.** `SHA256("agentkeys" \|\| "evm" \|\| initial_master_wallet_K3_v1)`. **Frozen at first SIWE-bind**; never rotates with K3, never changes with wallet rotation. The Layer 1 identifier per §6. | `omni_account` (JWT claim + CLI `whoami` field), `agentkeys_actor_omni` (AWS PrincipalTag key), `OMNI_A` / `OMNI_B` (demo shell vars). | diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 00000000..1c89e9b5 --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,72 @@ +# Deployed contracts — canonical registry + +**Single source of truth** for every on-chain contract address AgentKeys has deployed, per chain. Answers "what's the live address of `SidecarRegistry` / the ERC-4337 `EntryPoint` on Heima mainnet right now?" + +Mirrored into [`scripts/operator-workstation.env`](../scripts/operator-workstation.env) (the shell-consumable form, written by `scripts/heima-bring-up.sh` step 6 via `env_set`). When the two diverge, **this doc is authoritative for human reads, the env file for tooling**; the bring-up script keeps both in sync. Indexed from [`arch.md`](arch.md) §5. + +--- + +## Heima mainnet (chain_id = 212013) + +### v2 stage-1 set (current live) + +| Contract | Address | Bytecode | +|---|---|---| +| `AgentKeysScope` | `0xd44b375daefc65768f417d0f0125b68d5ba7df3b` | 4572 bytes | +| `SidecarRegistry` | `0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE` | 4572 bytes | +| `K3EpochCounter` | `0x6c9e675c699a06acefbc156afdee6bfbfe32ccb3` | 591 bytes | +| `CredentialAudit` | `0x63c4545ac01c77cc74044f25b8edea3880224577` | 3043 bytes | +| `P256Verifier` | `0xda5b772f9d6c09abe80414eea908612df9b54749` | (pre-deployed verifier) | +| `K11Verifier` | `0x5a441431f08e0f5f5ed10659620cb4e0e814e627` | (pre-deployed verifier) | + +### ERC-4337 master infra (#164, deployed 2026-06-02) + +Foundation plumbing for the P-256 smart-account master ([plan](plan/chain/erc4337-master-account.md)). **NOT yet the live master-auth:** the registry/scope cutover to account-authorization (#164 E3/E7) is a later coordinated redeploy; these are inert until masters are registered as accounts. + +| Contract | Address | Notes | +|---|---|---| +| `EntryPoint` (ERC-4337 v0.7) | `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` | canonical eth-infinitism v0.7 bytecode; landed a UserOp end-to-end in the spike | +| `P256AccountFactory` | `0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3` | CREATE2 factory; `constructor(entryPoint, k11Verifier)`; wired to the live `K11Verifier`; mainnet CREATE2 determinism smoke-verified | + +### Historical v1 deploy (superseded by v2; preserved for old-tx cross-reference) + +| Contract | Address | Bytecode | +|---|---|---| +| `AgentKeysScope` | `0x14C23B5D1cE20c094af643a20e6b0972dAD12aa8` | 3146 bytes | +| `SidecarRegistry` | `0x76D574a107727bE87fc1422661A030FEFda70786` | 3301 bytes | +| `K3EpochCounter` | `0x8396dEc50ff755d6DE7728DABB00Be2eFBCdf4dF` | 687 bytes | +| `CredentialAudit` | `0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06` | 1421 bytes | + +## Heima Paseo testnet (chain_id = 2013) + +Halted (block 2,905,430 frozen since 2026-01-15). **No contracts deployed** — the `*_ADDRESS_HEIMA_PASEO` entries in `operator-workstation.env` are placeholders (`0x..01`–`0x..04`). When collators return: `AGENTKEYS_CHAIN=heima-paseo bash harness/v2-stage1-demo.sh --only-step 9` deploys + auto-funds via Alice sudo; update this doc with the live testnet addresses then. + +--- + +## Deploy metadata (Heima mainnet v2) + +- Deployer wallet (EVM): `0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc` +- Deployer wallet (Substrate SS58 prefix 31): `47NGSq6JE5ZSnymGNa4nFVjWbsuhTfoSKN2jtpk28mUyC1M3` *(see [funding the EVM side via the Substrate twin](../scripts/evm-to-substrate-address.mjs))* +- v2 deploy date: 2026-05-19 · #164 E1 deploy date: 2026-06-02 +- Compiler: Solc 0.8.20, `evm_version = "london"` (a `forge script` header-validation workaround, NOT Heima's EVM level — Heima executes **Cancun**; see CLAUDE.md "Heima EVM compatibility level"). The EntryPoint v0.7 is the canonical eth-infinitism bytecode, deployed via `forge create`. +- Deploy script: [`crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol`](../crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol) + +**Constructor wiring** (verified post-deploy): +- `AgentKeysScope.registry()` = the v2 `SidecarRegistry` ✓ +- `P256AccountFactory.entryPoint()` = the v0.7 `EntryPoint` ✓, `.k11Verifier()` = the live `K11Verifier` ✓ +- `K3EpochCounter.currentEpoch()` = `1`; `.signerGovernance()` = deployer (to be transferred to an M-of-N multisig) +- `SidecarRegistry.ROLE_CAP_MINT()` = `1`, `ROLE_RECOVERY()` = `2`, `ROLE_SCOPE_MGMT()` = `4` ✓ + +## Verifying contracts are live (read-only RPC, zero gas) + +```bash +# One-shot health check across the v2 set: +AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh # exits 0 on all-pass + +# Bytecode presence (eth_getCode), e.g. the ERC-4337 EntryPoint: +cast code 0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9 --rpc-url https://rpc.heima-parachain.heima.network | head -c 12 +# View call, e.g. factory wiring: +cast call 0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3 "entryPoint()(address)" --rpc-url https://rpc.heima-parachain.heima.network +``` + +**Explorer note:** [`heima.statescan.io`](https://heima.statescan.io/) is Substrate-side — it indexes pallet extrinsics/events but does NOT decode EVM calls/bytecode. EVM contract verification on Heima goes via direct RPC until agentkeys-specific indexing on Litentry's `subscan-essentials` fork ships (arch.md §22a.6). diff --git a/docs/spec/deployed-contracts.md b/docs/spec/deployed-contracts.md index 5dfcd38c..246b2f9b 100644 --- a/docs/spec/deployed-contracts.md +++ b/docs/spec/deployed-contracts.md @@ -1,147 +1,5 @@ -# Deployed contracts — v2 stage 1 +# Deployed contracts — moved -**Canonical record** of the four v2 stage-1 Solidity contracts deployed to each chain. Source-of-truth for "what's the live address of `SidecarRegistry` on Heima mainnet right now?" +The canonical deployed-contract registry now lives at **[`docs/contracts.md`](../contracts.md)** (single source for every on-chain address across chains, indexed from [`arch.md`](../arch.md) §5). -Same addresses are mirrored into [`scripts/operator-workstation.env`](../../scripts/operator-workstation.env) (the shell-script-consumable form, written by `scripts/heima-bring-up.sh` step 6 via `env_set`). When the two diverge, **this doc is authoritative for human reads, the env file for tooling**. The bring-up script keeps both in sync. - -## Heima mainnet (chain_id = 212013) - -**v2 (current live)** — wider AgentKeysScope + SidecarRegistry surface: - -| Contract | Address | Bytecode | -|---|---|---| -| `AgentKeysScope` | `0xd44b375daefc65768f417d0f0125b68d5ba7df3b` | 4572 bytes | -| `SidecarRegistry` | `0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE` | 4572 bytes | -| `K3EpochCounter` | `0x6c9e675c699a06acefbc156afdee6bfbfe32ccb3` | 591 bytes | -| `CredentialAudit` | `0x63c4545ac01c77cc74044f25b8edea3880224577` | 3043 bytes | -| `P256Verifier` | `0xda5b772f9d6c09abe80414eea908612df9b54749` | (pre-deployed verifier) | -| `K11Verifier` | `0x5a441431f08e0f5f5ed10659620cb4e0e814e627` | (pre-deployed verifier) | - -**ERC-4337 master infra (#164 E1, deployed 2026-06-02)** — foundation plumbing for the P-256 smart-account master ([plan](../plan/chain/erc4337-master-account.md)). **NOT yet the live master-auth:** the registry/scope cutover to account-authorization (#164 E3/E7) is a later coordinated redeploy; these are inert until masters are registered as accounts. Deployer `0xdE64…63Bc`. - -| Contract | Address | Notes | -|---|---|---| -| `EntryPoint` (ERC-4337 v0.7) | `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` | canonical eth-infinitism v0.7 bytecode; landed a UserOp end-to-end in the spike | -| `P256AccountFactory` | `0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3` | CREATE2 factory; `constructor(entryPoint, k11Verifier)`; wired to the live `K11Verifier` | - -**Historical v1 deploy** (superseded by v2 above; preserved for cross-reference of old txs): - -| Contract | Address | Bytecode | -|---|---|---| -| `AgentKeysScope` | `0x14C23B5D1cE20c094af643a20e6b0972dAD12aa8` | 3146 bytes | -| `SidecarRegistry` | `0x76D574a107727bE87fc1422661A030FEFda70786` | 3301 bytes | -| `K3EpochCounter` | `0x8396dEc50ff755d6DE7728DABB00Be2eFBCdf4dF` | 687 bytes | -| `CredentialAudit` | `0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06` | 1421 bytes | - -**Explorer note**: [`heima.statescan.io`](https://heima.statescan.io/) is a Substrate-side explorer — it indexes pallet extrinsics + events but does NOT decode EVM contract calls or bytecode. Verifying EVM contracts on Heima today goes via direct RPC, not the explorer. The recipes (pointing at the live v2 deploy): - -```bash -# Bytecode presence (eth_getCode) — v2 AgentKeysScope: -curl -sS -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"eth_getCode","params":["0xd44b375daefc65768f417d0f0125b68d5ba7df3b","latest"],"id":1}' \ - https://rpc.heima-parachain.heima.network | jq -r '.result' | head -c 40 -# → non-"0x" output = contract bytecode present - -# View function (cast call, zero gas) — v2 SidecarRegistry: -cast call 0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE "ROLE_CAP_MINT()(uint8)" \ - --rpc-url https://rpc.heima-parachain.heima.network -# → 1 -``` - -Or run the one-shot health check: - -```bash -AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh -# → 13 checks across all 4 contracts; exits 0 on all-pass -``` - -Future stage-2/3 work: agentkeys-specific indexing on top of Litentry's fork of `subscan-essentials` ([backend](https://github.com/litentry/subscan-essentials) + [UI](https://github.com/litentry/subscan-essentials-ui-react)) per arch.md §22a.6 — this will surface contract calls/events at the explorer level. Until that ships, RPC is the source of truth. - -**Deploy metadata**: -- Deployer wallet (EVM): `0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc` -- Deployer wallet (Substrate SS58 prefix 31): `47NGSq6JE5ZSnymGNa4nFVjWbsuhTfoSKN2jtpk28mUyC1M3` *(see [funding the EVM side via the Substrate twin](../../scripts/evm-to-substrate-address.mjs))* -- Deploy date: 2026-05-19 -- Compiler: Solc 0.8.20, `evm_version = "london"` (a `forge script` header-validation workaround, NOT Heima's EVM level — Heima's execution level is actually Cancun; see CLAUDE.md "Heima EVM compatibility level") -- Forge: 1.6.0 -- Deploy script: [`crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol`](../../crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol) - -**Constructor wiring** (verified post-deploy against v2): -- `AgentKeysScope.registry()` = `0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE` (= the deployed v2 SidecarRegistry above) ✓ -- `K3EpochCounter.currentEpoch()` = `1` (initialized) ✓ -- `K3EpochCounter.signerGovernance()` = `0xdE644936D5B7d5d42032fd08bbA42Fbbfd6663Bc` (deployer; expected to be transferred to the operational signer wallet OR an M-of-N multisig in stage 2 via `setSignerGovernance(newGov)`) -- `SidecarRegistry.ROLE_CAP_MINT()` = `1`, `ROLE_RECOVERY()` = `2`, `ROLE_SCOPE_MGMT()` = `4` ✓ - -## Heima Paseo testnet (chain_id = 2013) - -Currently halted (block 2,905,430 frozen since 2026-01-15; 4+ months). No stage-1 contracts deployed yet. When collators come back online, run: - -```bash -AGENTKEYS_CHAIN=heima-paseo bash harness/v2-stage1-demo.sh --only-step 9 -``` - -…to deploy + auto-fund via Alice sudo. This doc will be updated with the live testnet addresses once that lands. - -## Verifying the contracts are live + functional - -Read-only RPC check (zero gas): - -```bash -AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh -``` - -Checks performed (all four pass right now per the deploy verification): - -1. **Bytecode presence** — `eth_getCode` for each contract returns non-empty bytecode -2. **View functions** — each contract responds to a known constant view function with the expected value (catches "wrong contract code at this slot" drift) -3. **Constructor wiring** — `AgentKeysScope.registry()` points at the deployed `SidecarRegistry` (catches wrong-address-in-constructor) -4. **Initialization** — `K3EpochCounter.currentEpoch ≥ 1`, `signerGovernance != address(0)` - -The script reads addresses from `operator-workstation.env`, so changing `AGENTKEYS_CHAIN` picks up the chain-specific deployment. - -## Re-deploy / replace - -Re-running `bash harness/v2-stage1-demo.sh --only-step 9` is **idempotent**: step 5 calls `cast code` on each stored address and skips the deploy if all four already have on-chain bytecode. Re-deploys only fire when: - -- Stored address in `operator-workstation.env` is the `0x0` sentinel or absent -- OR the stored address has no bytecode on-chain (chain reset, address corrupted) - -To **force** a fresh deploy at new addresses (e.g. after a contract patch), manually clear the address entries from `operator-workstation.env` (or set them to `0x0`) and re-run. - -After any re-deploy, **update this doc** with the new addresses + bytecode sizes + deploy date. The deploy operator is responsible for the doc bump; the bring-up script handles `operator-workstation.env` automatically but doesn't touch markdown. - -## ABI summary - -Full ABIs in [`crates/agentkeys-chain/src/*.sol`](../../crates/agentkeys-chain/src/). The functions broker + workers + CLI read on hot paths: - -### `SidecarRegistry` -- `registerMasterDevice(bytes32 deviceKeyHash, bytes32 operatorOmni, bytes32 actorOmni, bytes32 k11CredId, bytes attestation, uint8 roles, bytes k11Assertion)` — first call bootstraps `operatorMasterWallet[operatorOmni] = msg.sender`; subsequent require existing master + K11 -- `registerAgentDevice(bytes32 deviceKeyHash, bytes32 operatorOmni, bytes32 actorOmni, bytes linkCodeRedemption, bytes agentPopSig)` — master-only; agents get `ROLE_CAP_MINT` only -- `revokeDevice(bytes32 deviceKeyHash, bytes k11Assertion)` — master-only; K11 required for master tier -- `getDevice(bytes32 deviceKeyHash) → DeviceEntry` — view -- `isActive(bytes32 deviceKeyHash) → bool` — hot-path view for workers -- `operatorMasterWallet(bytes32 operatorOmni) → address` — auto-generated getter - -### `AgentKeysScope` -- `setScopeWithWebauthn(bytes32 operatorOmni, bytes32 agentOmni, bytes32[] services, bool readOnly, uint128 maxPerCall, uint128 maxPerPeriod, uint128 maxTotal, uint32 periodSeconds, bytes k11Assertion)` — master-only, K11-gated -- `revokeScope(bytes32 operatorOmni, bytes32 agentOmni, bytes k11Assertion)` — master-only, K11-gated -- `getScope(bytes32 operatorOmni, bytes32 agentOmni) → Scope` — view -- `isServiceInScope(bytes32 operatorOmni, bytes32 agentOmni, bytes32 serviceHash) → bool` — hot-path view - -### `K3EpochCounter` -- `advanceEpoch()` — signerGovernance-only -- `setSignerGovernance(address newGov)` — signerGovernance-only (handoff or rotation) -- `currentEpoch() → uint256` — auto-generated getter -- `signerGovernance() → address` — auto-generated getter - -### `CredentialAudit` -- `append(bytes32 operatorOmni, bytes32 actorOmni, bytes32 serviceHash, uint8 opType, bytes32 payloadHash)` — open append (any caller; gas is the spam-resistance) -- `getEntries(bytes32 operatorOmni, uint256 offset, uint256 limit) → AuditEntry[]` — paginated view -- `entryCount(bytes32 operatorOmni) → uint256` — view - -## When this doc needs to change - -1. **New deploy on any chain** — update the table for that chain (addresses + bytecode sizes + date + deployer + tx hash if known) -2. **Constructor re-wiring** — any change to the deploy script's constructor args; re-record the "Constructor wiring" section -3. **K3 epoch advance** — currentEpoch monotonically increases; update the "Constructor wiring" line for the latest value -4. **`signerGovernance` transfer** — when handoff from deployer → operational signer (or → multisig in stage 2) happens, record the new address + tx hash -5. **Re-deploy** at fresh addresses — replace the table row entirely; old addresses move to a "Historical deploys" appendix at the bottom of this doc for audit-trail +This file is kept only as a redirect so older links resolve. Do not add addresses here — edit [`docs/contracts.md`](../contracts.md). From 1673218fa458bb066786387e5c09827989efca4c Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:32:33 +0800 Subject: [PATCH 09/19] =?UTF-8?q?agentkeys:=20ERC-4337=20E5=20=E2=80=94=20?= =?UTF-8?q?guardian=20M-of-N=20social=20recovery=20(generation=20rotation,?= =?UTF-8?q?=20independent=20of=20primary=20passkey);=20+6=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/agentkeys-chain/src/P256Account.sol | 173 +++++++++++++++++- crates/agentkeys-chain/test/P256Account.t.sol | 88 ++++++++- 2 files changed, 251 insertions(+), 10 deletions(-) diff --git a/crates/agentkeys-chain/src/P256Account.sol b/crates/agentkeys-chain/src/P256Account.sol index 8f6e5875..f5468fa1 100644 --- a/crates/agentkeys-chain/src/P256Account.sol +++ b/crates/agentkeys-chain/src/P256Account.sol @@ -27,19 +27,39 @@ interface IK11Verifier { /// commits the entire UserOp (callData + nonce + chainId + entryPoint), /// the passkey signature is a provably-complete full-intent authorization /// — no hand-rolled per-op challenge, and no secp256k1 key on any device. -/// @dev Replay is the EntryPoint 2D nonce (no WebAuthn signCount here — see -/// the plan's Solution A rationale). Multi-passkey signer set; recovery -/// quorum is a later phase (#164 E5). The verifier (P-256) is reused from -/// the deployed K11Verifier, so no new crypto. +/// @dev Replay is the EntryPoint 2D nonce (no WebAuthn signCount). Multi-passkey +/// signer set + M-of-N guardian social recovery (#164 E5). The verifier +/// (P-256) is reused from the deployed K11Verifier, so no new crypto. contract P256Account is IAccount { uint256 internal constant SIG_OK = 0; uint256 internal constant SIG_FAIL = 1; + /// @notice Recovery challenge domain tag (guardian path is not an EntryPoint UserOp). + bytes32 public constant OP_RECOVER = keccak256("agentkeys:v1:p256account-recover"); + struct Signer { uint256 pubX; uint256 pubY; bytes32 rpIdHash; bool active; + uint64 generation; // a signer is live iff active && generation == signerGeneration + } + + struct Guardian { + uint256 pubX; + uint256 pubY; + bytes32 rpIdHash; + bool active; + } + + /// @dev WebAuthn assertion from a recovery guardian (recover() verifies M of N). + struct GuardianAssertion { + bytes32 guardianCredIdHash; + bytes authenticatorData; + bytes clientDataJSON; + uint256 challengeLocation; + uint256 r; + uint256 s; } address public immutable entryPoint; @@ -47,11 +67,26 @@ contract P256Account is IAccount { /// @notice credIdHash => authorized passkey. mapping(bytes32 => Signer) public signers; - /// @notice Count of active signers; the account refuses to drop to zero. + /// @notice Count of live (current-generation, active) signers; never drops to zero. uint256 public activeSignerCount; + /// @notice Monotonic signer generation. recover() bumps it, instantly + /// invalidating every signer from a prior generation (no iteration). + uint64 public signerGeneration; + + /// @notice guardianCredIdHash => recovery guardian passkey. + mapping(bytes32 => Guardian) public guardians; + uint256 public activeGuardianCount; + /// @notice M-of-N recovery threshold. 0 = recovery disabled (safe default). + uint8 public recoveryThreshold; + /// @notice Anti-replay for guardian recovery (recover() bypasses the EntryPoint nonce). + uint256 public recoveryNonce; event SignerAdded(bytes32 indexed credIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash); event SignerRemoved(bytes32 indexed credIdHash); + event GuardianAdded(bytes32 indexed guardianCredIdHash); + event GuardianRemoved(bytes32 indexed guardianCredIdHash); + event RecoveryThresholdSet(uint8 threshold); + event Recovered(bytes32 indexed newCredIdHash, uint64 generation); event Executed(address indexed dest, uint256 value, bytes data); error NotEntryPoint(); @@ -61,6 +96,12 @@ contract P256Account is IAccount { error UnknownSigner(bytes32 credIdHash); error LastSigner(); error LengthMismatch(); + error GuardianExists(bytes32 guardianCredIdHash); + error UnknownGuardian(bytes32 guardianCredIdHash); + error RecoveryDisabled(); + error InsufficientGuardians(uint8 got, uint8 required); + error DuplicateGuardian(bytes32 guardianCredIdHash); + error ThresholdTooHigh(uint8 threshold, uint256 guardianCount); constructor( address _entryPoint, @@ -119,7 +160,7 @@ contract P256Account is IAccount { ) = abi.decode(signature, (bytes32, bytes, bytes, uint256, uint256, uint256)); Signer storage signer = signers[credIdHash]; - if (!signer.active) return false; + if (!_signerActive(signer)) return false; return IK11Verifier(k11Verifier).verifyAssertion( userOpHash, @@ -177,7 +218,7 @@ contract P256Account is IAccount { function removeSigner(bytes32 credIdHash) external { _requireEntryPointOrSelf(); - if (!signers[credIdHash].active) revert UnknownSigner(credIdHash); + if (!_signerActive(signers[credIdHash])) revert UnknownSigner(credIdHash); if (activeSignerCount <= 1) revert LastSigner(); signers[credIdHash].active = false; activeSignerCount -= 1; @@ -185,12 +226,126 @@ contract P256Account is IAccount { } function _addSigner(bytes32 credIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash) internal { - if (signers[credIdHash].active) revert SignerExists(credIdHash); - signers[credIdHash] = Signer({pubX: pubX, pubY: pubY, rpIdHash: rpIdHash, active: true}); + if (_signerActive(signers[credIdHash])) revert SignerExists(credIdHash); + signers[credIdHash] = Signer({ + pubX: pubX, + pubY: pubY, + rpIdHash: rpIdHash, + active: true, + generation: signerGeneration + }); activeSignerCount += 1; emit SignerAdded(credIdHash, pubX, pubY, rpIdHash); } + function _signerActive(Signer storage s) internal view returns (bool) { + return s.active && s.generation == signerGeneration; + } + + // ─── Guardian management + social recovery (#164 E5) ────────────────── + function addGuardian(bytes32 guardianCredIdHash, uint256 pubX, uint256 pubY, bytes32 rpIdHash) + external + { + _requireEntryPointOrSelf(); + if (guardians[guardianCredIdHash].active) revert GuardianExists(guardianCredIdHash); + guardians[guardianCredIdHash] = + Guardian({pubX: pubX, pubY: pubY, rpIdHash: rpIdHash, active: true}); + activeGuardianCount += 1; + emit GuardianAdded(guardianCredIdHash); + } + + function removeGuardian(bytes32 guardianCredIdHash) external { + _requireEntryPointOrSelf(); + if (!guardians[guardianCredIdHash].active) revert UnknownGuardian(guardianCredIdHash); + guardians[guardianCredIdHash].active = false; + activeGuardianCount -= 1; + if (recoveryThreshold > activeGuardianCount) { + recoveryThreshold = uint8(activeGuardianCount); // keep the quorum satisfiable + emit RecoveryThresholdSet(recoveryThreshold); + } + emit GuardianRemoved(guardianCredIdHash); + } + + function setRecoveryThreshold(uint8 threshold) external { + _requireEntryPointOrSelf(); + if (threshold > activeGuardianCount) revert ThresholdTooHigh(threshold, activeGuardianCount); + recoveryThreshold = threshold; + emit RecoveryThresholdSet(threshold); + } + + /// @notice Social recovery: M-of-N guardians rotate control to a fresh passkey + /// WITHOUT the (lost) primary signer or the EntryPoint — the independent + /// guardian path (threat-model §7). Permissionless to submit (a relayer + /// can land it for a locked-out operator); authorized purely by the + /// guardian assertions. Bumps `signerGeneration`, invalidating every + /// prior signer, and installs `new*` as the sole active signer. + /// @dev Guardian assertions are P-256 verified in-contract over a challenge + /// binding the new signer + recoveryNonce + chainId + this account — the + /// one retained defense-in-depth (a guardian recovering a stolen device + /// cannot route through the compromised primary's validateUserOp). + function recover( + bytes32 newCredIdHash, + uint256 newPubX, + uint256 newPubY, + bytes32 newRpIdHash, + GuardianAssertion[] calldata assertions + ) external { + uint8 threshold = recoveryThreshold; + if (threshold == 0) revert RecoveryDisabled(); + if (assertions.length < threshold) { + revert InsufficientGuardians(uint8(assertions.length), threshold); + } + + bytes32 challenge = keccak256( + abi.encode( + OP_RECOVER, + newCredIdHash, + newPubX, + newPubY, + newRpIdHash, + recoveryNonce, + block.chainid, + address(this) + ) + ); + + uint256 nValid; + for (uint256 i = 0; i < assertions.length; ++i) { + bytes32 gid = assertions[i].guardianCredIdHash; + for (uint256 j = 0; j < i; ++j) { + if (assertions[j].guardianCredIdHash == gid) revert DuplicateGuardian(gid); + } + Guardian storage g = guardians[gid]; + if (!g.active) revert UnknownGuardian(gid); + // A malformed/mismatched assertion reverts in the verifier; try/catch + // so one bad guardian envelope doesn't grief the whole recovery. + try IK11Verifier(k11Verifier).verifyAssertion( + challenge, + g.rpIdHash, + assertions[i].authenticatorData, + assertions[i].clientDataJSON, + assertions[i].challengeLocation, + assertions[i].r, + assertions[i].s, + g.pubX, + g.pubY + ) returns (bool ok) { + if (ok) { + unchecked { + ++nValid; + } + } + } catch {} + } + if (nValid < threshold) revert InsufficientGuardians(uint8(nValid), threshold); + + recoveryNonce += 1; + signerGeneration += 1; // invalidates all prior-generation signers + activeSignerCount = 0; // _addSigner brings it back to 1 + _addSigner(newCredIdHash, newPubX, newPubY, newRpIdHash); + emit Recovered(newCredIdHash, signerGeneration); + } + function _requireEntryPointOrSelf() internal view { if (msg.sender != entryPoint && msg.sender != address(this)) revert NotEntryPointOrSelf(); } diff --git a/crates/agentkeys-chain/test/P256Account.t.sol b/crates/agentkeys-chain/test/P256Account.t.sol index 56455ee1..4e8ff596 100644 --- a/crates/agentkeys-chain/test/P256Account.t.sol +++ b/crates/agentkeys-chain/test/P256Account.t.sol @@ -91,11 +91,12 @@ contract P256AccountTest is Test { function test_InitialSigner() public { P256Account acct = _deploy(); assertEq(acct.activeSignerCount(), 1); - (uint256 x, uint256 y, bytes32 rp, bool active) = acct.signers(CRED); + (uint256 x, uint256 y, bytes32 rp, bool active, uint64 gen) = acct.signers(CRED); assertEq(x, PUBX); assertEq(y, PUBY); assertEq(rp, RPID); assertTrue(active); + assertEq(gen, 0); } function test_ValidateUserOp_Success() public { @@ -215,4 +216,89 @@ contract P256AccountTest is Test { acct.validateUserOp(_op(CRED), bytes32(uint256(1)), 0.1 ether); assertEq(ENTRYPOINT.balance, epBefore + 0.1 ether, "prefund forwarded to EntryPoint"); } + + // ─── E5: guardian social recovery ──────────────────────────────────── + bytes32 constant GCRED = keccak256("guardian-1"); + bytes32 constant GCRED2 = keccak256("guardian-2"); + bytes32 constant NEWCRED = keccak256("recovered-signer"); + + function _gAssertion(bytes32 gid) internal pure returns (P256Account.GuardianAssertion memory a) { + a.guardianCredIdHash = gid; + a.authenticatorData = hex"aa"; + a.clientDataJSON = hex"bb"; + a.challengeLocation = 0; + a.r = 1; + a.s = 2; + } + + function test_Guardian_Gating() public { + P256Account acct = _deploy(); + vm.expectRevert(P256Account.NotEntryPointOrSelf.selector); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + vm.prank(ENTRYPOINT); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + assertEq(acct.activeGuardianCount(), 1); + } + + function test_SetRecoveryThreshold_RejectsTooHigh() public { + P256Account acct = _deploy(); + vm.prank(ENTRYPOINT); + vm.expectRevert(abi.encodeWithSelector(P256Account.ThresholdTooHigh.selector, 1, 0)); + acct.setRecoveryThreshold(1); + } + + function test_Recover_RejectsWhenDisabled() public { + P256Account acct = _deploy(); + P256Account.GuardianAssertion[] memory a = new P256Account.GuardianAssertion[](0); + vm.expectRevert(P256Account.RecoveryDisabled.selector); + acct.recover(NEWCRED, PUBX, PUBY, RPID, a); + } + + function test_Recover_RotatesAndInvalidatesOld() public { + P256Account acct = _deploy(); + vm.startPrank(ENTRYPOINT); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + acct.setRecoveryThreshold(1); + vm.stopPrank(); + + k11.setResult(true); // guardian assertion verifies + P256Account.GuardianAssertion[] memory a = new P256Account.GuardianAssertion[](1); + a[0] = _gAssertion(GCRED); + acct.recover(NEWCRED, PUBX, PUBY, RPID, a); // permissionless submit + + assertEq(acct.signerGeneration(), 1); + assertEq(acct.activeSignerCount(), 1); + // new signer validates; the old one is invalidated by the generation bump + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(NEWCRED), bytes32(uint256(1)), 0), 0, "new signer live"); + vm.prank(ENTRYPOINT); + assertEq(acct.validateUserOp(_op(CRED), bytes32(uint256(1)), 0), 1, "old signer dead"); + } + + function test_Recover_RejectsBelowThreshold() public { + P256Account acct = _deploy(); + vm.startPrank(ENTRYPOINT); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + acct.addGuardian(GCRED2, PUBX, PUBY, RPID); + acct.setRecoveryThreshold(2); + vm.stopPrank(); + P256Account.GuardianAssertion[] memory a = new P256Account.GuardianAssertion[](1); + a[0] = _gAssertion(GCRED); + vm.expectRevert(abi.encodeWithSelector(P256Account.InsufficientGuardians.selector, 1, 2)); + acct.recover(NEWCRED, PUBX, PUBY, RPID, a); + } + + function test_Recover_RejectsDuplicateGuardian() public { + P256Account acct = _deploy(); + vm.startPrank(ENTRYPOINT); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + acct.setRecoveryThreshold(1); + vm.stopPrank(); + k11.setResult(true); + P256Account.GuardianAssertion[] memory a = new P256Account.GuardianAssertion[](2); + a[0] = _gAssertion(GCRED); + a[1] = _gAssertion(GCRED); + vm.expectRevert(abi.encodeWithSelector(P256Account.DuplicateGuardian.selector, GCRED)); + acct.recover(NEWCRED, PUBX, PUBY, RPID, a); + } } From 2c0fef39a49e7a89cc9615be5c17af2d8015df54 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:37:10 +0800 Subject: [PATCH 10/19] =?UTF-8?q?agentkeys:=20ERC-4337=20E6=20=E2=80=94=20?= =?UTF-8?q?VerifyingPaymaster=20(broker-co-signed=20sponsorship=20=3D=20Sy?= =?UTF-8?q?bil=20gate)=20+6=20tests;=20unsafe-mode=20bundler=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/VerifyingPaymaster.sol | 191 ++++++++++++++++++ .../test/VerifyingPaymaster.t.sol | 93 +++++++++ scripts/erc4337-bundler.sh | 53 +++++ 3 files changed, 337 insertions(+) create mode 100644 crates/agentkeys-chain/src/VerifyingPaymaster.sol create mode 100644 crates/agentkeys-chain/test/VerifyingPaymaster.t.sol create mode 100755 scripts/erc4337-bundler.sh diff --git a/crates/agentkeys-chain/src/VerifyingPaymaster.sol b/crates/agentkeys-chain/src/VerifyingPaymaster.sol new file mode 100644 index 00000000..056cdef5 --- /dev/null +++ b/crates/agentkeys-chain/src/VerifyingPaymaster.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {PackedUserOperation} from "./IERC4337.sol"; + +enum PostOpMode { + opSucceeded, + opReverted, + postOpReverted +} + +/// @dev ERC-4337 v0.7 paymaster surface (vendored). +interface IPaymaster { + function validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) external returns (bytes memory context, uint256 validationData); + + function postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualGasUsedPenalty + ) external; +} + +/// @dev Minimal EntryPoint deposit surface (StakeManager). +interface IEntryPointStake { + function depositTo(address account) external payable; + function withdrawTo(address payable withdrawAddress, uint256 amount) external; + function balanceOf(address account) external view returns (uint256); +} + +/// @title VerifyingPaymaster — sponsors only broker-co-signed UserOps (#164 E6). +/// @notice The Sybil gate for gasless master ops (threat-model §5): a UserOp is +/// sponsored ONLY if `brokerSigner` (an off-chain key the broker controls) +/// signed an approval over the op + validity window. The broker signs only +/// for an authenticated operator (valid J1 session), so a Sybil with no +/// operator session gets no sponsorship and must self-fund — no drain. +/// @dev Standard EIP-191 verifying-paymaster pattern. The paymaster's EntryPoint +/// deposit must stay ≥ the Heima ExistentialDeposit (~0.1 HEI); fund via +/// `deposit()`. Per-operator budgets/rate-limits are enforced off-chain by +/// the broker before it co-signs (it holds the policy + the key). +contract VerifyingPaymaster is IPaymaster { + address public immutable entryPoint; + address public owner; + address public brokerSigner; + + /// @dev paymasterAndData tail layout (after the 52-byte EntryPoint prefix: + /// 20 paymaster + 16 verificationGasLimit + 16 postOpGasLimit): + /// validUntil(6) | validAfter(6) | signature(65). + uint256 private constant VALID_TIMESTAMP_OFFSET = 52; + uint256 private constant SIGNATURE_OFFSET = 64; + + event BrokerSignerChanged(address indexed previous, address indexed current); + event OwnerChanged(address indexed previous, address indexed current); + + error NotEntryPoint(); + error NotOwner(); + error BadPaymasterDataLength(); + error ZeroAddress(); + + constructor(address _entryPoint, address _brokerSigner, address _owner) { + if (_entryPoint == address(0) || _brokerSigner == address(0) || _owner == address(0)) { + revert ZeroAddress(); + } + entryPoint = _entryPoint; + brokerSigner = _brokerSigner; + owner = _owner; + } + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + // ─── ERC-4337 paymaster ────────────────────────────────────────────── + function validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32, /* userOpHash */ + uint256 /* maxCost */ + ) external returns (bytes memory context, uint256 validationData) { + if (msg.sender != entryPoint) revert NotEntryPoint(); + (uint48 validUntil, uint48 validAfter, bytes calldata signature) = + _parsePaymasterAndData(userOp.paymasterAndData); + + bytes32 signed = _ethSignedHash(getHash(userOp, validUntil, validAfter)); + bool sigOk = _recover(signed, signature) == brokerSigner; + + // ERC-4337 packed validationData: bit0 = sigFailed, [160:208) validUntil, + // [208:256) validAfter. On bad sig we still return the time range so the + // EntryPoint rejects with AA34 rather than a hard revert. + validationData = _packValidationData(!sigOk, validUntil, validAfter); + context = ""; + } + + function postOp(PostOpMode, bytes calldata, uint256, uint256) external { + if (msg.sender != entryPoint) revert NotEntryPoint(); + // No per-op accounting on chain — budgets live in the broker. No-op. + } + + /// @notice The digest the broker signs (EIP-191) to approve sponsorship. + /// Excludes the paymaster signature (avoids circularity) but binds + /// every other op field + chainId + this paymaster + brokerSigner + + /// the validity window. + function getHash(PackedUserOperation calldata userOp, uint48 validUntil, uint48 validAfter) + public + view + returns (bytes32) + { + return keccak256( + abi.encode( + userOp.sender, + userOp.nonce, + keccak256(userOp.initCode), + keccak256(userOp.callData), + userOp.accountGasLimits, + userOp.preVerificationGas, + userOp.gasFees, + block.chainid, + address(this), + brokerSigner, + validUntil, + validAfter + ) + ); + } + + // ─── Owner / funding ───────────────────────────────────────────────── + function setBrokerSigner(address newSigner) external onlyOwner { + if (newSigner == address(0)) revert ZeroAddress(); + emit BrokerSignerChanged(brokerSigner, newSigner); + brokerSigner = newSigner; + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnerChanged(owner, newOwner); + owner = newOwner; + } + + /// @notice Fund this paymaster's EntryPoint deposit (keep it ≥ ED). + function deposit() external payable { + IEntryPointStake(entryPoint).depositTo{value: msg.value}(address(this)); + } + + function withdrawTo(address payable to, uint256 amount) external onlyOwner { + IEntryPointStake(entryPoint).withdrawTo(to, amount); + } + + function getDeposit() external view returns (uint256) { + return IEntryPointStake(entryPoint).balanceOf(address(this)); + } + + // ─── Internals ─────────────────────────────────────────────────────── + function _parsePaymasterAndData(bytes calldata paymasterAndData) + internal + pure + returns (uint48 validUntil, uint48 validAfter, bytes calldata signature) + { + if (paymasterAndData.length < SIGNATURE_OFFSET + 65) revert BadPaymasterDataLength(); + validUntil = uint48(bytes6(paymasterAndData[VALID_TIMESTAMP_OFFSET:VALID_TIMESTAMP_OFFSET + 6])); + validAfter = + uint48(bytes6(paymasterAndData[VALID_TIMESTAMP_OFFSET + 6:VALID_TIMESTAMP_OFFSET + 12])); + signature = paymasterAndData[SIGNATURE_OFFSET:]; + } + + function _ethSignedHash(bytes32 hash) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); + } + + function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) { + if (sig.length != 65) return address(0); + bytes32 r = bytes32(sig[0:32]); + bytes32 s = bytes32(sig[32:64]); + uint8 v = uint8(sig[64]); + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return address(0); // reject high-s (malleability) + } + return ecrecover(hash, v, r, s); + } + + function _packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) + internal + pure + returns (uint256) + { + return (sigFailed ? 1 : 0) | (uint256(validUntil) << 160) | (uint256(validAfter) << 208); + } +} diff --git a/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol b/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol new file mode 100644 index 00000000..b5af8a87 --- /dev/null +++ b/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {VerifyingPaymaster} from "../src/VerifyingPaymaster.sol"; +import {PackedUserOperation} from "../src/IERC4337.sol"; + +contract VerifyingPaymasterTest is Test { + address constant ENTRYPOINT = address(0xE427); + VerifyingPaymaster pm; + + uint256 brokerPk = 0xB0B; + address broker; + address owner = address(0xABCD); + + uint48 constant VALID_UNTIL = 4_000_000_000; + uint48 constant VALID_AFTER = 0; + + function setUp() public { + broker = vm.addr(brokerPk); + pm = new VerifyingPaymaster(ENTRYPOINT, broker, owner); + } + + function _op() internal pure returns (PackedUserOperation memory op) { + op.sender = address(0xACC7); + op.nonce = 1; + op.callData = hex"deadbeef"; + op.accountGasLimits = bytes32(uint256(1)); + op.preVerificationGas = 50_000; + op.gasFees = bytes32(uint256(2)); + } + + function _sign(uint256 pk, PackedUserOperation memory op) internal view returns (bytes memory pad) { + bytes32 h = pm.getHash(op, VALID_UNTIL, VALID_AFTER); + bytes32 ethH = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", h)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethH); + bytes memory sig = abi.encodePacked(r, s, v); + // prefix: 20 paymaster + 16 vGasLimit + 16 postOpGasLimit, then vu|va|sig + pad = abi.encodePacked( + address(pm), uint128(0), uint128(0), VALID_UNTIL, VALID_AFTER, sig + ); + } + + function test_ValidSponsorship() public { + PackedUserOperation memory op = _op(); + op.paymasterAndData = _sign(brokerPk, op); + vm.prank(ENTRYPOINT); + (, uint256 validationData) = pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + assertEq(validationData & 1, 0, "broker-signed -> sponsored (sigFailed bit clear)"); + assertEq((validationData >> 160) & ((1 << 48) - 1), VALID_UNTIL, "validUntil packed"); + } + + function test_RejectsWrongSigner() public { + PackedUserOperation memory op = _op(); + op.paymasterAndData = _sign(0xBADBAD, op); // not the broker key + vm.prank(ENTRYPOINT); + (, uint256 validationData) = pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + assertEq(validationData & 1, 1, "wrong signer -> sigFailed bit set"); + } + + function test_RejectsTamperedOp() public { + PackedUserOperation memory op = _op(); + op.paymasterAndData = _sign(brokerPk, op); + op.callData = hex"c0ffee"; // tamper after signing → hash mismatch + vm.prank(ENTRYPOINT); + (, uint256 validationData) = pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + assertEq(validationData & 1, 1, "tampered op -> sigFailed"); + } + + function test_OnlyEntryPoint() public { + PackedUserOperation memory op = _op(); + op.paymasterAndData = _sign(brokerPk, op); + vm.expectRevert(VerifyingPaymaster.NotEntryPoint.selector); + pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + } + + function test_SetBrokerSigner_OnlyOwner() public { + vm.expectRevert(VerifyingPaymaster.NotOwner.selector); + pm.setBrokerSigner(address(0x1234)); + + vm.prank(owner); + pm.setBrokerSigner(address(0x1234)); + assertEq(pm.brokerSigner(), address(0x1234)); + } + + function test_RejectsShortPaymasterData() public { + PackedUserOperation memory op = _op(); + op.paymasterAndData = abi.encodePacked(address(pm), uint128(0), uint128(0)); // no vu/va/sig + vm.prank(ENTRYPOINT); + vm.expectRevert(VerifyingPaymaster.BadPaymasterDataLength.selector); + pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + } +} diff --git a/scripts/erc4337-bundler.sh b/scripts/erc4337-bundler.sh new file mode 100755 index 00000000..b460a5da --- /dev/null +++ b/scripts/erc4337-bundler.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# scripts/erc4337-bundler.sh — self-hosted ERC-4337 v0.7 bundler for Heima (#164 E6). +# +# WHY UNSAFE MODE: Heima's Frontier RPC exposes no `debug` namespace +# (debug_traceCall / debug_traceTransaction → -32601 method-not-found, verified +# 2026-06-02). Standard bundlers need debug_traceCall for ERC-7562 validation, so +# we run a PRIVATE bundler in --unsafe mode, fed only by authenticated clients via +# the broker — NOT a public alt-mempool. Acceptable because the broker is already +# the gatekeeper and we control the single bundler. See +# docs/plan/chain/erc4337-master-account.md §2 + threat-model §4. +# +# This script is the operator-facing runner; it does not stand up infra by itself. +# Idempotent: re-running just (re)launches the bundler against the recorded EntryPoint. +set -euo pipefail + +RPC="${HEIMA_RPC:-https://rpc.heima-parachain.heima.network}" +ENTRYPOINT="${ENTRYPOINT_ADDRESS_HEIMA:-0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9}" +CHAIN_ID="${HEIMA_CHAIN_ID:-212013}" +# The bundler's own EOA pays handleOps gas (reimbursed from the account deposit / +# paymaster). It must hold HEI ≥ ExistentialDeposit (~0.1) + a gas float. +BUNDLER_KEY_FILE="${BUNDLER_KEY_FILE:-$HOME/.agentkeys/heima-bundler.key}" + +echo "ERC-4337 bundler (Heima, UNSAFE mode)" +echo " rpc = $RPC" +echo " entryPoint = $ENTRYPOINT" +echo " chainId = $CHAIN_ID" + +# Sanity: EntryPoint must be deployed. +code=$(cast code "$ENTRYPOINT" --rpc-url "$RPC" 2>/dev/null || echo 0x) +[ "${#code}" -gt 2 ] || { echo "fail: EntryPoint not deployed at $ENTRYPOINT"; exit 1; } +echo "ok proceeding: EntryPoint bytecode present" + +# Option A — eth-infinitism reference bundler (TypeScript), simplest for the demo: +# git clone https://github.com/eth-infinitism/bundler && cd bundler && yarn && yarn preprocess +# yarn bundler --network "$RPC" --entryPoint "$ENTRYPOINT" --unsafe \ +# --beneficiary "$(cast wallet address --private-key "$(cat "$BUNDLER_KEY_FILE")")" \ +# --privateKey "$(cat "$BUNDLER_KEY_FILE")" --port 3000 +# +# Option B — rundler (Rust, Alchemy), prod-grade, also supports unsafe: +# rundler node --network dev --node_http "$RPC" \ +# --entry_points "$ENTRYPOINT" --unsafe \ +# --builder.private_key "$(cat "$BUNDLER_KEY_FILE")" --rpc.port 3000 +# +# Both expose the standard JSON-RPC: eth_sendUserOperation, eth_estimateUserOperationGas, +# eth_getUserOperationByHash, eth_getUserOperationReceipt, eth_supportedEntryPoints. +# The broker submits broker-co-signed UserOps here; the paymaster (VerifyingPaymaster) +# sponsors them. NO BUNDLER IS REQUIRED FOR CORRECTNESS — anyone (incl. our broker) +# can call EntryPoint.handleOps directly (the #164 spike + harness E8 do exactly that); +# the bundler is the always-on automation layer. +echo +echo "Pick Option A (reference bundler) or B (rundler) above and run it; the broker" +echo "then points NEXT_PUBLIC_BUNDLER_URL / the cap-mint relay at http://localhost:3000." +echo "skip: live bundler standup is an operator step (this script documents + sanity-checks)." From 2099bc86c72a958a7b3cfa14122bed3bae45eb42 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:44:35 +0800 Subject: [PATCH 11/19] =?UTF-8?q?agentkeys:=20ERC-4337=20E7+E8=20=E2=80=94?= =?UTF-8?q?=20WebAuthn=20UserOp=20signer=20+=20passkey-only=20master=20dem?= =?UTF-8?q?o=20landed=20on=20phase1-wire-demo.sh=20(phase6);=20ran=20green?= =?UTF-8?q?=20on=20Heima=20mainnet=20(acct=200x3e79925F,=20addSigner=20via?= =?UTF-8?q?=20UserOp,=20no=20secp256k1=20key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harness/erc4337-master-e8.sh | 100 +++++++++++++++++++++++ harness/phase1-wire-demo.sh | 20 +++++ harness/scripts/erc4337-webauthn-sign.py | 82 +++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100755 harness/erc4337-master-e8.sh create mode 100755 harness/scripts/erc4337-webauthn-sign.py diff --git a/harness/erc4337-master-e8.sh b/harness/erc4337-master-e8.sh new file mode 100755 index 00000000..f95010b0 --- /dev/null +++ b/harness/erc4337-master-e8.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# harness/erc4337-master-e8.sh — #164 E8 acceptance: a passkey-only master lands a +# real master mutation via an ERC-4337 UserOp on Heima mainnet, with NO secp256k1 +# key on the "device". Proves E1 (factory) + E2 (P256Account, WebAuthn via the live +# K11Verifier) + the userOpHash full-intent binding, end-to-end on mainnet. +# +# Flow: keygen passkey → factory.createAccount → depositTo(account) → build a UserOp +# whose callData is account.addSigner(...) (a master mutation) → getUserOpHash → +# WebAuthn-sign the userOpHash → K11 pre-check (live, free) → EntryPoint.handleOps → +# assert activeSignerCount == 2. +# +# Append-only demo: each run mints a FRESH account (unique salt), so re-runs don't +# collide; it is NOT idempotent in the resource sense (one new account + ~0.22 HEI +# of gas/deposit per run). Direct handleOps (no bundler needed for the proof, per +# the #164 plan). Sourced as phase6 by phase1-wire-demo.sh, or run standalone. +set -uo pipefail + +RPC="${HEIMA_RPC:-https://rpc.heima-parachain.heima.network}" +FACTORY="${P256_ACCOUNT_FACTORY_ADDRESS_HEIMA:-0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3}" +EP="${ENTRYPOINT_ADDRESS_HEIMA:-0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9}" +K11="${K11_VERIFIER_ADDRESS_HEIMA:-0x5a441431f08e0f5f5ed10659620cb4e0e814e627}" +DEPLOYER_KEY_FILE="${HEIMA_DEPLOYER_KEY_FILE:-$HOME/.agentkeys/heima-deployer.key}" +RPID="${AGENTKEYS_RP_ID:-litentry.org}" +VENV="${ERC4337_VENV:-$HOME/.agentkeys/erc4337-venv}" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SIGNER="$HERE/scripts/erc4337-webauthn-sign.py" + +ok() { printf ' ok %s\n' "$1"; } +skip() { printf ' skip %s\n' "$1"; } +fail() { printf ' fail %s\n' "$1"; return 1; } + +erc4337_master_e8() { + echo "== #164 E8: passkey-only master via ERC-4337 UserOp (Heima mainnet) ==" + command -v cast >/dev/null || { skip "cast not on PATH"; return 0; } + [ -f "$DEPLOYER_KEY_FILE" ] || { skip "no deployer key ($DEPLOYER_KEY_FILE)"; return 0; } + if [ ! -x "$VENV/bin/python" ]; then + python3 -m venv "$VENV" >/dev/null 2>&1 && "$VENV/bin/pip" install -q cryptography \ + || { skip "could not provision python+cryptography venv"; return 0; } + fi + local PY="$VENV/bin/python" + local PK; PK="$(tr -d '[:space:]' < "$DEPLOYER_KEY_FILE")" + local DEPLOYER; DEPLOYER="$(cast wallet address --private-key "$PK")" + + # EntryPoint + factory must be live. + [ "$(cast code "$EP" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "EntryPoint $EP not deployed"; return 1; } + [ "$(cast code "$FACTORY" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "factory $FACTORY not deployed"; return 1; } + ok "EntryPoint + factory live" + + # 1. Keygen the master passkey (no secp256k1 key on the "device"). + local KEY="${TMPDIR:-/tmp}/e8-passkey.key" + eval "$($PY "$SIGNER" keygen "$KEY" "$RPID")" # PUBX PUBY RPIDHASH + local CRED1; CRED1="$(cast keccak "e8-cred-$$-${RANDOM}")" + local SALT; SALT="$(cast keccak "e8-salt-$$-${RANDOM}-$(date +%s 2>/dev/null || echo 0)")" + + # 2. Deploy the account via the factory (CREATE2). + local ACCT; ACCT="$(cast call "$FACTORY" "getAddress(bytes32,uint256,uint256,bytes32,bytes32)(address)" "$CRED1" "$PUBX" "$PUBY" "$RPIDHASH" "$SALT" --rpc-url "$RPC")" + cast send "$FACTORY" "createAccount(bytes32,uint256,uint256,bytes32,bytes32)" "$CRED1" "$PUBX" "$PUBY" "$RPIDHASH" "$SALT" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 1200000 >/dev/null 2>&1 + [ "$(cast code "$ACCT" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "account not deployed at $ACCT"; return 1; } + ok "account deployed (passkey-bound) at $ACCT" + + # 3. Fund the account's EntryPoint deposit (≥ ExistentialDeposit so missingAccountFunds==0). + cast send "$EP" "depositTo(address)" "$ACCT" --value 0.2ether --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 200000 >/dev/null 2>&1 + local DEP; DEP="$(cast call "$EP" "balanceOf(address)(uint256)" "$ACCT" --rpc-url "$RPC" | awk '{print $1}')" + [ "$DEP" != "0" ] || { fail "deposit not credited"; return 1; } + ok "account deposit funded ($DEP wei)" + + # 4. Build the UserOp: callData = a real master mutation (addSigner of a 2nd passkey). + local CRED2; CRED2="$(cast keccak "e8-cred2-$$-${RANDOM}")" + local CALLDATA; CALLDATA="$(cast calldata "addSigner(bytes32,uint256,uint256,bytes32)" "$CRED2" "$PUBX" "$PUBY" "$RPIDHASH")" + local AGL; AGL="$(printf '0x%032x%032x' 1500000 300000)" # verificationGasLimit | callGasLimit + local GASFEES; GASFEES="$(printf '0x%032x%032x' 1000000000 40000000000)" # maxPriority | maxFee + local NONCE; NONCE="$(cast call "$EP" "getNonce(address,uint192)(uint256)" "$ACCT" 0 --rpc-url "$RPC" | awk '{print $1}')" + local UNSIGNED="($ACCT,$NONCE,0x,$CALLDATA,$AGL,100000,$GASFEES,0x,0x)" + local UOH; UOH="$(cast call "$EP" "getUserOpHash((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes))(bytes32)" "$UNSIGNED" --rpc-url "$RPC")" + ok "userOpHash = $UOH" + + # 5. WebAuthn-sign the userOpHash (the passkey signs; full-intent commitment). + eval "$($PY "$SIGNER" sign "$KEY" "$UOH" "$RPID")" # AUTHDATA CDJ CHALLENGE_LOC R S + + # 6. Pre-check the assertion against the LIVE K11Verifier (free) before spending gas. + local PRE; PRE="$(cast call "$K11" "verifyAssertion(bytes32,bytes32,bytes,bytes,uint256,uint256,uint256,uint256,uint256)(bool)" "$UOH" "$RPIDHASH" "$AUTHDATA" "$CDJ" "$CHALLENGE_LOC" "$R" "$S" "$PUBX" "$PUBY" --rpc-url "$RPC" 2>&1 | tail -1)" + [ "$PRE" = "true" ] || { fail "K11 pre-check failed ($PRE)"; return 1; } + ok "K11 assertion verifies on live verifier" + + # 7. Assemble signature = abi.encode(credIdHash, authData, clientDataJSON, loc, r, s) + handleOps. + local SIG; SIG="$(cast abi-encode "x(bytes32,bytes,bytes,uint256,uint256,uint256)" "$CRED1" "$AUTHDATA" "$CDJ" "$CHALLENGE_LOC" "$R" "$S")" + local SIGNED="($ACCT,$NONCE,0x,$CALLDATA,$AGL,100000,$GASFEES,0x,$SIG)" + cast send "$EP" "handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address)" "[$SIGNED]" "$DEPLOYER" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 3000000 >/dev/null 2>&1 + + # 8. Assert the master mutation landed: activeSignerCount 1 -> 2. + local COUNT; COUNT="$(cast call "$ACCT" "activeSignerCount()(uint256)" --rpc-url "$RPC" | awk '{print $1}')" + [ "$COUNT" = "2" ] || { fail "UserOp did not execute (activeSignerCount=$COUNT, want 2)"; return 1; } + ok "UserOp executed: passkey-signed addSigner landed, activeSignerCount=2 — passkey-only master ✓" + echo " account=$ACCT (no secp256k1 key was used to authorize the mutation)" +} + +# Run standalone if invoked directly (non-zero exit on failure, for callers/CI). +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + erc4337_master_e8 || exit 1 +fi diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index 6271e10e..a8ab5ff5 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -1121,6 +1121,25 @@ phase5_teardown() { ok "5.2 account" "kept (no Act 3 → nothing to restore)" } +# ─── Phase 6 — ERC-4337 passkey-only master (#164 E8) ───────────────────────── +# Opt-in (AGENTKEYS_ERC4337_E8=1): proves a master with NO secp256k1 key lands a +# real master mutation via an ERC-4337 UserOp on Heima mainnet (factory → account +# → WebAuthn-signed UserOp via the live K11Verifier → handleOps → addSigner). The +# heavy lifting is in harness/erc4337-master-e8.sh (run as a subprocess so its +# helper names don't clobber this harness's ok/skip/fail). +phase6_erc4337_master() { + if [[ "${AGENTKEYS_ERC4337_E8:-0}" != "1" ]]; then + log "Phase 6 — ERC-4337 passkey-master (E8): skip (set AGENTKEYS_ERC4337_E8=1 to run on Heima mainnet, ~0.22 HEI)" + return + fi + log "Phase 6 — ERC-4337 passkey-only master via UserOp (#164 E8, Heima mainnet)" + if bash "$(dirname "${BASH_SOURCE[0]}")/erc4337-master-e8.sh"; then + ok "6.1 erc4337-e8" "passkey-only master landed a UserOp mutation (no secp256k1 key)" + else + fail "6.1 erc4337-e8" "see erc4337-master-e8.sh output above" + fi +} + # ─── main ──────────────────────────────────────────────────────────────────── main() { for t in curl jq docker; do command -v "$t" >/dev/null 2>&1 || { echo "missing tool: $t" >&2; exit 2; }; done @@ -1135,6 +1154,7 @@ main() { phase3_acts phase4_surprise phase5_teardown + phase6_erc4337_master log "summary" if [[ "$FAILED" -eq 0 ]]; then diff --git a/harness/scripts/erc4337-webauthn-sign.py b/harness/scripts/erc4337-webauthn-sign.py new file mode 100755 index 00000000..4d354734 --- /dev/null +++ b/harness/scripts/erc4337-webauthn-sign.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""WebAuthn (K11) UserOp signer for the ERC-4337 P256Account (#164 E7/E8). + +The production P256Account verifies a UserOp via the on-chain K11Verifier, which +expects a real WebAuthn assertion whose challenge == base64url(userOpHash). This +helper produces that assertion (authData + clientDataJSON + P-256 r,s) exactly the +way K11Verifier.verifyAssertion parses it: + - authData = sha256(rpId) || flags(0x05 = UP|UV) || signCount(4) (37 bytes) + - clientDataJSON = {"type":"webauthn.get","challenge":"<43-char b64url>","origin":...} + → challenge value starts at offset 36 (challengeLocation) + - msgHash = sha256(authData || sha256(clientDataJSON)); P-256 sign (low-s) + +Modes: + keygen -> PUBX=, PUBY=, RPIDHASH= + sign -> AUTHDATA=, CDJ=, CHALLENGE_LOC=, R=, S= +""" +import base64 +import hashlib +import sys + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature + +CURVE_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 + + +def _rp_id_hash(rp_id: str) -> bytes: + return hashlib.sha256(rp_id.encode()).digest() + + +def keygen(keyfile: str, rp_id: str) -> None: + priv = ec.generate_private_key(ec.SECP256R1()) + with open(keyfile, "wb") as f: + f.write(priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + n = priv.public_key().public_numbers() + print(f"PUBX=0x{n.x:064x}") + print(f"PUBY=0x{n.y:064x}") + print(f"RPIDHASH=0x{_rp_id_hash(rp_id).hex()}") + + +def sign(keyfile: str, userophash_hex: str, rp_id: str) -> None: + with open(keyfile, "rb") as f: + priv = serialization.load_pem_private_key(f.read(), password=None) + + uoh = bytes.fromhex(userophash_hex[2:] if userophash_hex.startswith("0x") else userophash_hex) + assert len(uoh) == 32, "userOpHash must be 32 bytes" + + challenge_b64 = base64.urlsafe_b64encode(uoh).rstrip(b"=").decode() # 43 chars + client_data = ( + '{"type":"webauthn.get","challenge":"' + challenge_b64 + + '","origin":"https://' + rp_id + '"}' + ).encode() + # K11Verifier expects the challenge value at offset 36 (after `{"type":"webauthn.get","challenge":"`). + assert client_data[36:36 + 43] == challenge_b64.encode(), "challengeLocation drift" + + auth_data = _rp_id_hash(rp_id) + bytes([0x05]) + (0).to_bytes(4, "big") # UP|UV, signCount 0 + + msg_hash = hashlib.sha256(auth_data + hashlib.sha256(client_data).digest()).digest() + der = priv.sign(msg_hash, ec.ECDSA(Prehashed(hashes.SHA256()))) + r, s = decode_dss_signature(der) + if s > CURVE_N // 2: + s = CURVE_N - s + + print(f"AUTHDATA=0x{auth_data.hex()}") + print(f"CDJ=0x{client_data.hex()}") + print("CHALLENGE_LOC=36") + print(f"R=0x{r:064x}") + print(f"S=0x{s:064x}") + + +if __name__ == "__main__": + if len(sys.argv) >= 4 and sys.argv[1] == "keygen": + keygen(sys.argv[2], sys.argv[3]) + elif len(sys.argv) >= 5 and sys.argv[1] == "sign": + sign(sys.argv[2], sys.argv[3], sys.argv[4]) + else: + sys.exit("usage: erc4337-webauthn-sign.py keygen | sign ") From 5aeedacdd6527597b9ea238953b0e9b04d247af6 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:46:07 +0800 Subject: [PATCH 12/19] =?UTF-8?q?agentkeys:=20#164=20plan=20=E2=80=94=20E4?= =?UTF-8?q?-E8=20complete=20(E8=20ran=20green=20on=20mainnet);=20cutover?= =?UTF-8?q?=20redeploy=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan/chain/erc4337-master-account.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md index 02ff7a9e..c2e40723 100644 --- a/docs/plan/chain/erc4337-master-account.md +++ b/docs/plan/chain/erc4337-master-account.md @@ -79,12 +79,17 @@ Each phase is independently shippable, idempotent where it mutates chain state ( - **E0** ✅ threat-model drafted → [`erc4337-threat-model.md`](erc4337-threat-model.md). - **E2** ✅ `IERC4337.sol`, `P256Account.sol`, `P256AccountFactory.sol` — **codex-reviewed** (1 P2 fixed: verifier/`abi.decode` reverts now map to `SIG_VALIDATION_FAILED` via a try/catch self-call); **17 account tests green**. -- **E1** ✅ **deployed live on Heima mainnet 2026-06-02** (recorded in [`deployed-contracts.md`](../../spec/deployed-contracts.md)): +- **E1** ✅ **deployed live on Heima mainnet 2026-06-02** (recorded in [`contracts.md`](../../contracts.md)): - `EntryPoint` v0.7 = `0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9` (canonical bytecode; landed a UserOp in the spike). - `P256AccountFactory` = `0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3` (CREATE2 determinism smoke-verified on mainnet: `getAddress` == `createAccount`). -- **E3** ✅ **implemented** — `AgentKeysScope` thinned to account-auth (in-contract K11 + `scopeNonce` retired; `setScopeWithWebauthn`→`setScope`); registry agent-bind closed structurally (master = account); master-device/recovery K11 retained pending E5. **59 crate tests green** (net −91 lines). -- ⏭️ **Cutover ripples (not yet done):** the broker's scope-mint call + `harness/scripts/heima-scope-set.sh` use `setScopeWithWebauthn(... assertion)` — update to `setScope(...)` (no assertion) at redeploy; `AgentKeysScope` now deploys with 1 constructor arg; `operator-workstation.env` gets `ENTRYPOINT_ADDRESS_HEIMA` + `P256_ACCOUNT_FACTORY_ADDRESS_HEIMA` via `heima-bring-up.sh env_set`; `verify-heima-contracts.sh` extended to check the EntryPoint + factory. arch.md §10/§12 update lands at cutover (registry/scope redeploy). -- **E4** folded into E3 (structural). **E5–E8** pending. +- **E3** ✅ `AgentKeysScope` thinned to account-auth (in-contract K11 + `scopeNonce` retired; `setScopeWithWebauthn`→`setScope`); registry agent-bind closed structurally (master = account). +- **E4** ✅ folded into E3 (agent bind/revoke passkey-gated structurally, no new code). +- **E5** ✅ guardian **M-of-N social recovery** in `P256Account` (generation-rotation; independent of the lost primary passkey, per threat-model §7); **+6 tests**. +- **E6** ✅ `VerifyingPaymaster` (broker-co-signed sponsorship = the Sybil gate; ED-aware funding) **+6 tests**; `scripts/erc4337-bundler.sh` unsafe-mode runner (off-chain — not stood up live; direct `handleOps` proves the path). +- **E7** ✅ `harness/scripts/erc4337-webauthn-sign.py` WebAuthn UserOp signer — **validated against the live mainnet K11Verifier** (`verifyAssertion → true`, zero gas). +- **E8** ✅ **ran green on Heima mainnet** — `harness/erc4337-master-e8.sh`, landed on `harness/phase1-wire-demo.sh` as opt-in `phase6` (`AGENTKEYS_ERC4337_E8=1`): a **passkey-only master (no secp256k1 key)** deployed an account via the factory + landed a real master mutation (`addSigner`) via a WebAuthn-signed UserOp through `handleOps`. Acct `0x3e79925F41E46CA87DD1103a572af7449CCd25a9`, `activeSignerCount 1→2`. +- **Crate tests: 71 green** (AgentKeysV1 25 · P256Account 23 · VerifyingPaymaster 6 · K11Verifier 9 · P256Verifier 8). +- ⏭️ **Cutover (coordinated redeploy, not yet done):** the **live E1 factory embeds the E2-era account** (deployed before E5) — so on-chain accounts have no `recover()` yet; the E8 mainnet run exercised `addSigner` (present since E2). Cutover **redeploys the factory** (E3/E5-complete account), the **registry + scope** (account-auth), and updates the broker scope-mint + `heima-scope-set.sh` (`setScopeWithWebauthn`→`setScope`), `operator-workstation.env` (`env_set` the EntryPoint/factory), `verify-heima-contracts.sh`, and arch.md §10/§12. CI skips first-master, so nothing breaks meanwhile. ## 3.2 E3 design — registry thinning + migration From 55924e0a45f27c338fd3a548596e2f1702692072 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 12:57:42 +0800 Subject: [PATCH 13/19] docs(runbook): document Phase 6 ERC-4337 passkey-master (#164 E8) in operator-runbook-wire.md --- docs/operator-runbook-wire.md | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 09ce9edd..7feefd49 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -176,6 +176,40 @@ Pass `--yes` to auto-confirm the non-secret prompts. (Act 3 — Online Revocation — is out of scope for this harness; tested elsewhere.) +## Optional — Phase 6: ERC-4337 passkey-only master (#164 E8) + +An **opt-in** phase (off by default) that proves the #164 master model: a master +with **no secp256k1 key** lands a real on-chain master mutation via an **ERC-4337 +UserOp**. It runs on Heima **mainnet** and spends **~0.22 HEI** per run (account +deposit + gas), so it only runs when you ask for it. + +```bash +# As part of the wire demo (appends Phase 6 after teardown): +AGENTKEYS_ERC4337_E8=1 bash harness/phase1-wire-demo.sh --real --webauthn + +# …or standalone — pure chain flow, no sandbox/Hermes needed: +bash harness/erc4337-master-e8.sh +``` + +What it does: keygen a P-256 passkey → `P256AccountFactory.createAccount` (CREATE2) +→ fund the account's EntryPoint deposit (≥ the ~0.1 HEI ExistentialDeposit, so +`missingAccountFunds == 0`) → build a UserOp whose callData is a master mutation +(`addSigner`) → **WebAuthn-sign the `userOpHash`** → pre-check against the live +`K11Verifier` (zero gas) → `EntryPoint.handleOps` → assert `activeSignerCount 1→2`. +No secp256k1 key signs anything. `ok …` / `fail …` per step. + +**Prereqs:** `cast` (Foundry) on PATH; the deployer key (`~/.agentkeys/heima-deployer.key` +— funds the deposit + gas); Python 3 (the script auto-provisions a `cryptography` +venv at `~/.agentkeys/erc4337-venv`). The live EntryPoint + factory addresses are in +[`docs/contracts.md`](contracts.md). **Append-only:** each run mints a fresh account +(it is NOT idempotent in the resource sense). The bundler is not required — the demo +calls `EntryPoint.handleOps` directly. Full design + cutover status: +[`docs/plan/chain/erc4337-master-account.md`](plan/chain/erc4337-master-account.md). + +> Note: the **live factory** currently embeds the E2-era account (deployed before the +> E5 recovery work), so on-chain accounts have `addSigner` (what E8 exercises) but not +> yet `recover()`; the E3/E5-complete account ships at the coordinated cutover redeploy. + ## Verifying it worked — deterministically (no LLM inference) **Do NOT judge success by the chat reply.** An LLM may phrase a memory-aware @@ -236,7 +270,8 @@ Env overrides: `SANDBOX_URL`, `MCP_PORT`, `SESSION_ID` (default `alice`), `AGENTKEYS_REUSE_AGENT=1` (skip Phase P fresh pairing; reuse a master-side agent) · `AGENTKEYS_AGENT_SESSION_BEARER` (override the agent session) · `MEMORY_ROLE_ARN` / `VAULT_ROLE_ARN` / `REGION` (per-actor STS relay; sourced from `operator-workstation.env`), -`AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER`. +`AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER` · +`AGENTKEYS_ERC4337_E8=1` (opt-in Phase 6 — ERC-4337 passkey-only master on Heima mainnet, ~0.22 HEI; see the Phase 6 section). ## Drift detection From 1d54a4218410c41b66f1509938f0eebef42cef76 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 13:21:51 +0800 Subject: [PATCH 14/19] agentkeys: make #164 E8 (Phase 6) default in --real wire demo (skip in --light; opt out --skip-6 / AGENTKEYS_ERC4337_E8=0) + runbook --- docs/operator-runbook-wire.md | 27 +++++++++++++++++---------- harness/phase1-wire-demo.sh | 25 +++++++++++++++++-------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 7feefd49..74db08d5 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -25,7 +25,9 @@ bash harness/phase1-wire-demo.sh --light # IN THE SANDBOX (never on the master), the master binds it on-chain, and # --webauthn "approves" the memory scope via Touch ID. Then it seeds + recalls # the Chengdu memory. Each run DEPAIRS the prior device (revoke) + re-pairs a fresh -# K10 (register), so expect ONE Touch ID + ~2 on-chain txs per run. +# K10 (register), so expect ONE Touch ID + ~2 on-chain txs per run. It also ends +# with Phase 6 (#164 E8): an ERC-4337 passkey-only master UserOp on mainnet +# (~0.22 HEI) — pass --skip-6 (or AGENTKEYS_ERC4337_E8=0) to skip that spend. bash harness/phase1-wire-demo.sh --real --webauthn # VERIFY — deterministic, no LLM. Run IN THE SANDBOX after setup (the harness @@ -178,16 +180,21 @@ Pass `--yes` to auto-confirm the non-secret prompts. ## Optional — Phase 6: ERC-4337 passkey-only master (#164 E8) -An **opt-in** phase (off by default) that proves the #164 master model: a master -with **no secp256k1 key** lands a real on-chain master mutation via an **ERC-4337 -UserOp**. It runs on Heima **mainnet** and spends **~0.22 HEI** per run (account -deposit + gas), so it only runs when you ask for it. +Runs by **default at the end of every `--real` run** (skipped in `--light`, which +never touches mainnet). Proves the #164 master model: a master with **no secp256k1 +key** lands a real on-chain master mutation via an **ERC-4337 UserOp**. It spends +**~0.22 HEI** on Heima mainnet per run (account deposit + gas) and mints a fresh +account each time, so **opt out** when you don't want the spend: `--skip-6` or +`AGENTKEYS_ERC4337_E8=0`. ```bash -# As part of the wire demo (appends Phase 6 after teardown): -AGENTKEYS_ERC4337_E8=1 bash harness/phase1-wire-demo.sh --real --webauthn +# Default — Phase 6 runs automatically at the end of a --real run: +bash harness/phase1-wire-demo.sh --real --webauthn + +# Skip it (no mainnet spend): +bash harness/phase1-wire-demo.sh --real --webauthn --skip-6 -# …or standalone — pure chain flow, no sandbox/Hermes needed: +# …or run JUST the E8 flow standalone — pure chain, no sandbox/Hermes: bash harness/erc4337-master-e8.sh ``` @@ -255,7 +262,7 @@ optional live demo; run it **while the gate is open** (Phase 5 stops the MCP). --reuse-agent skip fresh pairing; reuse one master-side agent (fast iterate) --unwire remove the managed hooks block at teardown --yes auto-confirm non-secret prompts ---skip-N skip phase N (0–5); e.g. --skip-4 to skip the surprise +--skip-N skip phase N (0–6); e.g. --skip-6 to skip the ERC-4337 master (Phase 6) --help ``` @@ -271,7 +278,7 @@ Env overrides: `SANDBOX_URL`, `MCP_PORT`, `SESSION_ID` (default `alice`), `AGENTKEYS_AGENT_SESSION_BEARER` (override the agent session) · `MEMORY_ROLE_ARN` / `VAULT_ROLE_ARN` / `REGION` (per-actor STS relay; sourced from `operator-workstation.env`), `AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER` · -`AGENTKEYS_ERC4337_E8=1` (opt-in Phase 6 — ERC-4337 passkey-only master on Heima mainnet, ~0.22 HEI; see the Phase 6 section). +`AGENTKEYS_ERC4337_E8=0` (opt OUT of the default Phase 6 — ERC-4337 passkey-only master; runs by default in `--real`, ~0.22 HEI; see the Phase 6 section). ## Drift detection diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index a8ab5ff5..c60291fe 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -14,6 +14,9 @@ # --real Live broker + workers + Heima mainnet, REUSING the account # `setup-heima.sh` created (master `alice`, agent `demo-agent`). # NO in-memory fixture. Live-env steps fail-loud if a prereq missing. +# Ends with Phase 6 (#164 E8) by default: an ERC-4337 passkey-only +# master UserOp on mainnet (~0.22 HEI); opt out --skip-6 / +# AGENTKEYS_ERC4337_E8=0. # # The agent binary must be aarch64-linux (the sandbox is aarch64 Linux); the # harness cross-builds it in an arm64 Linux rust container and uploads it via @@ -1122,17 +1125,23 @@ phase5_teardown() { } # ─── Phase 6 — ERC-4337 passkey-only master (#164 E8) ───────────────────────── -# Opt-in (AGENTKEYS_ERC4337_E8=1): proves a master with NO secp256k1 key lands a -# real master mutation via an ERC-4337 UserOp on Heima mainnet (factory → account -# → WebAuthn-signed UserOp via the live K11Verifier → handleOps → addSigner). The -# heavy lifting is in harness/erc4337-master-e8.sh (run as a subprocess so its -# helper names don't clobber this harness's ok/skip/fail). +# DEFAULT in --real (skipped in --light, which never touches mainnet). Proves a +# master with NO secp256k1 key lands a real master mutation via an ERC-4337 UserOp +# on Heima mainnet (factory → account → WebAuthn-signed UserOp via the live +# K11Verifier → handleOps → addSigner), ~0.22 HEI/run. Opt out with --skip-6 or +# AGENTKEYS_ERC4337_E8=0. Heavy lifting in harness/erc4337-master-e8.sh (run as a +# subprocess so its helper names don't clobber this harness's ok/skip/fail). phase6_erc4337_master() { - if [[ "${AGENTKEYS_ERC4337_E8:-0}" != "1" ]]; then - log "Phase 6 — ERC-4337 passkey-master (E8): skip (set AGENTKEYS_ERC4337_E8=1 to run on Heima mainnet, ~0.22 HEI)" + skip_phase 6 && { log "Phase 6 — ERC-4337 passkey-master (E8): skip (--skip-6)"; return; } + if [[ "$MODE" != "real" ]]; then + log "Phase 6 — ERC-4337 passkey-master (E8): skip (--light; needs --real + Heima mainnet)" return fi - log "Phase 6 — ERC-4337 passkey-only master via UserOp (#164 E8, Heima mainnet)" + if [[ "${AGENTKEYS_ERC4337_E8:-1}" == "0" ]]; then + log "Phase 6 — ERC-4337 passkey-master (E8): skip (AGENTKEYS_ERC4337_E8=0)" + return + fi + log "Phase 6 — ERC-4337 passkey-only master via UserOp (#164 E8, Heima mainnet, ~0.22 HEI)" if bash "$(dirname "${BASH_SOURCE[0]}")/erc4337-master-e8.sh"; then ok "6.1 erc4337-e8" "passkey-only master landed a UserOp mutation (no secp256k1 key)" else From 8a832f450a8d46c3fc35c871642817b6c546bb75 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 13:40:58 +0800 Subject: [PATCH 15/19] agentkeys: E8 fresh-per-run (local) / reuse-one-account (CI auto) + Phase-0 agentkeys-cli guard (#164) - erc4337-master-e8.sh: fresh (default) vs reuse (auto when $CI) modes; reuse uses a persistent passkey+fixed salt (deterministic account), funds only when low; delta assertion (count +1) works for both. Fix _has_code (cast code '0x' != empty). - webauthn-sign.py: keygen idempotent (load-if-exists) so reuse keeps its address. - phase1-wire-demo.sh: 0.2b real-mode guard fails loud if the on-PATH agentkeys lacks the 'agent' subcommand (the stale-binary cascade). - runbook: document the two E8 modes + env vars. --- docs/operator-runbook-wire.md | 18 ++-- harness/erc4337-master-e8.sh | 102 +++++++++++++++-------- harness/phase1-wire-demo.sh | 12 +++ harness/scripts/erc4337-webauthn-sign.py | 26 ++++-- 4 files changed, 108 insertions(+), 50 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 74db08d5..806896bc 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -182,10 +182,12 @@ Pass `--yes` to auto-confirm the non-secret prompts. Runs by **default at the end of every `--real` run** (skipped in `--light`, which never touches mainnet). Proves the #164 master model: a master with **no secp256k1 -key** lands a real on-chain master mutation via an **ERC-4337 UserOp**. It spends -**~0.22 HEI** on Heima mainnet per run (account deposit + gas) and mints a fresh -account each time, so **opt out** when you don't want the spend: `--skip-6` or -`AGENTKEYS_ERC4337_E8=0`. +key** lands a real on-chain master mutation via an **ERC-4337 UserOp**. **Opt out** +with `--skip-6` or `AGENTKEYS_ERC4337_E8=0`. + +**Two modes (auto-selected):** +- **fresh** (default locally) — a NEW account + ephemeral passkey + fresh deposit each run (**~0.22 HEI/run**, append-only). HEI cost doesn't matter for local testing. +- **reuse** (default when `$CI` is set; or force with `ERC4337_E8_MODE=reuse`) — **one persistent account** (fixed passkey at `ERC4337_E8_KEY_FILE` + fixed salt → deterministic address), created once and funded only when its deposit drops below ~0.05 HEI. So CI does **not** mint a new account / spend a full deposit every run. **CI must persist the key file** (Actions cache, or a secret written to `ERC4337_E8_KEY_FILE`) for the account to actually be reused across runs; otherwise each run keygens a new key → a new account. ```bash # Default — Phase 6 runs automatically at the end of a --real run: @@ -202,8 +204,9 @@ What it does: keygen a P-256 passkey → `P256AccountFactory.createAccount` (CRE → fund the account's EntryPoint deposit (≥ the ~0.1 HEI ExistentialDeposit, so `missingAccountFunds == 0`) → build a UserOp whose callData is a master mutation (`addSigner`) → **WebAuthn-sign the `userOpHash`** → pre-check against the live -`K11Verifier` (zero gas) → `EntryPoint.handleOps` → assert `activeSignerCount 1→2`. -No secp256k1 key signs anything. `ok …` / `fail …` per step. +`K11Verifier` (zero gas) → `EntryPoint.handleOps` → assert the account's +active-signer count went up by exactly 1 (fresh `1→2`; reuse `N→N+1`). No secp256k1 +key signs anything. `ok …` / `fail …` per step. **Prereqs:** `cast` (Foundry) on PATH; the deployer key (`~/.agentkeys/heima-deployer.key` — funds the deposit + gas); Python 3 (the script auto-provisions a `cryptography` @@ -278,7 +281,8 @@ Env overrides: `SANDBOX_URL`, `MCP_PORT`, `SESSION_ID` (default `alice`), `AGENTKEYS_AGENT_SESSION_BEARER` (override the agent session) · `MEMORY_ROLE_ARN` / `VAULT_ROLE_ARN` / `REGION` (per-actor STS relay; sourced from `operator-workstation.env`), `AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER` · -`AGENTKEYS_ERC4337_E8=0` (opt OUT of the default Phase 6 — ERC-4337 passkey-only master; runs by default in `--real`, ~0.22 HEI; see the Phase 6 section). +`AGENTKEYS_ERC4337_E8=0` (opt OUT of the default Phase 6 — ERC-4337 passkey-only master; runs by default in `--real`, ~0.22 HEI; see the Phase 6 section) · +`ERC4337_E8_MODE` (`fresh` | `reuse`; auto = `reuse` when `$CI` is set) · `ERC4337_E8_KEY_FILE` (persistent passkey for reuse-mode; CI must cache/secret it). ## Drift detection diff --git a/harness/erc4337-master-e8.sh b/harness/erc4337-master-e8.sh index f95010b0..c9b3956d 100755 --- a/harness/erc4337-master-e8.sh +++ b/harness/erc4337-master-e8.sh @@ -4,15 +4,20 @@ # key on the "device". Proves E1 (factory) + E2 (P256Account, WebAuthn via the live # K11Verifier) + the userOpHash full-intent binding, end-to-end on mainnet. # -# Flow: keygen passkey → factory.createAccount → depositTo(account) → build a UserOp -# whose callData is account.addSigner(...) (a master mutation) → getUserOpHash → -# WebAuthn-sign the userOpHash → K11 pre-check (live, free) → EntryPoint.handleOps → -# assert activeSignerCount == 2. +# Flow: passkey → factory.createAccount → fund deposit → build a UserOp whose +# callData is account.addSigner(...) (a master mutation) → getUserOpHash → +# WebAuthn-sign → K11 pre-check (live, free) → EntryPoint.handleOps → assert the +# account's active-signer count went up by exactly 1. # -# Append-only demo: each run mints a FRESH account (unique salt), so re-runs don't -# collide; it is NOT idempotent in the resource sense (one new account + ~0.22 HEI -# of gas/deposit per run). Direct handleOps (no bundler needed for the proof, per -# the #164 plan). Sourced as phase6 by phase1-wire-demo.sh, or run standalone. +# MODES: +# fresh (default — local): a NEW account + ephemeral passkey + fresh deposit each +# run. Append-only; ~0.22 HEI/run. HEI cost doesn't matter for local testing. +# reuse (auto when $CI is set; or ERC4337_E8_MODE=reuse): ONE persistent account +# (fixed passkey at $ERC4337_E8_KEY_FILE + fixed salt → deterministic address), +# created once and funded only when its deposit runs low — so CI doesn't mint a +# new account / spend a full deposit every run. CI must persist the key file +# (cache/secret) for the account to actually be reused across runs. +# Direct handleOps (no bundler needed for the proof, per the #164 plan). set -uo pipefail RPC="${HEIMA_RPC:-https://rpc.heima-parachain.heima.network}" @@ -22,15 +27,23 @@ K11="${K11_VERIFIER_ADDRESS_HEIMA:-0x5a441431f08e0f5f5ed10659620cb4e0e814e627}" DEPLOYER_KEY_FILE="${HEIMA_DEPLOYER_KEY_FILE:-$HOME/.agentkeys/heima-deployer.key}" RPID="${AGENTKEYS_RP_ID:-litentry.org}" VENV="${ERC4337_VENV:-$HOME/.agentkeys/erc4337-venv}" +# fresh (local default) vs reuse (default in CI). Override with ERC4337_E8_MODE. +MODE_E8="${ERC4337_E8_MODE:-$([ -n "${CI:-}" ] && echo reuse || echo fresh)}" +REUSE_KEY_FILE="${ERC4337_E8_KEY_FILE:-$HOME/.agentkeys/erc4337-e8-reuse.key}" +DEPOSIT_WEI="${ERC4337_E8_DEPOSIT_WEI:-200000000000000000}" # 0.2 HEI +MIN_DEPOSIT_WEI="${ERC4337_E8_MIN_DEPOSIT_WEI:-50000000000000000}" # top up reuse below 0.05 HEI HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SIGNER="$HERE/scripts/erc4337-webauthn-sign.py" ok() { printf ' ok %s\n' "$1"; } skip() { printf ' skip %s\n' "$1"; } fail() { printf ' fail %s\n' "$1"; return 1; } +# True iff $1 has contract code. `cast code` returns exactly "0x" for an empty +# account (wc -c would count that as 3 — hence a length check is wrong). +_has_code() { local c; c="$(cast code "$1" --rpc-url "$RPC" 2>/dev/null)"; [ -n "$c" ] && [ "$c" != "0x" ]; } erc4337_master_e8() { - echo "== #164 E8: passkey-only master via ERC-4337 UserOp (Heima mainnet) ==" + echo "== #164 E8: passkey-only master via ERC-4337 UserOp (Heima mainnet, mode=$MODE_E8) ==" command -v cast >/dev/null || { skip "cast not on PATH"; return 0; } [ -f "$DEPLOYER_KEY_FILE" ] || { skip "no deployer key ($DEPLOYER_KEY_FILE)"; return 0; } if [ ! -x "$VENV/bin/python" ]; then @@ -41,31 +54,49 @@ erc4337_master_e8() { local PK; PK="$(tr -d '[:space:]' < "$DEPLOYER_KEY_FILE")" local DEPLOYER; DEPLOYER="$(cast wallet address --private-key "$PK")" - # EntryPoint + factory must be live. - [ "$(cast code "$EP" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "EntryPoint $EP not deployed"; return 1; } - [ "$(cast code "$FACTORY" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "factory $FACTORY not deployed"; return 1; } + _has_code "$EP" || { fail "EntryPoint $EP not deployed"; return 1; } + _has_code "$FACTORY" || { fail "factory $FACTORY not deployed"; return 1; } ok "EntryPoint + factory live" - # 1. Keygen the master passkey (no secp256k1 key on the "device"). - local KEY="${TMPDIR:-/tmp}/e8-passkey.key" - eval "$($PY "$SIGNER" keygen "$KEY" "$RPID")" # PUBX PUBY RPIDHASH - local CRED1; CRED1="$(cast keccak "e8-cred-$$-${RANDOM}")" - local SALT; SALT="$(cast keccak "e8-salt-$$-${RANDOM}-$(date +%s 2>/dev/null || echo 0)")" + # 1. The master passkey + the account identity (fresh = new each run; reuse = fixed). + local KEY CRED1 SALT + if [ "$MODE_E8" = reuse ]; then + KEY="$REUSE_KEY_FILE" + CRED1="$(cast keccak "agentkeys-e8-reuse-cred1-v1")" + SALT="${ERC4337_E8_SALT:-$(cast keccak "agentkeys-e8-reuse-salt-v1")}" + else + KEY="${TMPDIR:-/tmp}/e8-passkey-$$-${RANDOM}.key" # unique → genuinely fresh key + CRED1="$(cast keccak "e8-cred-$$-${RANDOM}-$(date +%s 2>/dev/null || echo 0)")" + SALT="$(cast keccak "e8-salt-$$-${RANDOM}-$(date +%s 2>/dev/null || echo 0)")" + fi + eval "$($PY "$SIGNER" keygen "$KEY" "$RPID")" # PUBX PUBY RPIDHASH (idempotent: loads if KEY exists) - # 2. Deploy the account via the factory (CREATE2). + # 2. Deploy (or reuse) the account via the factory (CREATE2, idempotent). local ACCT; ACCT="$(cast call "$FACTORY" "getAddress(bytes32,uint256,uint256,bytes32,bytes32)(address)" "$CRED1" "$PUBX" "$PUBY" "$RPIDHASH" "$SALT" --rpc-url "$RPC")" - cast send "$FACTORY" "createAccount(bytes32,uint256,uint256,bytes32,bytes32)" "$CRED1" "$PUBX" "$PUBY" "$RPIDHASH" "$SALT" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 1200000 >/dev/null 2>&1 - [ "$(cast code "$ACCT" --rpc-url "$RPC" 2>/dev/null | wc -c)" -gt 2 ] || { fail "account not deployed at $ACCT"; return 1; } - ok "account deployed (passkey-bound) at $ACCT" + if _has_code "$ACCT"; then + ok "account reused at $ACCT" + else + cast send "$FACTORY" "createAccount(bytes32,uint256,uint256,bytes32,bytes32)" "$CRED1" "$PUBX" "$PUBY" "$RPIDHASH" "$SALT" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 1200000 >/dev/null 2>&1 + _has_code "$ACCT" || { fail "account not deployed at $ACCT"; return 1; } + ok "account deployed (passkey-bound) at $ACCT" + fi - # 3. Fund the account's EntryPoint deposit (≥ ExistentialDeposit so missingAccountFunds==0). - cast send "$EP" "depositTo(address)" "$ACCT" --value 0.2ether --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 200000 >/dev/null 2>&1 + # 3. Fund the EntryPoint deposit (≥ ED so missingAccountFunds==0). fresh: always; + # reuse: only when low, so CI doesn't re-deposit a full 0.2 HEI every run. local DEP; DEP="$(cast call "$EP" "balanceOf(address)(uint256)" "$ACCT" --rpc-url "$RPC" | awk '{print $1}')" - [ "$DEP" != "0" ] || { fail "deposit not credited"; return 1; } - ok "account deposit funded ($DEP wei)" + if [ "$MODE_E8" != reuse ] || [ "$DEP" -lt "$MIN_DEPOSIT_WEI" ]; then + cast send "$EP" "depositTo(address)" "$ACCT" --value "${DEPOSIT_WEI}wei" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 200000 >/dev/null 2>&1 + DEP="$(cast call "$EP" "balanceOf(address)(uint256)" "$ACCT" --rpc-url "$RPC" | awk '{print $1}')" + ok "account deposit funded ($DEP wei)" + else + ok "account deposit sufficient ($DEP wei) — reuse, no top-up" + fi + + # 4. Active-signer count BEFORE (fresh: 1; reuse: grows each run — we assert the delta). + local BEFORE; BEFORE="$(cast call "$ACCT" "activeSignerCount()(uint256)" --rpc-url "$RPC" | awk '{print $1}')" - # 4. Build the UserOp: callData = a real master mutation (addSigner of a 2nd passkey). - local CRED2; CRED2="$(cast keccak "e8-cred2-$$-${RANDOM}")" + # 5. Build the UserOp: callData = a real master mutation (addSigner of a UNIQUE-per-run passkey). + local CRED2; CRED2="$(cast keccak "e8-cred2-$$-${RANDOM}-$(date +%s 2>/dev/null || echo 0)")" local CALLDATA; CALLDATA="$(cast calldata "addSigner(bytes32,uint256,uint256,bytes32)" "$CRED2" "$PUBX" "$PUBY" "$RPIDHASH")" local AGL; AGL="$(printf '0x%032x%032x' 1500000 300000)" # verificationGasLimit | callGasLimit local GASFEES; GASFEES="$(printf '0x%032x%032x' 1000000000 40000000000)" # maxPriority | maxFee @@ -74,24 +105,25 @@ erc4337_master_e8() { local UOH; UOH="$(cast call "$EP" "getUserOpHash((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes))(bytes32)" "$UNSIGNED" --rpc-url "$RPC")" ok "userOpHash = $UOH" - # 5. WebAuthn-sign the userOpHash (the passkey signs; full-intent commitment). + # 6. WebAuthn-sign the userOpHash (the passkey signs; full-intent commitment). eval "$($PY "$SIGNER" sign "$KEY" "$UOH" "$RPID")" # AUTHDATA CDJ CHALLENGE_LOC R S - # 6. Pre-check the assertion against the LIVE K11Verifier (free) before spending gas. + # 7. Pre-check the assertion against the LIVE K11Verifier (free) before spending gas. local PRE; PRE="$(cast call "$K11" "verifyAssertion(bytes32,bytes32,bytes,bytes,uint256,uint256,uint256,uint256,uint256)(bool)" "$UOH" "$RPIDHASH" "$AUTHDATA" "$CDJ" "$CHALLENGE_LOC" "$R" "$S" "$PUBX" "$PUBY" --rpc-url "$RPC" 2>&1 | tail -1)" [ "$PRE" = "true" ] || { fail "K11 pre-check failed ($PRE)"; return 1; } ok "K11 assertion verifies on live verifier" - # 7. Assemble signature = abi.encode(credIdHash, authData, clientDataJSON, loc, r, s) + handleOps. + # 8. Assemble signature = abi.encode(credIdHash, authData, clientDataJSON, loc, r, s) + handleOps. local SIG; SIG="$(cast abi-encode "x(bytes32,bytes,bytes,uint256,uint256,uint256)" "$CRED1" "$AUTHDATA" "$CDJ" "$CHALLENGE_LOC" "$R" "$S")" local SIGNED="($ACCT,$NONCE,0x,$CALLDATA,$AGL,100000,$GASFEES,0x,$SIG)" cast send "$EP" "handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address)" "[$SIGNED]" "$DEPLOYER" --private-key "$PK" --rpc-url "$RPC" --legacy --gas-limit 3000000 >/dev/null 2>&1 - # 8. Assert the master mutation landed: activeSignerCount 1 -> 2. - local COUNT; COUNT="$(cast call "$ACCT" "activeSignerCount()(uint256)" --rpc-url "$RPC" | awk '{print $1}')" - [ "$COUNT" = "2" ] || { fail "UserOp did not execute (activeSignerCount=$COUNT, want 2)"; return 1; } - ok "UserOp executed: passkey-signed addSigner landed, activeSignerCount=2 — passkey-only master ✓" - echo " account=$ACCT (no secp256k1 key was used to authorize the mutation)" + # 9. Assert the master mutation landed: active-signer count went up by exactly 1 + # (works for both fresh [1→2] and reuse [N→N+1]). + local AFTER; AFTER="$(cast call "$ACCT" "activeSignerCount()(uint256)" --rpc-url "$RPC" | awk '{print $1}')" + [ "$AFTER" = "$((BEFORE + 1))" ] || { fail "UserOp did not execute (activeSignerCount $BEFORE→$AFTER, want $((BEFORE + 1)))"; return 1; } + ok "UserOp executed: passkey-signed addSigner landed, activeSignerCount $BEFORE→$AFTER — passkey-only master ✓" + echo " account=$ACCT mode=$MODE_E8 (no secp256k1 key authorized the mutation)" } # Run standalone if invoked directly (non-zero exit on failure, for callers/CI). diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index c60291fe..c740f329 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -284,6 +284,18 @@ phase0_prereqs() { fail "0.2 broker healthz" "broker not reachable (BACKEND_URL=$broker) — run scripts/setup-broker-host.sh" fi + # 0.2b — the agentkeys CLI must have the `agent` subcommand. Phase P (§10.2 + # pairing) calls `agentkeys agent claim/pending`; a STALE CLI on PATH (predates + # #144) fails P.1 with "unrecognized subcommand 'agent'" and cascades into the + # MCP/wire/Acts steps. Catch it here in one line instead of mid-run. + if [[ "$MODE" == "real" ]]; then + if agentkeys agent --help >/dev/null 2>&1; then + ok "0.2b agentkeys cli" "'agent' subcommand present ($(command -v agentkeys))" + else + fail "0.2b agentkeys cli" "agentkeys CLI missing or stale (no 'agent' subcommand) — rebuild: cargo build --release -p agentkeys-cli && cp target/release/agentkeys ~/.local/bin/agentkeys" + fi + fi + # Resolve operator_omni + actor_omni. OPERATOR_OMNI is the MASTER's omni # (sha256("agentkeys"||"evm"||master_addr_lc)) — derive it from OPERATOR_KEY_FILE # so it NEVER depends on the (key-less, fresh-each-run) agent file. In fresh diff --git a/harness/scripts/erc4337-webauthn-sign.py b/harness/scripts/erc4337-webauthn-sign.py index 4d354734..588aafd4 100755 --- a/harness/scripts/erc4337-webauthn-sign.py +++ b/harness/scripts/erc4337-webauthn-sign.py @@ -11,11 +11,14 @@ - msgHash = sha256(authData || sha256(clientDataJSON)); P-256 sign (low-s) Modes: - keygen -> PUBX=, PUBY=, RPIDHASH= + keygen -> PUBX=, PUBY=, RPIDHASH= + Idempotent: if exists, loads + prints its pubkey (does NOT + overwrite — so a reused account keeps its address); else generates + saves. sign -> AUTHDATA=, CDJ=, CHALLENGE_LOC=, R=, S= """ import base64 import hashlib +import os import sys from cryptography.hazmat.primitives import hashes, serialization @@ -30,13 +33,20 @@ def _rp_id_hash(rp_id: str) -> bytes: def keygen(keyfile: str, rp_id: str) -> None: - priv = ec.generate_private_key(ec.SECP256R1()) - with open(keyfile, "wb") as f: - f.write(priv.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - )) + if os.path.exists(keyfile): + with open(keyfile, "rb") as f: + priv = serialization.load_pem_private_key(f.read(), password=None) + else: + priv = ec.generate_private_key(ec.SECP256R1()) + d = os.path.dirname(keyfile) + if d: + os.makedirs(d, exist_ok=True) + with open(keyfile, "wb") as f: + f.write(priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) n = priv.public_key().public_numbers() print(f"PUBX=0x{n.x:064x}") print(f"PUBY=0x{n.y:064x}") From 4f24efe60eed4db4ce21173b362c615aa0a7212a Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 13:54:01 +0800 Subject: [PATCH 16/19] agentkeys: harness Phase-0 builds the host agentkeys CLI (self-heal the stale-binary cascade) + runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2b now runs 'cargo build --release -p agentkeys-cli' so the master-side CLI Phase P uses ($REPO_ROOT/target/release/agentkeys — already preferred at P.0/P.1) is current, then verifies the actual binary has the 'agent' subcommand. Opt out AGENTKEYS_SKIP_CLI_BUILD=1. Runbook: real-mode prereq + troubleshooting row document the build + manual fallback. --- docs/operator-runbook-wire.md | 2 ++ harness/phase1-wire-demo.sh | 30 +++++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 806896bc..ddee7741 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -151,6 +151,7 @@ All three are **idempotent + unattended by default** — re-running converges an - **Hermes** in the sandbox — the harness installs it (guarded `curl|bash`) if absent; needs GitHub reachable. **Real mode only:** +- **The master-side `agentkeys` CLI must have the `agent` subcommand** (Phase P §10.2 pairing calls `agentkeys agent claim/pending`). Step **0.2b builds it for you** — `cargo build --release -p agentkeys-cli` → `target/release/agentkeys`, which the harness prefers (release → debug → PATH). If you opt out (`AGENTKEYS_SKIP_CLI_BUILD=1`) or run `agentkeys agent …` by hand, install a current binary first: `cargo build --release -p agentkeys-cli && cp target/release/agentkeys ~/.local/bin/agentkeys`. A **stale** CLI fails `P.1 claim` with `unrecognized subcommand 'agent'` and cascades into the MCP/wire/Acts steps. - **The broker must be running the issue-#144 (method A) code** — the §10.2 endpoints (`/v1/agent/pairing/request`, `/v1/agent/pairing/claim`, `/v1/agent/pairing/poll`, `/v1/agent/pending-bindings`). If your broker predates this PR, run `bash scripts/setup-broker-host.sh --ref main` (or `--test --yes` for the test host) FIRST, or Phase P `P.0 request` fails with HTTP **404**. The deploy self-checks this — a no-bearer `POST /v1/agent/pairing/claim` must return **401** (route live), not 404 (stale binary). - The `setup-heima.sh` account already created (master device registered + contracts deployed; the harness verifies, never rebuilds). `OPERATOR_OMNI` is derived from your master key (`OPERATOR_KEY_FILE`); the **agent** identity is generated fresh in the sandbox by Phase P (no pre-existing agent file needed in the default fresh-pairing mode). - An **operator session JWT** for cap-mint. The harness now mints this **automatically and non-interactively**: step `0.7` decodes the on-disk session, and if it's missing, expired, **or for the wrong operator** (its `agentkeys.omni_account` ≠ the agent's `operator_omni`), it SIWE-signs a fresh one with `OPERATOR_KEY_FILE` (default `~/.agentkeys/heima-deployer.key` — the master key whose broker omni == `operator_omni`) via the broker's `wallet_sig` plugin. Requires `cast` (Foundry) on PATH. **Note:** the old `alice` email session is a *different* omni and is no longer used for cap-mint — set `OPERATOR_KEY_FILE` to the master key for your operator if the default isn't it. (Pass `AGENTKEYS_SESSION_BEARER` to override entirely.) @@ -312,6 +313,7 @@ Re-running `agentkeys wire hermes` is always safe — unchanged scripts/config s | Phase 1 `1.3 … upload failed` | sandbox upload API runs non-root → can't write `/usr/local/bin` (`Errno 13`) | fixed: binaries now upload to the writable `~/.local/bin` (on PATH); just re-run | | Phase 4 surprise → "No inference provider configured" | key not in `~/.hermes/.env`, or wrong provider | 4.0 writes `OPENROUTER_API_KEY` to `~/.hermes/.env` + sets `provider: openrouter`; confirm `0.6 LLM key` shows `ok` | | `agentkeys memory put` → `error: unrecognized subcommand 'memory'` (run by hand) | a **stale** `agentkeys` on your PATH predates the `memory` command | rebuild + reinstall: `cargo build --release -p agentkeys-cli && cp target/release/agentkeys ~/.local/bin/agentkeys`. The harness itself uses the freshly cross-built **sandbox** binary, so 1.5 is unaffected | +| `P.1 claim` → `unrecognized subcommand 'agent'` (then `1.4 mcp` / wire / Acts cascade) | a **stale** master-side `agentkeys` (predates #144) — Phase P §10.2 needs the `agent` subcommand | step **0.2b** now builds + verifies it automatically (`cargo build --release -p agentkeys-cli` → `target/release/agentkeys`, which the harness prefers). If you set `AGENTKEYS_SKIP_CLI_BUILD=1`, build it yourself or `cp` a current binary onto your PATH. The MCP/wire/Acts failures are cascades — they clear once P.1 works | | `1.5a scope grant` → `grant SKIPPED` | the master's primary K11 isn't enrolled in webauthn mode | `agentkeys k11 enroll --webauthn --rp-id localhost --operator-omni 0x`, then re-run (the failure prints this exact command) | | `1.5b seed memory` fails after the grant (`memory.put` failed) | (a) the operator session was stale/wrong-omni — now auto-minted at `0.7`; (b) the worker 502'd because the per-actor STS relay wasn't wired — now fixed (the MCP backend forwards `X-Aws-*` creds; see the 502 row) | confirm `0.7` shows "operator session ready" AND `0.8 agent session` shows "minted (omni == actor_omni)"; the `{:#}` CLI error now prints the full chain if it still fails | | Phase 4 `4.1 model smoke` / surprise → HTTP 429 | OpenRouter throttling a `:free` model | retry, or use the paid default `LLM_MODEL=deepseek/deepseek-v4-flash` | diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index c740f329..6658503e 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -284,15 +284,31 @@ phase0_prereqs() { fail "0.2 broker healthz" "broker not reachable (BACKEND_URL=$broker) — run scripts/setup-broker-host.sh" fi - # 0.2b — the agentkeys CLI must have the `agent` subcommand. Phase P (§10.2 - # pairing) calls `agentkeys agent claim/pending`; a STALE CLI on PATH (predates - # #144) fails P.1 with "unrecognized subcommand 'agent'" and cascades into the - # MCP/wire/Acts steps. Catch it here in one line instead of mid-run. + # 0.2b — build + verify the MASTER-side agentkeys CLI. Phase P (§10.2 pairing) + # calls `agentkeys agent claim/pending` from the host; the harness prefers + # $REPO_ROOT/target/release/agentkeys (see P.0/P.1 below, release→debug→PATH). + # We BUILD it from the repo source so P.1 runs CURRENT code — not a stale binary + # on PATH (the "unrecognized subcommand 'agent'" cascade into MCP/wire/Acts). + # cargo build is incremental (a no-op when up-to-date); opt out with + # AGENTKEYS_SKIP_CLI_BUILD=1 (then your PATH agentkeys must already have `agent`). if [[ "$MODE" == "real" ]]; then - if agentkeys agent --help >/dev/null 2>&1; then - ok "0.2b agentkeys cli" "'agent' subcommand present ($(command -v agentkeys))" + if [[ "${AGENTKEYS_SKIP_CLI_BUILD:-0}" != "1" ]] && command -v cargo >/dev/null 2>&1; then + log " 0.2b building host agentkeys (cargo build --release -p agentkeys-cli; first build ~1 min)…" + if ( cd "$REPO_ROOT" && cargo build --release -p agentkeys-cli ) >/dev/null 2>&1; then + ok "0.2b agentkeys cli" "built $REPO_ROOT/target/release/agentkeys from current source" + else + skip "0.2b agentkeys cli" "cargo build failed — falling back to an existing binary (verified next)" + fi + fi + # Verify the exact binary Phase P will use (release→debug→PATH) has `agent`. + local _la="" + if [[ -x "$REPO_ROOT/target/release/agentkeys" ]]; then _la="$REPO_ROOT/target/release/agentkeys" + elif [[ -x "$REPO_ROOT/target/debug/agentkeys" ]]; then _la="$REPO_ROOT/target/debug/agentkeys" + else _la="$(command -v agentkeys 2>/dev/null || true)"; fi + if [[ -n "$_la" ]] && "$_la" agent --help >/dev/null 2>&1; then + ok "0.2b agent subcommand" "present in $_la" else - fail "0.2b agentkeys cli" "agentkeys CLI missing or stale (no 'agent' subcommand) — rebuild: cargo build --release -p agentkeys-cli && cp target/release/agentkeys ~/.local/bin/agentkeys" + fail "0.2b agent subcommand" "the agentkeys Phase P will use ($_la) lacks 'agent' — build it: cargo build --release -p agentkeys-cli (then it lands at target/release/, which the harness prefers; or cp it onto your PATH)" fi fi From 36be51372df2eb7e4a7411713c6dd6e87a6e6e0d Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 14:06:27 +0800 Subject: [PATCH 17/19] =?UTF-8?q?fix(harness):=20brace=20${BEFORE}/${AFTER?= =?UTF-8?q?}=20in=20E8=20assert=20=E2=80=94=20UTF-8=20locale=20absorbed=20?= =?UTF-8?q?the=20=E2=86=92=20byte=20into=20the=20var=20name=20(set=20-u:?= =?UTF-8?q?=20'BEFORE:=20unbound=20variable')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harness/erc4337-master-e8.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/harness/erc4337-master-e8.sh b/harness/erc4337-master-e8.sh index c9b3956d..1238c690 100755 --- a/harness/erc4337-master-e8.sh +++ b/harness/erc4337-master-e8.sh @@ -121,8 +121,8 @@ erc4337_master_e8() { # 9. Assert the master mutation landed: active-signer count went up by exactly 1 # (works for both fresh [1→2] and reuse [N→N+1]). local AFTER; AFTER="$(cast call "$ACCT" "activeSignerCount()(uint256)" --rpc-url "$RPC" | awk '{print $1}')" - [ "$AFTER" = "$((BEFORE + 1))" ] || { fail "UserOp did not execute (activeSignerCount $BEFORE→$AFTER, want $((BEFORE + 1)))"; return 1; } - ok "UserOp executed: passkey-signed addSigner landed, activeSignerCount $BEFORE→$AFTER — passkey-only master ✓" + [ "$AFTER" = "$((BEFORE + 1))" ] || { fail "UserOp did not execute (activeSignerCount ${BEFORE}->${AFTER}, want $((BEFORE + 1)))"; return 1; } + ok "UserOp executed: passkey-signed addSigner landed, activeSignerCount ${BEFORE}->${AFTER} - passkey-only master (no secp256k1 key)" echo " account=$ACCT mode=$MODE_E8 (no secp256k1 key authorized the mutation)" } From 256a460145cab3ffb2ce049623c0c428c730cb63 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 14:14:21 +0800 Subject: [PATCH 18/19] =?UTF-8?q?agentkeys:=20unhook=20E8=20from=20the=20w?= =?UTF-8?q?ire=20demo=20=E2=80=94=20it's=20a=20standalone=20mechanism=20sm?= =?UTF-8?q?oke,=20not=20a=20phase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per arch.md §9 the 4337 account binds to the MASTER ONBOARDING ceremony (K11 at stage 2, register the account at stage 4) = #164 E7, landing with the registry cutover. The standalone erc4337-master-e8.sh uses a throwaway software passkey, so it proves the mechanism but is NOT the ceremony (the real master K11 is in the platform authenticator → needs a Touch ID assert). Remove phase6 + the call + the --real header note from phase1-wire-demo.sh (keep the 0.2b Phase-P CLI guard). Runbook + plan reframed; #164 E8 row → mechanism smoke, E7 → ceremony binding. --- docs/operator-runbook-wire.md | 40 ++++++++++------------- docs/plan/chain/erc4337-master-account.md | 4 +-- harness/phase1-wire-demo.sh | 33 +++---------------- 3 files changed, 24 insertions(+), 53 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index ddee7741..abdfb3f5 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -25,9 +25,7 @@ bash harness/phase1-wire-demo.sh --light # IN THE SANDBOX (never on the master), the master binds it on-chain, and # --webauthn "approves" the memory scope via Touch ID. Then it seeds + recalls # the Chengdu memory. Each run DEPAIRS the prior device (revoke) + re-pairs a fresh -# K10 (register), so expect ONE Touch ID + ~2 on-chain txs per run. It also ends -# with Phase 6 (#164 E8): an ERC-4337 passkey-only master UserOp on mainnet -# (~0.22 HEI) — pass --skip-6 (or AGENTKEYS_ERC4337_E8=0) to skip that spend. +# K10 (register), so expect ONE Touch ID + ~2 on-chain txs per run. bash harness/phase1-wire-demo.sh --real --webauthn # VERIFY — deterministic, no LLM. Run IN THE SANDBOX after setup (the harness @@ -179,28 +177,26 @@ Pass `--yes` to auto-confirm the non-secret prompts. (Act 3 — Online Revocation — is out of scope for this harness; tested elsewhere.) -## Optional — Phase 6: ERC-4337 passkey-only master (#164 E8) +## ERC-4337 passkey-only master — standalone mechanism smoke (#164 E8) -Runs by **default at the end of every `--real` run** (skipped in `--light`, which -never touches mainnet). Proves the #164 master model: a master with **no secp256k1 -key** lands a real on-chain master mutation via an **ERC-4337 UserOp**. **Opt out** -with `--skip-6` or `AGENTKEYS_ERC4337_E8=0`. - -**Two modes (auto-selected):** -- **fresh** (default locally) — a NEW account + ephemeral passkey + fresh deposit each run (**~0.22 HEI/run**, append-only). HEI cost doesn't matter for local testing. -- **reuse** (default when `$CI` is set; or force with `ERC4337_E8_MODE=reuse`) — **one persistent account** (fixed passkey at `ERC4337_E8_KEY_FILE` + fixed salt → deterministic address), created once and funded only when its deposit drops below ~0.05 HEI. So CI does **not** mint a new account / spend a full deposit every run. **CI must persist the key file** (Actions cache, or a secret written to `ERC4337_E8_KEY_FILE`) for the account to actually be reused across runs; otherwise each run keygens a new key → a new account. +**Not a wire-demo phase.** The 4337 account is the *master*, so it belongs in the +**master-onboarding ceremony** (arch.md §9 — K11 generated at stage 2, the account +registered at stage 4), tracked as **#164 E7** and landing with the registry +**cutover**. Until that ships, this is a **standalone mechanism smoke**: it proves +the on-chain path works (EntryPoint v0.7 + on-chain P-256 verify + a WebAuthn-signed +UserOp on Heima mainnet) using a **throwaway software passkey** — it is **not** the +real ceremony (the real master K11 lives in the platform authenticator, so a real +UserOp needs a Touch ID assert + the cutover). ```bash -# Default — Phase 6 runs automatically at the end of a --real run: -bash harness/phase1-wire-demo.sh --real --webauthn - -# Skip it (no mainnet spend): -bash harness/phase1-wire-demo.sh --real --webauthn --skip-6 - -# …or run JUST the E8 flow standalone — pure chain, no sandbox/Hermes: +# Run the mechanism smoke directly (pure chain — no sandbox/Hermes). ~0.22 HEI: bash harness/erc4337-master-e8.sh ``` +**Two modes (auto-selected):** +- **fresh** (default locally) — a NEW account + ephemeral passkey + fresh deposit each run (**~0.22 HEI/run**, append-only). HEI cost doesn't matter for local testing. +- **reuse** (default when `$CI` is set; or force with `ERC4337_E8_MODE=reuse`) — **one persistent account** (fixed passkey at `ERC4337_E8_KEY_FILE` + fixed salt → deterministic address), created once and funded only when its deposit drops below ~0.05 HEI, so CI doesn't mint a new account each run. **CI must persist the key file** (Actions cache, or a secret written to `ERC4337_E8_KEY_FILE`); otherwise each run keygens a new key → a new account. + What it does: keygen a P-256 passkey → `P256AccountFactory.createAccount` (CREATE2) → fund the account's EntryPoint deposit (≥ the ~0.1 HEI ExistentialDeposit, so `missingAccountFunds == 0`) → build a UserOp whose callData is a master mutation @@ -266,7 +262,7 @@ optional live demo; run it **while the gate is open** (Phase 5 stops the MCP). --reuse-agent skip fresh pairing; reuse one master-side agent (fast iterate) --unwire remove the managed hooks block at teardown --yes auto-confirm non-secret prompts ---skip-N skip phase N (0–6); e.g. --skip-6 to skip the ERC-4337 master (Phase 6) +--skip-N skip phase N (0–5); e.g. --skip-4 to skip the surprise --help ``` @@ -281,9 +277,7 @@ Env overrides: `SANDBOX_URL`, `MCP_PORT`, `SESSION_ID` (default `alice`), `AGENTKEYS_REUSE_AGENT=1` (skip Phase P fresh pairing; reuse a master-side agent) · `AGENTKEYS_AGENT_SESSION_BEARER` (override the agent session) · `MEMORY_ROLE_ARN` / `VAULT_ROLE_ARN` / `REGION` (per-actor STS relay; sourced from `operator-workstation.env`), -`AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER` · -`AGENTKEYS_ERC4337_E8=0` (opt OUT of the default Phase 6 — ERC-4337 passkey-only master; runs by default in `--real`, ~0.22 HEI; see the Phase 6 section) · -`ERC4337_E8_MODE` (`fresh` | `reuse`; auto = `reuse` when `$CI` is set) · `ERC4337_E8_KEY_FILE` (persistent passkey for reuse-mode; CI must cache/secret it). +`AGENTKEYS_ACTOR_OMNI` / `AGENTKEYS_OPERATOR_OMNI` / `AGENTKEYS_SESSION_BEARER`. ## Drift detection diff --git a/docs/plan/chain/erc4337-master-account.md b/docs/plan/chain/erc4337-master-account.md index c2e40723..ec20e42d 100644 --- a/docs/plan/chain/erc4337-master-account.md +++ b/docs/plan/chain/erc4337-master-account.md @@ -86,8 +86,8 @@ Each phase is independently shippable, idempotent where it mutates chain state ( - **E4** ✅ folded into E3 (agent bind/revoke passkey-gated structurally, no new code). - **E5** ✅ guardian **M-of-N social recovery** in `P256Account` (generation-rotation; independent of the lost primary passkey, per threat-model §7); **+6 tests**. - **E6** ✅ `VerifyingPaymaster` (broker-co-signed sponsorship = the Sybil gate; ED-aware funding) **+6 tests**; `scripts/erc4337-bundler.sh` unsafe-mode runner (off-chain — not stood up live; direct `handleOps` proves the path). -- **E7** ✅ `harness/scripts/erc4337-webauthn-sign.py` WebAuthn UserOp signer — **validated against the live mainnet K11Verifier** (`verifyAssertion → true`, zero gas). -- **E8** ✅ **ran green on Heima mainnet** — `harness/erc4337-master-e8.sh`, landed on `harness/phase1-wire-demo.sh` as opt-in `phase6` (`AGENTKEYS_ERC4337_E8=1`): a **passkey-only master (no secp256k1 key)** deployed an account via the factory + landed a real master mutation (`addSigner`) via a WebAuthn-signed UserOp through `handleOps`. Acct `0x3e79925F41E46CA87DD1103a572af7449CCd25a9`, `activeSignerCount 1→2`. +- **E7** (ceremony binding) — **building block done; full binding pending the cutover.** `harness/scripts/erc4337-webauthn-sign.py` (WebAuthn UserOp signer) is built + **validated against the live mainnet K11Verifier** (`verifyAssertion → true`, zero gas). The real binding — derive the account from the **real K11** (`cose_pubkey_hex`) at **arch.md §9 stage 2**, register it as master via a K11-signed UserOp at **stage 4** — belongs in the master-onboarding path (`heima-register-first-master.sh` / §9 / parent-control onboarding #163) and lands **with the registry cutover**. Blockers: the cutover (registry must accept account-as-`msg.sender`) + the real master K11 lives in the platform authenticator, so a real UserOp needs a **Touch ID assert** (the master private key is not on disk — only `cose_pubkey_hex`). +- **E8** (mechanism proof) ✅ **ran green on Heima mainnet** — `harness/erc4337-master-e8.sh`: a passkey-only flow (no secp256k1 key, **throwaway software passkey**) deployed an account via the factory + landed a real master mutation (`addSigner`) via a WebAuthn-signed UserOp through `handleOps` (acct `0x3e79925F41E46CA87DD1103a572af7449CCd25a9`, `activeSignerCount 1→2`; reuse-mode also verified on `0x417F…`). **Unhooked from `phase1-wire-demo.sh`** — it is a standalone *mechanism smoke*, NOT the ceremony (that is E7 above). The real passkey-only-master e2e acceptance (onboarding-bound, fresh per run) lands with E7 + the cutover. - **Crate tests: 71 green** (AgentKeysV1 25 · P256Account 23 · VerifyingPaymaster 6 · K11Verifier 9 · P256Verifier 8). - ⏭️ **Cutover (coordinated redeploy, not yet done):** the **live E1 factory embeds the E2-era account** (deployed before E5) — so on-chain accounts have no `recover()` yet; the E8 mainnet run exercised `addSigner` (present since E2). Cutover **redeploys the factory** (E3/E5-complete account), the **registry + scope** (account-auth), and updates the broker scope-mint + `heima-scope-set.sh` (`setScopeWithWebauthn`→`setScope`), `operator-workstation.env` (`env_set` the EntryPoint/factory), `verify-heima-contracts.sh`, and arch.md §10/§12. CI skips first-master, so nothing breaks meanwhile. diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index 6658503e..cb16477a 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -14,9 +14,6 @@ # --real Live broker + workers + Heima mainnet, REUSING the account # `setup-heima.sh` created (master `alice`, agent `demo-agent`). # NO in-memory fixture. Live-env steps fail-loud if a prereq missing. -# Ends with Phase 6 (#164 E8) by default: an ERC-4337 passkey-only -# master UserOp on mainnet (~0.22 HEI); opt out --skip-6 / -# AGENTKEYS_ERC4337_E8=0. # # The agent binary must be aarch64-linux (the sandbox is aarch64 Linux); the # harness cross-builds it in an arm64 Linux rust container and uploads it via @@ -1152,30 +1149,11 @@ phase5_teardown() { ok "5.2 account" "kept (no Act 3 → nothing to restore)" } -# ─── Phase 6 — ERC-4337 passkey-only master (#164 E8) ───────────────────────── -# DEFAULT in --real (skipped in --light, which never touches mainnet). Proves a -# master with NO secp256k1 key lands a real master mutation via an ERC-4337 UserOp -# on Heima mainnet (factory → account → WebAuthn-signed UserOp via the live -# K11Verifier → handleOps → addSigner), ~0.22 HEI/run. Opt out with --skip-6 or -# AGENTKEYS_ERC4337_E8=0. Heavy lifting in harness/erc4337-master-e8.sh (run as a -# subprocess so its helper names don't clobber this harness's ok/skip/fail). -phase6_erc4337_master() { - skip_phase 6 && { log "Phase 6 — ERC-4337 passkey-master (E8): skip (--skip-6)"; return; } - if [[ "$MODE" != "real" ]]; then - log "Phase 6 — ERC-4337 passkey-master (E8): skip (--light; needs --real + Heima mainnet)" - return - fi - if [[ "${AGENTKEYS_ERC4337_E8:-1}" == "0" ]]; then - log "Phase 6 — ERC-4337 passkey-master (E8): skip (AGENTKEYS_ERC4337_E8=0)" - return - fi - log "Phase 6 — ERC-4337 passkey-only master via UserOp (#164 E8, Heima mainnet, ~0.22 HEI)" - if bash "$(dirname "${BASH_SOURCE[0]}")/erc4337-master-e8.sh"; then - ok "6.1 erc4337-e8" "passkey-only master landed a UserOp mutation (no secp256k1 key)" - else - fail "6.1 erc4337-e8" "see erc4337-master-e8.sh output above" - fi -} +# Note: the ERC-4337 passkey-master (#164 E8) is NOT a wire-demo phase. It binds +# to the MASTER ONBOARDING ceremony (arch.md §9 — K11 at stage 2, register the +# account at stage 4), tracked as #164 E7 and landing with the registry cutover. +# Until then, harness/erc4337-master-e8.sh is a standalone *mechanism smoke* (run +# it directly), not part of this agent-side e2e. See docs/operator-runbook-wire.md. # ─── main ──────────────────────────────────────────────────────────────────── main() { @@ -1191,7 +1169,6 @@ main() { phase3_acts phase4_surprise phase5_teardown - phase6_erc4337_master log "summary" if [[ "$FAILED" -eq 0 ]]; then From bbb1e66d3aad63027746d8d80befb45e58ce4fa3 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 14:31:51 +0800 Subject: [PATCH 19/19] agentkeys: address codex adversarial review (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1 HIGH: VerifyingPaymaster.getHash now binds the paymaster gas limits (paymasterAndData[20:52]) so a valid broker sig can't be reused with inflated limits. +test_RejectsTamperedGasLimits. - #3 MED: recover() dedups guardians by (pubX,pubY), not just credIdHash, so one physical key under two credIds can't satisfy an M>=2 quorum. +test. - #2 HIGH (deployment-ordering): warn in AgentKeysScope @dev + threat-model §10 — never deploy the thinned scope before the registry cutover (else an EOA master could setScope with no biometric). - #4/#5 documented in threat-model §10 (malleability is nonce-mitigated; recover-to- unusable-signer is operator error). 73 tests green. --- crates/agentkeys-chain/src/AgentKeysScope.sol | 6 +++ crates/agentkeys-chain/src/P256Account.sol | 14 +++++-- .../src/VerifyingPaymaster.sol | 6 +++ crates/agentkeys-chain/test/P256Account.t.sol | 17 +++++++++ .../test/VerifyingPaymaster.t.sol | 37 ++++++++++++++++--- docs/plan/chain/erc4337-threat-model.md | 12 ++++++ 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/crates/agentkeys-chain/src/AgentKeysScope.sol b/crates/agentkeys-chain/src/AgentKeysScope.sol index 7a76650f..dd26fa3c 100644 --- a/crates/agentkeys-chain/src/AgentKeysScope.sol +++ b/crates/agentkeys-chain/src/AgentKeysScope.sol @@ -11,6 +11,12 @@ interface ISidecarRegistry { /// Read by the broker on cap-mint AND by workers on cap-verify /// (arch.md §12.4, §13.1, §19). /// +/// @dev ⚠ DEPLOYMENT ORDER (codex #2): deploy this thinned scope ONLY together +/// with the registry cutover that stores the operator's 4337 ACCOUNT as +/// `operatorMasterWallet`. If deployed while a master is still a raw EOA, +/// that EOA key alone could setScope/revokeScope with NO biometric (the +/// in-contract K11 gate is gone). Never deploy E3 before the cutover. +/// /// @dev #164 E3 (Solution A — ERC-4337 P-256 master). Scope mutations are /// authorized by `msg.sender == operatorMasterWallet(operator)`, where /// the master is now an ERC-4337 P-256 smart account. The passkey check diff --git a/crates/agentkeys-chain/src/P256Account.sol b/crates/agentkeys-chain/src/P256Account.sol index f5468fa1..828a8f2c 100644 --- a/crates/agentkeys-chain/src/P256Account.sol +++ b/crates/agentkeys-chain/src/P256Account.sol @@ -312,11 +312,19 @@ contract P256Account is IAccount { uint256 nValid; for (uint256 i = 0; i < assertions.length; ++i) { bytes32 gid = assertions[i].guardianCredIdHash; - for (uint256 j = 0; j < i; ++j) { - if (assertions[j].guardianCredIdHash == gid) revert DuplicateGuardian(gid); - } Guardian storage g = guardians[gid]; if (!g.active) revert UnknownGuardian(gid); + // codex #3: reject the same credId AND the same physical key registered + // under a second credId — one guardian must not satisfy an M>=2 quorum. + for (uint256 j = 0; j < i; ++j) { + Guardian storage pg = guardians[assertions[j].guardianCredIdHash]; + if ( + assertions[j].guardianCredIdHash == gid + || (pg.pubX == g.pubX && pg.pubY == g.pubY) + ) { + revert DuplicateGuardian(gid); + } + } // A malformed/mismatched assertion reverts in the verifier; try/catch // so one bad guardian envelope doesn't grief the whole recovery. try IK11Verifier(k11Verifier).verifyAssertion( diff --git a/crates/agentkeys-chain/src/VerifyingPaymaster.sol b/crates/agentkeys-chain/src/VerifyingPaymaster.sol index 056cdef5..3744e6ae 100644 --- a/crates/agentkeys-chain/src/VerifyingPaymaster.sol +++ b/crates/agentkeys-chain/src/VerifyingPaymaster.sol @@ -118,6 +118,12 @@ contract VerifyingPaymaster is IPaymaster { userOp.accountGasLimits, userOp.preVerificationGas, userOp.gasFees, + // codex #1: bind the paymaster gas limits the broker approved + // (paymasterAndData[20:52]) so a bundler can't inflate them while + // reusing a valid sponsorship signature. + userOp.paymasterAndData.length >= 52 + ? bytes32(userOp.paymasterAndData[20:52]) + : bytes32(0), block.chainid, address(this), brokerSigner, diff --git a/crates/agentkeys-chain/test/P256Account.t.sol b/crates/agentkeys-chain/test/P256Account.t.sol index 4e8ff596..3e8011d2 100644 --- a/crates/agentkeys-chain/test/P256Account.t.sol +++ b/crates/agentkeys-chain/test/P256Account.t.sol @@ -301,4 +301,21 @@ contract P256AccountTest is Test { vm.expectRevert(abi.encodeWithSelector(P256Account.DuplicateGuardian.selector, GCRED)); acct.recover(NEWCRED, PUBX, PUBY, RPID, a); } + + // codex #3: the same physical key registered under two credIds must not satisfy + // an M>=2 quorum — recover() dedups by (pubX,pubY), not just credIdHash. + function test_Recover_RejectsDuplicateGuardianPubkey() public { + P256Account acct = _deploy(); + vm.startPrank(ENTRYPOINT); + acct.addGuardian(GCRED, PUBX, PUBY, RPID); + acct.addGuardian(GCRED2, PUBX, PUBY, RPID); // distinct credId, SAME physical key + acct.setRecoveryThreshold(2); + vm.stopPrank(); + k11.setResult(true); + P256Account.GuardianAssertion[] memory a = new P256Account.GuardianAssertion[](2); + a[0] = _gAssertion(GCRED); + a[1] = _gAssertion(GCRED2); + vm.expectRevert(abi.encodeWithSelector(P256Account.DuplicateGuardian.selector, GCRED2)); + acct.recover(NEWCRED, PUBX, PUBY, RPID, a); + } } diff --git a/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol b/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol index b5af8a87..e6dfb6de 100644 --- a/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol +++ b/crates/agentkeys-chain/test/VerifyingPaymaster.t.sol @@ -30,14 +30,23 @@ contract VerifyingPaymasterTest is Test { op.gasFees = bytes32(uint256(2)); } + uint128 constant PM_VER_GAS = 1_000_000; + uint128 constant PM_POST_GAS = 50_000; + function _sign(uint256 pk, PackedUserOperation memory op) internal view returns (bytes memory pad) { - bytes32 h = pm.getHash(op, VALID_UNTIL, VALID_AFTER); - bytes32 ethH = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", h)); + // getHash now binds paymasterAndData[20:52] (the gas limits), so set them + // before hashing (placeholder 65-byte sig — getHash ignores the sig bytes). + op.paymasterAndData = abi.encodePacked( + address(pm), PM_VER_GAS, PM_POST_GAS, VALID_UNTIL, VALID_AFTER, new bytes(65) + ); + bytes32 ethH = keccak256( + abi.encodePacked( + "\x19Ethereum Signed Message:\n32", pm.getHash(op, VALID_UNTIL, VALID_AFTER) + ) + ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethH); - bytes memory sig = abi.encodePacked(r, s, v); - // prefix: 20 paymaster + 16 vGasLimit + 16 postOpGasLimit, then vu|va|sig pad = abi.encodePacked( - address(pm), uint128(0), uint128(0), VALID_UNTIL, VALID_AFTER, sig + address(pm), PM_VER_GAS, PM_POST_GAS, VALID_UNTIL, VALID_AFTER, abi.encodePacked(r, s, v) ); } @@ -90,4 +99,22 @@ contract VerifyingPaymasterTest is Test { vm.expectRevert(VerifyingPaymaster.BadPaymasterDataLength.selector); pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); } + + // codex #1: a bundler/attacker inflates the paymaster gas limits ([20:52]) while + // reusing a valid broker signature → must be rejected (the limits are now signed). + function test_RejectsTamperedGasLimits() public { + PackedUserOperation memory op = _op(); + bytes memory pad = _sign(brokerPk, op); // signed over PM_VER_GAS / PM_POST_GAS + bytes memory sig = new bytes(65); + for (uint256 i = 0; i < 65; ++i) { + sig[i] = pad[64 + i]; + } + // same sig, inflated gas limits: + op.paymasterAndData = abi.encodePacked( + address(pm), uint128(9_000_000), uint128(9_000_000), VALID_UNTIL, VALID_AFTER, sig + ); + vm.prank(ENTRYPOINT); + (, uint256 vd) = pm.validatePaymasterUserOp(op, bytes32(0), 1 ether); + assertEq(vd & 1, 1, "inflated paymaster gas limits -> sigFailed"); + } } diff --git a/docs/plan/chain/erc4337-threat-model.md b/docs/plan/chain/erc4337-threat-model.md index 747bfe74..70d40802 100644 --- a/docs/plan/chain/erc4337-threat-model.md +++ b/docs/plan/chain/erc4337-threat-model.md @@ -108,3 +108,15 @@ A lost primary device means no `validateUserOp` from that key. Recovery (M-of-N - [ ] E3: prove the registry rewrite keeps the #90 negative tests green + adds account-only positive/negative tests. - [ ] E7: front-run negative test under the account model. - [ ] Only deploy production EntryPoint + factory to mainnet **after** the above. + +## 10. Adversarial review findings (codex, 2026-06-02) + dispositions + +Adversarial pass over `P256Account` / `P256AccountFactory` / `VerifyingPaymaster` / the thinned `AgentKeysScope`. No ERC-7562 opcode bypass found. + +| # | Sev | Finding | Disposition | +|---|---|---|---| +| 1 | HIGH | `VerifyingPaymaster.getHash` omitted the paymaster gas limits (`paymasterAndData[20:52]`) → a valid broker sig could be reused with inflated limits (drain/grief). | **Fixed** — `getHash` now binds `[20:52]`; test `test_RejectsTamperedGasLimits`. | +| 2 | HIGH | Thinned `AgentKeysScope` (`msg.sender == operatorMasterWallet`, in-contract K11 retired) + an **un-migrated EOA master** ⇒ scope writes with no biometric. | **Deployment-ordering invariant** — the contract can't tell an EOA from a 4337 account, so there is no clean in-contract guard. The thinned scope MUST be deployed **only together with the registry cutover** that stores account-masters (so `msg.sender == account`, which is passkey-gated). Warned in `AgentKeysScope.sol` @dev + the E3/cutover notes. **Never deploy E3 pre-cutover.** | +| 3 | MED | Guardian quorum bypass — one physical key registered under two credIds satisfies an M≥2 quorum. | **Fixed** — `recover()` dedups by `(pubX,pubY)`, not just credIdHash; test `test_Recover_RejectsDuplicateGuardianPubkey`. | +| 4 | MED | P-256 malleability — `P256Verifier` accepts high-s, so `(r, n-s)` also verifies. | **Mitigated, not replay-exploitable**: the EntryPoint 2D nonce (`validateUserOp`) and `recoveryNonce` (`recover`) consume the op regardless of sig form. Follow-up: enforce low-s in `P256Verifier` — deferred because it is the **live, shared** verifier (also used by `SidecarRegistry`), so it needs its own change + redeploy. | +| 5 | LOW | `recover()` can install an unusable signer (caller supplies a bad new pubkey). | **Accepted** — operator/relayer error, not an attacker path; guardians can recover again. An on-chain check cannot distinguish a valid-looking-but-wrong P-256 point. |