diff --git a/CLAUDE.md b/CLAUDE.md index 4500a426..9c61f34b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,6 +271,8 @@ same change** (the keep-the-docs-in-sync rule). ## Heima EVM compatibility level — keep `evm_version = "london"` in foundry.toml (but NOT because Heima is "London") +> **Migration index:** every Heima-vs-Ethereum EVM divergence the repo works around (this `evm_version` pin, the `eth_estimateGas`-reverts-on-`handleOps` gas-limit pins, the mixHash-less-receipt on-chain re-verify posture, the `cast send --create` deploy path, the year-prefixed `chain_id`) is consolidated as a **gap → symptom → workaround → code site → what-changes-on-eth** inventory in [`docs/spec/heima-eth-gap.md`](docs/spec/heima-eth-gap.md), with a Heima→Ethereum migration checklist. This section stays the canonical home for the *capability proofs* below; the gap doc defers here for them. + **Two separate things — do not conflate them (the earlier revision of this section did):** 1. **EVM *execution* level (which opcodes the chain runs) = Cancun.** Heima's Frontier `stable2412` `pallet_evm` returns `&CANCUN_CONFIG` from `frame/evm/src/lib.rs::config()` (the `// London` doc-comment one line up is stale upstream). **Verified on-chain** (local `heima-node --dev`, 2026-06-01) by deploying + *executing* contracts that use post-London opcodes: @@ -300,7 +302,16 @@ Determine the real opcode level any time by *executing* a probe on a dev chain ( ## Deployed contract registry -Live contract addresses on each chain (Heima mainnet v2 set, the ERC-4337 master infra #164, historical v1) plus the prod/test EVM deployer wallets are kept in [`docs/spec/deployed-contracts.md`](docs/spec/deployed-contracts.md) — the single canonical registry, indexed from `arch.md` §5. (`docs/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/spec/deployed-contracts.md` is the human-readable canonical record (deployer, deploy date, block, explorer links, ABI summary). +Live contract addresses on each chain plus the prod/test EVM deployer wallets are documented in [`docs/spec/deployed-contracts.md`](docs/spec/deployed-contracts.md) — **human PROSE only** (deployer wallets, ABI summaries, cutover/historical notes, explorer links), indexed from `arch.md` §5. (`docs/contracts.md` redirects to it.) It **no longer carries an address table** — the addresses live in the chain profile (below). + +**The machine-readable SOURCE OF TRUTH is the chain profile [`crates/agentkeys-core/chain-profiles/.json`](crates/agentkeys-core/chain-profiles/heima.json)** — a strict-typed `ChainProfile` (Rust struct + `include_str!` + the `chain_profile::tests::heima_carries_full_contract_registry_and_version` pinning test). Its `contracts[]` array holds each contract's address; `contract_set_version` holds the deployed SET version. `scripts/heima-bring-up.sh` step 6b **rewrites it programmatically on every fresh deploy** (alongside `scripts/operator-workstation.env`, the shell mirror, step 6). The **expected** source version lives in [`crates/agentkeys-chain/VERSION`](crates/agentkeys-chain/VERSION). (The former `deployed-contracts.json` was folded INTO the chain profile — do not re-create it.) + +**Two HARD rules when any contract changes:** + +1. **Idempotency is by VERSION, not bytecode.** Solidity bytecode isn't reliably comparable (embedded metadata hash + immutables), so do NOT diff bytecode. A redeploy is warranted when `crates/agentkeys-chain/VERSION` ≠ the chain profile's `contract_set_version` (or there's no on-chain code). **Bump `VERSION` when you change a contract** → the next deploy redeploys + bumps the profile's `contract_set_version`. A `VERSION` mismatch while code is already live is a **hard stop** (the script prints the mismatch + asks for an explicit opt-in — orphaning state costs mainnet gas), not an auto-redeploy. `FORCE_DEPLOY=1 heima-bring-up.sh` is a **BLIND manual override**; for the #225 account-auth cutover use [`scripts/heima-cutover-account-auth.sh`](scripts/heima-cutover-account-auth.sh) (probes the live `setScope` selector `d8e9e3c6` + skips when already live). +2. **A new deploy auto-updates the two machine mirrors; YOU update only the prose + rebuild.** `heima-bring-up.sh` writes the chain profile (`contracts[]` + `contract_set_version`) + `operator-workstation.env` automatically. You ALSO touch `docs/spec/deployed-contracts.md` **only if the design/version changed** (the version line + any ABI/cutover note — no address table to edit), and since the profile is `include_str!`-compiled, **rebuild the broker/daemon/UI** (`setup-broker-host.sh --ref main`) so they serve the new addresses. `arch.md` §5 links to the registry (no literal addresses to edit). **Confirm locally — NOT a per-PR CI workflow (CI is reserved for heavier checks):** `bash scripts/check-deployed-contracts-sync.sh` (verifies the chain profile ⟷ `operator-workstation.env`). + + **COMMIT + PUSH the two machine mirrors BEFORE you redeploy the broker (HARD — real #225 split-registry incident).** The broker host deploys from `origin/` and compiles the chain profile in via `include_str!`. If the freshly-rewritten `heima.json` + `operator-workstation.env` are left uncommitted (or committed-but-unpushed), `setup-broker-host.sh --ref ` rebuilds the broker on the **OLD** registry while the local daemon onboards into the **NEW** one. The broker then reads `operatorMasterWallet` from the orphaned registry, builds the accept UserOp for the wrong (stale) master account, and `handleOps` reverts **`SIG_VALIDATION_FAILED`** — an accept failure that looks like a "wrong passkey" bug but is actually a split registry. Order: deploy → **commit + push `heima.json` + `operator-workstation.env`** → `setup-broker-host.sh --ref ` on the host. `heima-bring-up.sh`'s step-7 guard warns loudly if you skip the commit. Verify all contracts are live + functional any time: diff --git a/Cargo.lock b/Cargo.lock index 27f0cf0b..9a8052b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ name = "agentkeys-cli" version = "0.1.0" dependencies = [ + "agentkeys-backend-client", "agentkeys-core", "agentkeys-memory-engine", "agentkeys-memory-openviking", diff --git a/apps/parent-control/app/_components/App.tsx b/apps/parent-control/app/_components/App.tsx index 97c59221..09840eea 100644 --- a/apps/parent-control/app/_components/App.tsx +++ b/apps/parent-control/app/_components/App.tsx @@ -16,6 +16,8 @@ import { LogoPage } from './logos'; import { MemoryPage } from './memory'; import { CredentialsPage } from './credentials'; import { PairingPage } from './pairing'; +import { getAssertionOverHash } from '@/lib/webauthn'; +import { akLog } from '@/lib/debug'; import { EmptyState, Modal, WebAuthnModal } from './shared'; import { useClient, useConnectionStatus } from '@/lib/ClientProvider'; import { PREPARED_MEMORY } from '@/lib/preparedMemory'; @@ -48,7 +50,7 @@ export function App() { const [paused, setPaused] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [eventDetail, setEventDetail] = useState(null); - const [toast, setToast] = useState<{ msg: string; sticky?: boolean } | null>(null); + const [toast, setToast] = useState<{ msg: string; sticky?: boolean; action?: { label: string; fn: () => void } } | null>(null); const [onboarded, setOnboarded] = useState(false); const [identity, setIdentity] = useState<{ email?: string; omni?: string } | null>(null); @@ -171,12 +173,54 @@ export function App() { return stop; }, [onboarded, paused, client]); - const showToast = (msg: string, sticky = false) => { - setToast({ msg, sticky }); + const showToast = (msg: string, sticky = false, action?: { label: string; fn: () => void }) => { + setToast({ msg, sticky, action }); // Sticky toasts (e.g. post-onboarding next-steps) stay until dismissed. if (!sticky) setTimeout(() => setToast(null), 2600); }; + // #225 E7 — fully unbind the master so the operator can re-onboard a fresh passkey + // (used when the bound passkey was deleted in the OS password manager, or an accept + // fails with a wrong-passkey signature after a re-onboard). The daemon clears BOTH + // local state AND the on-chain operatorMasterWallet (owner-gated resetMaster), which + // is what actually lets the fresh passkey re-bind. Cannot delete the OS passkey + // (WebAuthn) — the toast tells the operator to do that manually. + const resetMaster = async () => { + if (status.kind !== 'connected') { showToast('Connect a daemon first.'); return; } + let cleared: string | null = null; + try { cleared = localStorage.getItem('ak_master_cred_id'); } catch {} + akLog('reset: clearing master binding (local + on-chain; OS passkey untouched)', { + clearedCredentialId: cleared, + }); + try { localStorage.removeItem('ak_master_cred_id'); } catch {} + try { localStorage.removeItem('ak_onboarded'); } catch {} + const r = await client.resetMaster(); + if (!r.ok) { + akLog('reset: FAILED ❌', { detail: r.status?.detail }); + showToast(`Reset failed — ${r.status?.detail ?? 'daemon error'}`); + return; + } + const onchain = r.data.onchain; + akLog('reset: done', { onchain }); + const onchainCleared = + onchain?.status === 'reset' || + (onchain?.status === 'skipped' && onchain?.reason === 'already-unbound'); + if (onchainCleared) { + showToast( + 'Master unbound (local + on-chain). Delete the master passkey in System Settings ▸ Passwords, then re-onboard once.', + true, + ); + } else { + // On-chain unbind didn't land — re-onboarding will still SIG_VALIDATION-fail until + // it does. Surface the reason so the operator (or dev) can fix it. + const why = onchain?.error ?? onchain?.reason ?? 'unknown'; + showToast( + `Local binding cleared, but the ON-CHAIN unbind did NOT land (${why}). Re-onboarding will still fail until it does — ensure the registry has resetMaster (VERSION ≥ 0.3) + the deployer key, then retry.`, + true, + ); + } + }; + const go = (p: Page, id: string | null = null) => { setPage(p); setActorId(id); @@ -359,18 +403,116 @@ export function App() { showToast('Connect a daemon to approve a pairing.'); return; } - showToast(`Registering ${req.agent} on chain…`); - const r = await client.registerPairing(req.id); - if (!r.ok) { - showToast('Register failed — check your master session + chain config.'); + // #225 E7 — the real Touch-ID gate: build the sponsored executeBatch UserOp on + // the broker, sign its userOpHash with K11 (Touch ID), submit → handleOps. This + // does BOTH registerAgentDevice (P.2) + setScope (P.3) atomically, in one block. + const services = req.requested.flatMap((p) => p.ns).map((ns) => `memory:${ns}`); + showToast(`Building accept for ${req.agent}…`); + const built = await client.acceptBuild({ + requestId: req.id, + services, + readOnly: false, + maxPerCall: '0', + maxPerPeriod: '0', + maxTotal: '0', + periodSeconds: 0, + }); + if (!built.ok) { + showToast(`Accept build failed — ${built.status?.detail ?? 'check master session + chain (cutover?)'}`); + return; + } + showToast('Approve with Touch ID…'); + const masterAccount = built.data.user_op?.sender; + akLog('accept: built UserOp — master account = operatorMasterWallet', { + masterAccount, + userOpHash: built.data.user_op_hash, + entryPoint: built.data.entry_point, + }); + let assertion; + try { + // Auto-select the master passkey (stored at K11 enrollment) so the browser + // skips its picker and the right key signs. Absent ⇒ full picker (fallback). + let masterCred: string | null = null; + try { masterCred = localStorage.getItem('ak_master_cred_id'); } catch {} + akLog('accept: signing userOpHash (Touch ID)', { + masterAccount, + requestedCredentialId: masterCred ?? '(none — full picker)', + userOpHash: built.data.user_op_hash, + }); + assertion = await getAssertionOverHash( + built.data.user_op_hash, + masterCred ? [masterCred] : undefined, + ); + // THE key diagnostic: the passkey that actually signed vs the one we requested. + // If signingCredentialId ≠ the credential bound at onboarding, the accept will + // SIG_VALIDATION_FAILED on chain (the account verifies only the bound pubkey). + akLog('accept: assertion produced', { + masterAccount, + requestedCredentialId: masterCred ?? '(none)', + signingCredentialId: assertion.credential_id, + autoSelectMatched: !masterCred || assertion.credential_id === masterCred, + }); + } catch { + // Either the operator cancelled, OR the bound master passkey was deleted in the + // OS password manager (the picker then has nothing to sign with). + showToast( + 'Touch ID cancelled — or your master passkey was deleted. If you deleted it, reset + re-onboard once.', + true, + { label: 'Reset master', fn: resetMaster }, + ); return; } - showToast(`Registered ${req.agent} on chain. Grant its scope next (Touch ID).`); + const submitted = await client.acceptSubmit({ user_op: built.data.user_op, assertion }); + if (!submitted.ok) { + const detail = submitted.status?.detail ?? 'handleOps error'; + akLog('accept: submit FAILED ❌', { + masterAccount, + signingCredentialId: assertion.credential_id, + detail, + }); + // A SIG_VALIDATION_FAILED / on-chain revert here almost always means the signing + // passkey ≠ the one bound to the master account — typically a stale binding after a + // passkey was deleted/regenerated. The reset path now unbinds on-chain (owner-gated + // resetMaster) so a fresh enroll CAN re-bind. Offer the reset + re-onboard path. + if (/SIG_VALIDATION|wrong passkey|reverted on-chain/i.test(detail)) { + showToast( + `Accept failed (${detail}). Your signing passkey ≠ the one bound to your master account — reset, delete the old passkey, re-onboard once.`, + true, + { label: 'Reset master', fn: resetMaster }, + ); + } else { + showToast(`Accept submit failed — ${detail}`); + } + return; + } + akLog('accept: submit OK ✅', { + masterAccount, + signingCredentialId: assertion.credential_id, + txHash: (submitted.data as { tx_hash?: string } | undefined)?.tx_hash, + }); + showToast(`${req.agent} accepted on chain (Touch ID · register + scope, one block).`); + // Drop it from the pending list. The accept registered the agent on-chain, but the + // broker only clears the rendezvous row on an explicit ack (the accept/submit body + // carries no request_id) — without this the request reappears on every refresh. + // Remove locally for instant feedback, then ack the broker so it stays gone. + setPairingRequests((prev) => prev.filter((r) => r.id !== req.id)); + const acked = await client.ackPairing(req.id); + if (!acked.ok) akLog('accept: ack pending-binding failed (request may reappear)', { detail: acked.status?.detail }); await refreshPairing(); + const a = await client.listActors(); + if (a.ok) setActors(a.data); }; - const declinePairing = (id: string) => { - setPairingRequests((prev) => prev.filter((r) => r.id !== id)); + const declinePairing = async (id: string) => { + // Actually drop the request on the broker (J1-gated, no Touch ID) — not just + // the local list, or it reappears on the next refresh. + const r = await client.declinePairing(id); + if (!r.ok) { + showToast(`Decline failed — ${r.status?.detail ?? 'check the master session'}`); + return; + } + setPairingRequests((prev) => prev.filter((req) => req.id !== id)); showToast('Pairing request declined.'); + await refreshPairing(); }; // #214: poll the REAL broker rendezvous (daemon GET /v1/agent/pairing/pending) // for agents the master has claimed that await on-chain approval. Replaces the @@ -429,7 +571,7 @@ export function App() { }); }; - const confirmAction = () => { + const confirmAction = async () => { const action = pendingAction; setPendingAction(null); if (!action) return; @@ -439,9 +581,18 @@ export function App() { } if (action.kind === 'revoke-device') { const actor = action.actor; - void client.revokeDevice(actor.id, action.intent); - setActors((prev) => prev.map((a) => (a.id === actor.id ? { ...a, status: 'bad', lastActive: 'revoked', label: a.label + ' (revoked)' } : a))); - showToast(`${actor.label} revoked. SSE drop event broadcast.`); + // On-chain revokeAgentDevice (daemon → heima-device-revoke.sh). Await it — the + // binding isn't gone until SidecarRegistry says so — then re-fetch the + // authoritative actor tree instead of optimistically flipping local state. + showToast(`Revoking ${actor.label} on chain…`); + const r = await client.revokeDevice(actor.id, action.intent); + if (!r.ok) { + showToast(`Revoke failed — ${r.status?.detail ?? 'check the daemon + chain config'}`); + return; + } + const a = await client.listActors(); + if (a.ok) setActors(a.data); + showToast(`${actor.label} revoked on chain.`); go('audit'); } }; @@ -557,6 +708,16 @@ export function App() { +
brand
+ )} {toast.sticky && ( {!isMaster && } + {isMaster && onResetMaster && ( + + )} } /> @@ -144,6 +158,18 @@ export function ActorDetail({
actor_omni
{actor.omni} ({actor.omniHex})
+ {actor.role === 'master' && ( + <> +
account
+
+ {actor.accountType === 'p256account' && actor.accountAddress ? ( + <>{actor.accountAddress} · passkey P256Account (ERC-4337 · operatorMasterWallet) + ) : ( + not yet bound on chain — complete onboarding to register your master P256Account + )} +
+ + )}
derivation
{actor.derivation} (hard / HDKD)
device pubkey
{actor.devicePubkey} · K10 secp256k1
vendor
{actor.vendor}
diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx index dcdfd32c..78818811 100644 --- a/apps/parent-control/app/_components/pairing.tsx +++ b/apps/parent-control/app/_components/pairing.tsx @@ -16,6 +16,7 @@ export function PairingPage({ claiming, justPaired, onManage, + onUnpair, }: { requests: PairingRequest[]; actors: Actor[]; @@ -26,6 +27,7 @@ export function PairingPage({ claiming: boolean; justPaired: string | null; onManage?: (id: string) => void; + onUnpair?: (a: Actor) => void; }) { const [view, setView] = useState<'devices' | 'permissions'>('devices'); const [claimCode, setClaimCode] = useState(''); @@ -82,7 +84,7 @@ export function PairingPage({
Pairing request · {req.agent}
-
{req.vendor} · {req.requestedAt}
+
{req.vendor} · requested {req.requestedAt ? new Date(req.requestedAt * 1000).toLocaleString() : '—'}
action required @@ -90,6 +92,12 @@ export function PairingPage({
+ {/* DECLARED — self-reported by the runtime, NOT cryptographically + attested. Cosmetic context only; never a basis for trust. The + only verifiable identity is the attested column on the right. */} +
+ ⚠ declared by the runtime · self-reported, NOT attested +
device
{req.device}
machine
@@ -98,12 +106,24 @@ export function PairingPage({
{req.runtime}
-
pair-code
-
{req.pairCode}
+ {/* ATTESTED — the cryptographic device identity (proved by the + agent's pop_sig over its K10 key). #224: cross-check + device_key_hash + D_pub against the agent's `--request-pairing` + output before approving. pairing code + request id are broker- + minted handles (not attested, but tamper-evident on claim). */} +
+ ✓ attested cryptographic identity · cross-check on the agent +
+
device key hash · verify on agent
+
{req.deviceKeyHash || req.deviceKeyHashShort}
+
device public address · verify on agent
+
{req.dpubFull || req.dpub}
+
pairing code · matches the agent device
+
{req.pairCode || '—'}
+
request id · master handle
+
{req.id}
derivation
O_master{req.derivation}
-
D_pub
-
{req.dpub}
@@ -164,6 +184,15 @@ export function PairingPage({
active
{a.lastActive}
+ {a.status !== 'bad' && onUnpair && ( + + )} ))} diff --git a/apps/parent-control/app/_components/types.ts b/apps/parent-control/app/_components/types.ts index 5b367470..767e0d9d 100644 --- a/apps/parent-control/app/_components/types.ts +++ b/apps/parent-control/app/_components/types.ts @@ -19,6 +19,12 @@ export interface Actor { status: StatusKind; vendor: string; k11: boolean; + /** #225 E7: on-chain account address — the master's passkey P256Account + * (operatorMasterWallet), or an agent's K10 device omni. */ + accountAddress?: string; + /** "p256account" (bound smart-account master) | "device" (agent) | "none" + * (master not yet registered on chain — show the register CTA). */ + accountType?: string; children?: string[]; scope?: Record; paymentCap?: { perTx: number; daily: number; currency: string }; @@ -48,6 +54,10 @@ export interface CeremonyStep { label: string; sub: string; onchain?: boolean; + /** When set (e.g. "1 of 2"), renders a "Touch ID · " badge so the user + * expects the biometric prompt — the master onboarding fires TWO (create the + * passkey, then sign its on-chain registration), which surprises people. */ + touchId?: string; fn?: string; /** Optional real async work the runner awaits while this step is "running" * (e.g. the WebAuthn Touch ID at the §9 Stage-2 binding step). */ @@ -91,10 +101,15 @@ export interface PairingRequest { runtime: string; dpub: string; dpubFull: string; + // #224 — the cross-verifiable device identity: the agent's `--request-pairing` + // prints `device_key_hash`, so the operator confirms it matches before approving. + deviceKeyHash: string; + deviceKeyHashShort: string; pairCode: string; derivation: string; requested: RequestedPerm[]; - requestedAt: string; + /** Unix seconds the agent requested pairing (`created_at`). Formatted in the UI. */ + requestedAt: number; attestation: string; } diff --git a/apps/parent-control/app/globals.css b/apps/parent-control/app/globals.css index 241f14b9..bba8f590 100644 --- a/apps/parent-control/app/globals.css +++ b/apps/parent-control/app/globals.css @@ -679,6 +679,16 @@ a:hover { text-decoration-color: var(--ink); } font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--info); border: 1px solid var(--info); padding: 0 5px; } +.clog-touch { + font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; +} +.touchid-notice { + font-size: 11.5px; line-height: 1.55; color: var(--ink-dim); + border: 1px dashed var(--accent); background: var(--accent-soft); + padding: 9px 12px; margin-bottom: 14px; +} +.touchid-notice strong { color: var(--ink); } .clog-sub { font-size: 11px; color: var(--ink-dim); margin-top: 1px; } .clog-tx { font-size: 10.5px; color: var(--ok); margin-top: 3px; } diff --git a/apps/parent-control/lib/client/daemon.ts b/apps/parent-control/lib/client/daemon.ts index 8b1fde96..78801bf6 100644 --- a/apps/parent-control/lib/client/daemon.ts +++ b/apps/parent-control/lib/client/daemon.ts @@ -16,7 +16,10 @@ import type { K11EnrollBegin, K11EnrollFinishInput, K11EnrollResult, + RegisterMasterAssertion, + RegisterMasterResult, MasterMemoryEntry, + MasterResetResult, MemoryCategory, OnboardingState, PlantResult, @@ -262,6 +265,10 @@ export class DaemonBackend implements AgentKeysClient { return r.ok ? { ok: true, data: undefined } : r; } + async resetMaster(): Promise> { + return this.postJson('/v1/master/reset', {}); + } + async enrollK11Begin(input: { userName: string; userDisplayName: string }): Promise> { try { const resp = await fetch(`${this.baseUrl}/v1/k11/enroll/begin`, { @@ -326,6 +333,9 @@ export class DaemonBackend implements AgentKeysClient { credentialId: body.credential_id, registeredAt: body.registered_at_unix, chainTxHash: body.chain_tx_hash ?? undefined, + chain: body.chain ?? undefined, + registerUserOpHash: body.register_userop_hash ?? undefined, + registerAccount: body.register_account ?? undefined, }, }; } catch (e) { @@ -333,6 +343,30 @@ export class DaemonBackend implements AgentKeysClient { } } + // #225 E7: phase 2 of the master register. The browser passkey signed the + // register userOpHash (from enrollK11Finish); relay the assertion so the daemon + // lands handleOps and binds operatorMasterWallet = the master P256Account. + async registerMasterSubmit(assertion: RegisterMasterAssertion): Promise> { + try { + const resp = await fetch(`${this.baseUrl}/v1/master/register/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ assertion }), + }); + if (!resp.ok) { + const text = await resp.text(); + return { ok: false, status: unreachable(`register/submit returned ${resp.status}: ${text}`) }; + } + const body = await resp.json(); + return { + ok: true, + data: { ok: body.ok ?? true, txHash: body.tx_hash ?? undefined, account: body.account ?? undefined }, + }; + } catch (e) { + return { ok: false, status: unreachable(`register/submit fetch failed: ${(e as Error).message}`) }; + } + } + async listMemoryCategories(): Promise> { const r = await this.getJson<{ categories: ApiMemoryCategory[] }>('/v1/master/memory'); if (!r.ok) return r; @@ -489,6 +523,48 @@ export class DaemonBackend implements AgentKeysClient { return { ok: true, data: undefined }; } + // #225: decline a claimed pairing request — broker drops the pending row (J1, no Touch ID). + async declinePairing(requestId: string): Promise> { + const r = await this.postJson('/v1/agent/pairing/decline', { request_id: requestId }); + if (!r.ok) return r; + return { ok: true, data: undefined }; + } + + // #225 E7: after the on-chain accept lands, mark the binding BOUND so the broker drops + // it from pending (the accept/submit body has no request_id, so the broker can't do it + // itself). J1-gated, no Touch ID. Without this the accepted request keeps reappearing. + async ackPairing(requestId: string): Promise> { + const r = await this.postJson('/v1/agent/pairing/ack', { request_id: requestId }); + if (!r.ok) return r; + return { ok: true, data: undefined }; + } + + async acceptBuild(input: { + requestId: string; + services: string[]; + readOnly: boolean; + maxPerCall: string; + maxPerPeriod: string; + maxTotal: string; + periodSeconds: number; + }): Promise< + Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> + > { + return this.postJson('/v1/accept/build', { + request_id: input.requestId, + services: input.services, + read_only: input.readOnly, + max_per_call: input.maxPerCall, + max_per_period: input.maxPerPeriod, + max_total: input.maxTotal, + period_seconds: input.periodSeconds, + }); + } + + async acceptSubmit(body: unknown): Promise> { + return this.postJson('/v1/accept/submit', body); + } + async listCredentials(): Promise> { const r = await this.getJson<{ credentials: { service: string; category: string; sensitivity: 'safe' | 'sensitive' }[]; @@ -544,6 +620,9 @@ interface ApiActor { payment_cap?: { per_tx: number; daily: number; currency: string }; time_window?: { start: string; end: string; tz: string }; services?: string[]; + // #225 E7: on-chain account (master → P256Account address; agent → device omni). + account_address?: string | null; + account_type?: string; // "p256account" | "device" | "none" } interface ApiAuditEvent { @@ -585,6 +664,8 @@ function apiToActor(a: ApiActor): Actor { status: normalizeStatus(a.status), vendor: a.vendor, k11: a.k11, + accountAddress: a.account_address ?? undefined, + accountType: a.account_type ?? undefined, scope: a.scope as Actor['scope'], paymentCap: a.payment_cap ? { perTx: a.payment_cap.per_tx, daily: a.payment_cap.daily, currency: a.payment_cap.currency } diff --git a/apps/parent-control/lib/client/empty.ts b/apps/parent-control/lib/client/empty.ts index 8c8dbf9f..2a346e41 100644 --- a/apps/parent-control/lib/client/empty.ts +++ b/apps/parent-control/lib/client/empty.ts @@ -13,7 +13,10 @@ import type { K11EnrollBegin, K11EnrollFinishInput, K11EnrollResult, + RegisterMasterAssertion, + RegisterMasterResult, MasterMemoryEntry, + MasterResetResult, MemoryCategory, OnboardingState, PlantResult, @@ -56,6 +59,10 @@ export class EmptyBackend implements AgentKeysClient { return disconnected(); } + async resetMaster(): Promise> { + return disconnected(); + } + async listActors(): Promise> { return disconnected(); } @@ -124,6 +131,10 @@ export class EmptyBackend implements AgentKeysClient { return disconnected(); } + async registerMasterSubmit(_assertion: RegisterMasterAssertion): Promise> { + return disconnected(); + } + async listMemoryCategories(): Promise> { return disconnected(); } @@ -168,6 +179,32 @@ export class EmptyBackend implements AgentKeysClient { return disconnected(); } + async declinePairing(_requestId: string): Promise> { + return disconnected(); + } + + async ackPairing(_requestId: string): Promise> { + return disconnected(); + } + + async acceptBuild(_input: { + requestId: string; + services: string[]; + readOnly: boolean; + maxPerCall: string; + maxPerPeriod: string; + maxTotal: string; + periodSeconds: number; + }): Promise< + Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> + > { + return disconnected(); + } + + async acceptSubmit(_body: unknown): Promise> { + return disconnected(); + } + async listCredentials(): Promise> { return disconnected(); } diff --git a/apps/parent-control/lib/client/types.ts b/apps/parent-control/lib/client/types.ts index 4fa8dc86..d705c98c 100644 --- a/apps/parent-control/lib/client/types.ts +++ b/apps/parent-control/lib/client/types.ts @@ -56,6 +56,28 @@ export interface K11EnrollResult { credentialId: string; registeredAt: number; chainTxHash?: string; + /** #225 E7: "register-pending" (browser must sign + submit), "master-registered" + * (idempotent skip — already on chain), or "none". */ + chain?: string; + /** #225 E7: when chain === "register-pending", the userOpHash the browser passkey + * must sign (second Touch ID) and POST to register/submit. */ + registerUserOpHash?: string; + /** #225 E7: the deployed master P256Account address (operatorMasterWallet-to-be). */ + registerAccount?: string; +} + +/** #225 E7: the browser `get()` assertion over the register userOpHash. */ +export interface RegisterMasterAssertion { + authenticator_data: string; + client_data_json: string; + signature: string; + credential_id: string; +} + +export interface RegisterMasterResult { + ok: boolean; + txHash?: string; + account?: string; } export interface RevokeIntent { @@ -205,6 +227,26 @@ export interface OnboardingState { session?: string; } +/** On-chain half of `POST /v1/master/reset` (#225 E7) — the owner-gated resetMaster. */ +export interface MasterResetOnchain { + /** "reset" = operatorMasterWallet cleared this call; "skipped" = nothing to do / not wired; "failed" = on-chain unbind errored. */ + status: 'reset' | 'skipped' | 'failed'; + /** Present on "skipped" — "already-unbound" | "no-register-script-configured" | "no-operator-omni-known". */ + reason?: string; + /** Present on "failed" — the script/cast error (e.g. registry pre-VERSION-0.3 has no resetMaster). */ + error?: string; + tx_hash?: string; + operator_omni?: string; +} + +/** Result of `POST /v1/master/reset` (#225 E7). */ +export interface MasterResetResult { + ok: boolean; + /** Operator guidance — adapts to whether the on-chain unbind landed. */ + note?: string; + onchain?: MasterResetOnchain; +} + /** One deployed contract from `GET /v1/chain/info` (real address + explorer link). */ export interface ChainContract { name: string; @@ -312,6 +354,9 @@ export interface AgentKeysClient { enrollK11Begin(input: { userName: string; userDisplayName: string }): Promise>; enrollK11Finish(input: K11EnrollFinishInput): Promise>; + // #225 E7: phase 2 of the master register — submit the browser assertion over + // the register userOpHash → handleOps binds operatorMasterWallet = the P256Account. + registerMasterSubmit(assertion: RegisterMasterAssertion): Promise>; // §1 onboarding — real email magic-link verify (broker-backed, W1). The // browser starts it, then polls until the operator clicks the link. @@ -320,6 +365,13 @@ export interface AgentKeysClient { // Real "logged in" state, held by the daemon (replaces the ak_onboarded flag). getOnboardingState(): Promise>; logout(): Promise>; + // #225 E7: fully unbind the master so the operator can re-onboard a fresh passkey — + // used when the bound master passkey was deleted in the OS password manager. Clears + // BOTH the LOCAL binding (registered_master + persisted coords) AND the ON-CHAIN + // operatorMasterWallet (owner-gated resetMaster via the deployer). `onchain` reports + // whether the on-chain unbind landed; `note` carries the operator guidance. Cannot + // delete the OS passkey (WebAuthn) — the operator does that manually. + resetMaster(): Promise>; // §2 — master memory (#201 Phase 4). The LIST resolves CATEGORIES from the // durable, master-only Config taxonomy (zero memory decryption, survives daemon @@ -365,6 +417,29 @@ export interface AgentKeysClient { // on chain for the binding's request_id, then acks the broker. (The Touch-ID scope // grant is the separate grantScope step, P.3.) registerPairing(requestId: string): Promise>; + // Decline a claimed pairing request (J1-gated, NO Touch ID) — the daemon tells the + // broker to drop the pending rendezvous row so it stops reappearing on refresh. + declinePairing(requestId: string): Promise>; + // #225 E7: after the on-chain accept lands, mark the binding bound so the broker drops + // it from pending (the accept/submit body carries no request_id). J1-gated, no Touch ID. + ackPairing(requestId: string): Promise>; + + // #225 E7 — the Touch-ID-gated accept. `acceptBuild` → broker assembles the + // sponsored executeBatch([registerAgentDevice, setScope]) UserOp + returns the + // userOpHash the browser K11-signs; `acceptSubmit` relays the signed op (+ the + // assertion) → EntryPoint.handleOps. + acceptBuild(input: { + requestId: string; + services: string[]; + readOnly: boolean; + maxPerCall: string; + maxPerPeriod: string; + maxTotal: string; + periodSeconds: number; + }): Promise< + Result<{ user_op: Record; user_op_hash: string; entry_point: string; chain_id: number }> + >; + acceptSubmit(body: unknown): Promise>; // §credentials data class (#207). The SAME abstraction as memory: list the // master's stored credential services (categorized via the catalog) and vault diff --git a/apps/parent-control/lib/debug.ts b/apps/parent-control/lib/debug.ts new file mode 100644 index 00000000..60b8dbc2 --- /dev/null +++ b/apps/parent-control/lib/debug.ts @@ -0,0 +1,16 @@ +// Greppable structured console logging for the passkey ↔ master-account flow. +// +// Touch-ID / "wrong passkey" / SIG_VALIDATION_FAILED bugs are diagnosed by comparing +// the master ACCOUNT and the signing PASSKEY (WebAuthn credential id) across the two +// moments they must agree — **onboarding** (which passkey the account was bound with) +// vs **accept** (which passkey actually signed). Filter the browser console by +// `[agentkeys]` to see the whole trail; if `boundCredentialId` (onboarding) ≠ +// `signingCredentialId` (accept), or the `account` differs, that's the bug. +export function akLog(event: string, data: Record = {}): void { + try { + // eslint-disable-next-line no-console + console.info(`[agentkeys] ${event}`, data); + } catch { + /* console may be unavailable (SSR / locked-down env) — logging is best-effort */ + } +} diff --git a/apps/parent-control/lib/webauthn.ts b/apps/parent-control/lib/webauthn.ts index 03d6839c..b0368240 100644 --- a/apps/parent-control/lib/webauthn.ts +++ b/apps/parent-control/lib/webauthn.ts @@ -80,6 +80,80 @@ export function webauthnAvailable(): boolean { ); } +function hexToBytes(hex: string): Uint8Array { + const h = hex.replace(/^0x/, ''); + const out = new Uint8Array(Math.floor(h.length / 2)); + for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); + return out; +} + +// #225 E7 — the raw WebAuthn assertion over the accept UserOp hash. The broker +// encodes these into the P256Account UserOp signature (abi.encode(credIdHash, +// authData, clientDataJSON, loc, r, s); same format as the Rust CLI's +// `k11 webauthn-userop-sign`) before EntryPoint.handleOps. +export interface AcceptAssertion { + authenticator_data: string; // base64url + client_data_json: string; // base64url + signature: string; // base64url (DER ECDSA) + credential_id: string; // base64url +} + +/** Touch ID over the accept `userOpHash`. The hash IS the WebAuthn challenge — + * raw, no sha256 wrap (arch.md §22b.1) — so the passkey signs the full intent. */ +export async function getAssertionOverHash( + userOpHashHex: string, + allowCredentialIdsB64Url?: string[], +): Promise { + const challenge = hexToBytes(userOpHashHex); + // Pin the master passkey so the browser auto-selects it (and the user can't + // accidentally sign with the wrong key → on-chain rejection). Empty/absent ⇒ + // the browser shows its full picker (legacy behavior). + const allowCredentials = (allowCredentialIdsB64Url ?? []) + .filter(Boolean) + .map((id) => ({ type: 'public-key' as const, id: base64UrlDecode(id) })); + const cred = (await navigator.credentials.get({ + publicKey: { + challenge, + userVerification: 'required', + timeout: 60_000, + ...(allowCredentials.length ? { allowCredentials } : {}), + }, + })) as PublicKeyCredential | null; + if (!cred) throw new Error('no assertion (Touch ID cancelled)'); + const a = cred.response as AuthenticatorAssertionResponse; + return { + authenticator_data: base64UrlEncode(a.authenticatorData), + client_data_json: base64UrlEncode(a.clientDataJSON), + signature: base64UrlEncode(a.signature), + credential_id: base64UrlEncode(cred.rawId), + }; +} + +/** + * #225 E7 — best-effort check that the master passkey still EXISTS (the operator may + * have deleted it in the OS password manager → System Settings ▸ Passwords). WebAuthn + * has no SILENT existence API (privacy by design), so this does a real `get()` and WILL + * prompt Touch ID. Returns true if the credential signs, false if the authenticator + * reports no such credential (NotAllowedError) — or the user cancels. Use only when an + * explicit check is worth a prompt (before "reset master", or after an accept fails). + */ +export async function masterPasskeyPresent(credentialIdB64Url: string): Promise { + if (!webauthnAvailable() || !credentialIdB64Url) return false; + try { + const cred = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ type: 'public-key', id: base64UrlDecode(credentialIdB64Url) }], + userVerification: 'discouraged', + timeout: 60_000, + }, + }); + return !!cred; + } catch { + return false; // NotAllowedError ⇒ no matching credential (deleted) or cancelled + } +} + export async function platformAuthenticatorAvailable(): Promise { if (!webauthnAvailable()) return false; try { diff --git a/crates/agentkeys-backend-client/src/client.rs b/crates/agentkeys-backend-client/src/client.rs index 318ed0bf..a001801e 100644 --- a/crates/agentkeys-backend-client/src/client.rs +++ b/crates/agentkeys-backend-client/src/client.rs @@ -13,8 +13,9 @@ use reqwest::Client; use crate::protocol::{ AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, CredFetchBody, CredFetchInput, CredFetchResp, - CredFetchResult, MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, - MemoryPutInput, MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredFetchResult, CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, + MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, + MemoryPutResult, RevokeResult, ENVELOPE_VERSION, }; #[derive(thiserror::Error, Debug)] @@ -314,6 +315,41 @@ impl BackendClient { }) } + /// `POST /v1/cred/store` — vault a credential (#216). The signed cap carries + /// the `service` + data-class; the per-actor STS creds (vault role) scope the + /// S3 PUT to `bots//credentials/`. The plaintext is base64 in the body + /// (the worker encrypts with the K3 KEK). + pub async fn cred_store(&self, input: CredStoreInput) -> Result { + let url = format!("{}/v1/cred/store", self.cred()?); + let mut req = self.client.post(&url).json(&CredStoreBody { + cap: input.cap, + plaintext_b64: input.plaintext_b64, + }); + if let Some(headers) = self.sts_headers(self.vault_role_arn.as_ref()).await? { + for (k, v) in headers { + req = req.header(k, v); + } + } + let resp = req + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + let parsed: CredStoreResp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + Ok(CredStoreResult { + ok: parsed.ok, + s3_key: parsed.s3_key, + envelope_size: parsed.envelope_size, + }) + } + /// `POST /v1/audit/append/v2` — append a signed audit envelope. `ts_unix` /// is stamped here; `intent_commitment` is always `None` on this side /// (the broker computes it). diff --git a/crates/agentkeys-backend-client/src/fixtures.rs b/crates/agentkeys-backend-client/src/fixtures.rs index 18ea0044..77ce90f3 100644 --- a/crates/agentkeys-backend-client/src/fixtures.rs +++ b/crates/agentkeys-backend-client/src/fixtures.rs @@ -18,7 +18,8 @@ use serde_json::{json, Value}; use crate::protocol::{ - AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, MemoryGetBody, MemoryPutBody, + AcceptAssertion, AuditAppendV2, BrokerCapRequest, BuildAcceptUserOpRequest, ConfigGetBody, + ConfigPutBody, MemoryGetBody, MemoryPutBody, SubmitAcceptUserOpRequest, WireUserOp, ENVELOPE_VERSION, }; @@ -66,6 +67,39 @@ pub fn canonical_fixtures() -> Vec { intent_text: Some("".into()), intent_commitment: None, }; + let build_accept = BuildAcceptUserOpRequest { + operator_omni: "0x".into(), + actor_omni: "0x".into(), + device_key_hash: "0x".into(), + agent_pop_sig: "0x".into(), + link_code_redemption: "0x".into(), + services: vec!["memory:".into()], + read_only: true, + max_per_call: "0".into(), + max_per_period: "0".into(), + max_total: "0".into(), + period_seconds: 0, + }; + let wire_user_op = WireUserOp { + sender: "0x".into(), + nonce: "0x".into(), + init_code: "0x".into(), + call_data: "0x".into(), + account_gas_limits: "0x".into(), + pre_verification_gas: "0x".into(), + gas_fees: "0x".into(), + paymaster_and_data: "0x".into(), + signature: "0x".into(), + }; + let submit_accept = SubmitAcceptUserOpRequest { + user_op: wire_user_op.clone(), + assertion: AcceptAssertion { + authenticator_data: "".into(), + client_data_json: "".into(), + signature: "".into(), + credential_id: "".into(), + }, + }; vec![ Fixture { name: "cap_mint_request", @@ -91,6 +125,18 @@ pub fn canonical_fixtures() -> Vec { name: "audit_append_v2", body: serde_json::to_value(&audit).expect("audit serializes"), }, + Fixture { + name: "build_accept_userop_request", + body: serde_json::to_value(&build_accept).expect("build_accept serializes"), + }, + Fixture { + name: "wire_user_op", + body: serde_json::to_value(&wire_user_op).expect("wire_user_op serializes"), + }, + Fixture { + name: "submit_accept_userop_request", + body: serde_json::to_value(&submit_accept).expect("submit_accept serializes"), + }, ] } @@ -174,4 +220,50 @@ mod tests { ] ); } + + #[test] + fn build_accept_userop_request_keys_frozen() { + assert_eq!( + keys_of("build_accept_userop_request"), + vec![ + "actor_omni", + "agent_pop_sig", + "device_key_hash", + "link_code_redemption", + "max_per_call", + "max_per_period", + "max_total", + "operator_omni", + "period_seconds", + "read_only", + "services", + ] + ); + } + + #[test] + fn wire_user_op_keys_frozen() { + assert_eq!( + keys_of("wire_user_op"), + vec![ + "account_gas_limits", + "call_data", + "gas_fees", + "init_code", + "nonce", + "paymaster_and_data", + "pre_verification_gas", + "sender", + "signature", + ] + ); + } + + #[test] + fn submit_accept_userop_request_keys_frozen() { + assert_eq!( + keys_of("submit_accept_userop_request"), + vec!["assertion", "user_op"] + ); + } } diff --git a/crates/agentkeys-backend-client/src/lib.rs b/crates/agentkeys-backend-client/src/lib.rs index f9e5ab01..2929719d 100644 --- a/crates/agentkeys-backend-client/src/lib.rs +++ b/crates/agentkeys-backend-client/src/lib.rs @@ -20,6 +20,7 @@ pub use protocol::{ normalize_omni_0x, service_memory, AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, ConfigGetBody, ConfigGetResp, ConfigPutBody, CredFetchBody, CredFetchInput, CredFetchResp, CredFetchResult, - MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, - MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, MemoryGetInput, + MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, MemoryPutResult, + RevokeResult, ENVELOPE_VERSION, }; diff --git a/crates/agentkeys-backend-client/src/protocol.rs b/crates/agentkeys-backend-client/src/protocol.rs index 19006237..4fb7c67f 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-backend-client/src/protocol.rs @@ -191,6 +191,23 @@ pub struct CredFetchResp { pub plaintext_b64: String, } +/// Cred-worker `/v1/cred/store` request body. Mirrors +/// `agentkeys_worker_creds::handlers::StoreRequest` — the signed cap (the +/// credential `service` rides INSIDE the cap payload) plus the base64 plaintext. +/// The worker encrypts (K3 KEK) + S3-PUTs `bots//credentials/.enc`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreBody { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CredStoreResp { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + // ── audit worker (`/v1/audit/append/v2`) ──────────────────────────────────── /// Audit envelope version, pinned to `agentkeys_core::audit::ENVELOPE_VERSION`. @@ -260,6 +277,19 @@ pub struct CredFetchResult { pub plaintext_b64: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreInput { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreResult { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditAppendInput { pub operator_omni: String, @@ -286,6 +316,95 @@ pub struct RevokeResult { pub note: Option, } +// ── #225 / #164 E7 — on-chain K11-gated agent accept (sponsored executeBatch) ─ +// +// The accept becomes ONE P256Account.executeBatch UserOp that lands the device +// binding (P.2) + the scope grant (P.3) atomically, gated by one master K11 +// signature. Two broker endpoints, J1_master-gated: `build` assembles + co-signs +// the sponsored op and returns the userOpHash; the daemon K11-signs it; `submit` +// relays the signed op to `EntryPoint.handleOps`. The broker mirrors these shapes +// server-side (it doesn't depend on this crate); the frozen key-set tests in +// `crate::fixtures` pin them so the two sides can't drift. + +/// Daemon → broker `POST /v1/accept/build`. The granted scope (`services` + +/// caps) is what the master approved in the pairing UI; the register fields bind +/// the agent device. `operator_omni`/`actor_omni` are `0x`-omni +/// ([`normalize_omni_0x`]); the `u128` caps ride as decimal strings (wire-safe +/// past 2^53; `"0"` = unset). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptUserOpRequest { + pub operator_omni: String, + pub actor_omni: String, + pub device_key_hash: String, + pub agent_pop_sig: String, + pub link_code_redemption: String, + pub services: Vec, + pub read_only: bool, + pub max_per_call: String, + pub max_per_period: String, + pub max_total: String, + pub period_seconds: u32, +} + +/// ERC-4337 v0.7 `PackedUserOperation`, hex-encoded for the wire. Mirrors +/// `agentkeys_broker_server::sponsor::PackedUserOp`; the daemon fills `signature` +/// with the master's K11 assertion over `user_op_hash`, then returns the whole op +/// to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireUserOp { + pub sender: String, + pub nonce: String, + pub init_code: String, + pub call_data: String, + pub account_gas_limits: String, + pub pre_verification_gas: String, + pub gas_fees: String, + pub paymaster_and_data: String, + pub signature: String, +} + +/// Broker → daemon response to `/v1/accept/build`. The master signs +/// `user_op_hash` (the `EntryPoint.getUserOpHash` of `user_op`) with K11. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptUserOpResponse { + pub user_op: WireUserOp, + pub user_op_hash: String, + pub entry_point: String, + pub chain_id: u64, +} + +/// The raw browser WebAuthn assertion (base64url) over the accept `user_op_hash`, +/// as `apps/parent-control/lib/webauthn.ts::getAssertionOverHash` emits it. The +/// broker encodes it into the P256Account UserOp signature; it derives the +/// master's `credIdHash` from `operator_omni`, so the raw `credential_id` here is +/// for cross-checks/audit, not the signer key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptAssertion { + pub authenticator_data: String, + pub client_data_json: String, + pub signature: String, + pub credential_id: String, +} + +/// Daemon → broker `POST /v1/accept/submit` — the op from `build` + the master's +/// browser WebAuthn `assertion` over `user_op_hash`. The broker encodes the +/// assertion into `user_op.signature` (binding the `credIdHash` it derives from +/// the verified J1 session omni, not a body field), then relays to +/// `EntryPoint.handleOps` (Stage B). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitAcceptUserOpRequest { + pub user_op: WireUserOp, + pub assertion: AcceptAssertion, +} + +/// Broker → daemon response to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitAcceptUserOpResponse { + pub ok: bool, + pub tx_hash: String, + pub block_number: String, +} + // ── shared protocol helpers (the omni-normalization bug site, centralized) ─── /// Build the signed cap **service** string for a memory namespace — diff --git a/crates/agentkeys-broker-server/src/accept_assertion.rs b/crates/agentkeys-broker-server/src/accept_assertion.rs new file mode 100644 index 00000000..8d6c24f1 --- /dev/null +++ b/crates/agentkeys-broker-server/src/accept_assertion.rs @@ -0,0 +1,158 @@ +//! #225 / #164 E7 — decode a browser WebAuthn assertion into the P256Account +//! UserOp signature (the accept-submit "final mile"). +//! +//! Mirrors the mainnet-proven CLI path +//! (`agentkeys-cli::k11_webauthn::extract_chain_assertion`) + +//! `harness/scripts/erc4337-register-master.sh`'s +//! `cast abi-encode "x(bytes32,bytes,bytes,uint256,uint256,uint256)"`, reusing the +//! golden-tested `agentkeys_core::erc4337::{encode_webauthn_signature, +//! master_cred_id_hash}`. The `p256` DER decode lives here (the broker carries the +//! `p256` dep; core does not), so this is the broker-side wrapper around core's +//! ABI encoder. + +use agentkeys_core::erc4337::{encode_webauthn_signature, master_cred_id_hash}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use p256::ecdsa::Signature; +use serde::{Deserialize, Serialize}; + +/// The raw browser WebAuthn assertion (base64url, exactly as +/// `apps/parent-control/lib/webauthn.ts::getAssertionOverHash` emits it) over an +/// accept `userOpHash`. The `credential_id` is kept for cross-checks/audit — it +/// is **not** the signer key; the master's `P256Account` signer is keyed by the +/// operator-derived [`master_cred_id_hash`], not `keccak(rawId)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserAssertion { + pub authenticator_data: String, // base64url + pub client_data_json: String, // base64url + pub signature: String, // base64url DER ECDSA (P-256) + pub credential_id: String, // base64url rawId +} + +fn b64u(field: &str, s: &str) -> Result, String> { + URL_SAFE_NO_PAD + .decode(s.trim()) + .map_err(|e| format!("{field} base64url: {e}")) +} + +/// Decode + ABI-encode the browser assertion into the P256Account UserOp +/// signature, binding the master's operator-derived `credIdHash`: +/// 1. `cred_id_hash = master_cred_id_hash(operator_omni)` (NOT `keccak(rawId)`). +/// 2. `(r, s) = DER-decode(signature)` → 32-byte big-endian each (p256; +/// mirrors the mainnet-proven `extract_chain_assertion` — no low-s renorm, +/// the authenticator already emits low-s for P-256/WebAuthn). +/// 3. `challenge_location` = byte offset of the challenge value in +/// `clientDataJSON` (right after the literal `"challenge":"`). +/// 4. `encode_webauthn_signature(cred_id_hash, authData, clientDataJSON, loc, r, s)`. +pub fn encode_browser_assertion_signature( + a: &BrowserAssertion, + operator_omni: &[u8; 32], +) -> Result, String> { + let authenticator_data = b64u("authenticator_data", &a.authenticator_data)?; + let client_data_json = b64u("client_data_json", &a.client_data_json)?; + let signature_der = b64u("signature", &a.signature)?; + + // The signer key is operator-derived (the value the account was created with). + let cred_id_hash = master_cred_id_hash(operator_omni); + + // DER → (r, s). p256 `Signature::to_bytes()` = r(32) ‖ s(32), big-endian. + let sig = + Signature::from_der(&signature_der).map_err(|e| format!("signature DER → (r,s): {e}"))?; + let rs = sig.to_bytes(); + if rs.len() != 64 { + return Err(format!("sig.to_bytes() = {} bytes, expected 64", rs.len())); + } + let mut r = [0u8; 32]; + let mut s = [0u8; 32]; + r.copy_from_slice(&rs[0..32]); + s.copy_from_slice(&rs[32..64]); + + let cdj = + std::str::from_utf8(&client_data_json).map_err(|e| format!("clientDataJSON utf-8: {e}"))?; + const NEEDLE: &str = "\"challenge\":\""; + let challenge_location = cdj + .find(NEEDLE) + .map(|p| p + NEEDLE.len()) + .ok_or_else(|| format!("clientDataJSON missing {NEEDLE:?}"))?; + + Ok(encode_webauthn_signature( + &cred_id_hash, + &authenticator_data, + &client_data_json, + challenge_location as u128, + &r, + &s, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{signature::Signer, SigningKey}; + + fn b64(b: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(b) + } + + /// Round-trip: a real P-256 DER signature + a real clientDataJSON decode into + /// the exact `encode_webauthn_signature` layout. Asserts the six head words: + /// [credIdHash][offAuth][offCdj][challengeLoc][r][s]. + #[test] + fn decodes_a_real_p256_assertion_into_the_userop_signature() { + let sk = SigningKey::random(&mut rand_core::OsRng); + // The decoder does NOT verify the sig (the chain does) — it only extracts + // (r, s) from DER — so signing arbitrary bytes is a faithful unit test. + let sig: Signature = sk.sign(b"any-message"); + let der = sig.to_der(); + let rs = sig.to_bytes(); + + let auth_data = vec![0xABu8; 37]; // ≥ 37 bytes (rpIdHash + flags + signCount) + let cdj = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://x"}"#; + let challenge_loc = std::str::from_utf8(cdj) + .unwrap() + .find("\"challenge\":\"") + .unwrap() + + "\"challenge\":\"".len(); + + let omni = [0x42u8; 32]; + let a = BrowserAssertion { + authenticator_data: b64(&auth_data), + client_data_json: b64(cdj), + signature: b64(der.as_bytes()), + credential_id: b64(b"raw-credential-id"), + }; + let out = encode_browser_assertion_signature(&a, &omni).unwrap(); + + // Head layout (each 32-byte word): credIdHash | offAuth | offCdj | loc | r | s. + assert_eq!(&out[0..32], &master_cred_id_hash(&omni)); + let word_u128 = |n: u128| { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&n.to_be_bytes()); + w + }; + assert_eq!(&out[32..64], &word_u128(6 * 32)); // off_auth = head = 6 words + assert_eq!(&out[96..128], &word_u128(challenge_loc as u128)); + assert_eq!(&out[128..160], &rs[0..32]); // r + assert_eq!(&out[160..192], &rs[32..64]); // s + // The tail carries the authenticatorData + clientDataJSON (length-prefixed). + assert!(out.len() > 6 * 32 + auth_data.len() + cdj.len()); + } + + #[test] + fn rejects_bad_base64_and_missing_challenge() { + let omni = [0u8; 32]; + let mut a = BrowserAssertion { + authenticator_data: "!!notb64".into(), + client_data_json: "e30".into(), + signature: "AA".into(), + credential_id: "AA".into(), + }; + assert!(encode_browser_assertion_signature(&a, &omni).is_err()); + + // valid b64 but clientDataJSON has no challenge field, and signature isn't DER. + a.authenticator_data = URL_SAFE_NO_PAD.encode([0u8; 37]); + a.client_data_json = URL_SAFE_NO_PAD.encode(br#"{"type":"webauthn.get"}"#); + a.signature = URL_SAFE_NO_PAD.encode([0u8; 8]); // not a valid DER sig + assert!(encode_browser_assertion_signature(&a, &omni).is_err()); + } +} diff --git a/crates/agentkeys-broker-server/src/handlers/accept.rs b/crates/agentkeys-broker-server/src/handlers/accept.rs new file mode 100644 index 00000000..76f16d1b --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/accept.rs @@ -0,0 +1,803 @@ +//! #225 / #164 E7 — the broker `/v1/accept/*` flow (the Touch-ID-gated agent accept). +//! +//! The accept becomes ONE sponsored `P256Account.executeBatch([registerAgentDevice, +//! setScope])` UserOp gated by the master's K11 Touch ID. Two J1_master-gated routes: +//! `/v1/accept/build` assembles the op + returns the `userOpHash` the browser passkey +//! signs; `/v1/accept/submit` relays the signed op to `EntryPoint.handleOps`. +//! +//! **Slice 1 (this file):** the `/v1/accept/build` request type + the pure parse from +//! the wire request into the typed `agentkeys_core::erc4337` structs that the +//! sponsored-UserOp composer (`crate::sponsored_accept::assemble_accept_userop`) +//! consumes. The axum handler — J1 auth (mirroring `handlers::cap::mint_cap`), chain +//! reads of `SidecarRegistry.operatorMasterWallet` + `EntryPoint.getNonce`, the broker +//! co-sign, and the `handleOps` submit — builds on this in the next slices. + +use agentkeys_core::erc4337::{AgentRegister, ScopeGrant}; +use serde::Deserialize; + +/// Broker-side mirror of `agentkeys_backend_client::protocol::BuildAcceptUserOpRequest` +/// (the broker doesn't depend on that crate; the frozen key-set test there pins the +/// shape). `POST /v1/accept/build` body, J1_master-gated. +#[derive(Debug, Clone, Deserialize)] +pub struct BuildAcceptRequest { + pub operator_omni: String, + pub actor_omni: String, + pub device_key_hash: String, + pub agent_pop_sig: String, + pub link_code_redemption: String, + pub services: Vec, + pub read_only: bool, + pub max_per_call: String, + pub max_per_period: String, + pub max_total: String, + pub period_seconds: u32, +} + +/// Parse the wire request into the typed register + scope-grant args. A scope +/// `service` string becomes a `bytes32` via `keccak256(lowercase(service))` — the +/// SAME hash `heima-scope-set.sh` writes, so a service id is byte-identical on every +/// path (the terminology-source-of-truth rule, at the encoding level). The `u128` +/// caps ride as decimal strings (wire-safe past 2^53). +pub fn parse_register_and_grant( + req: &BuildAcceptRequest, +) -> Result<(AgentRegister, ScopeGrant), String> { + let h32 = |s: &str, name: &str| -> Result<[u8; 32], String> { + let b = hex::decode(s.trim_start_matches("0x")).map_err(|e| format!("{name} hex: {e}"))?; + b.try_into().map_err(|_| format!("{name} must be 32 bytes")) + }; + let raw = |s: &str, name: &str| -> Result, String> { + hex::decode(s.trim_start_matches("0x")).map_err(|e| format!("{name} hex: {e}")) + }; + let cap = |s: &str, name: &str| -> Result { + s.parse::().map_err(|e| format!("{name}: {e}")) + }; + + let register = AgentRegister { + device_key_hash: h32(&req.device_key_hash, "device_key_hash")?, + operator_omni: h32(&req.operator_omni, "operator_omni")?, + actor_omni: h32(&req.actor_omni, "actor_omni")?, + link_code_redemption: raw(&req.link_code_redemption, "link_code_redemption")?, + agent_pop_sig: raw(&req.agent_pop_sig, "agent_pop_sig")?, + }; + let services: Vec<[u8; 32]> = req + .services + .iter() + .map(|s| agentkeys_core::device_crypto::keccak256(s.to_lowercase().as_bytes())) + .collect(); + let grant = ScopeGrant { + services, + read_only: req.read_only, + max_per_call: cap(&req.max_per_call, "max_per_call")?, + max_per_period: cap(&req.max_per_period, "max_per_period")?, + max_total: cap(&req.max_total, "max_total")?, + period_seconds: req.period_seconds, + }; + Ok((register, grant)) +} + +// ─── slice 2: the /v1/accept/build handler ────────────────────────────────── + +use crate::sponsored_accept::{assemble_accept_userop, AcceptUserOpParams, BuildAcceptResponse}; +use crate::state::SharedState; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::Json; +use k256::ecdsa::SigningKey; + +// Gas defaults (named constants per the no-hardcoded-values rule; override via the +// matching ACCEPT_* env vars). +// +// verificationGasLimit = 1.5M: the account's validateUserOp does an ON-CHAIN P256 +// (WebAuthn) signature verify, which is gas-heavy on Heima (pure-Solidity / no cheap +// precompile). The cap MUST cover it — at 600k the verify ran out of gas INSIDE the +// account's `try checkUserOpSignature catch { SIG_FAIL }`, so the catch mapped the OOG +// to SIG_VALIDATION_FAILED and handleOps reverted AA24 ("wrong passkey" — but actually +// gas starvation; #225). 1.5M is the value the working passkey REGISTER UserOp uses. +// +// maxFeePerGas = 40 gwei: Heima's base fee is ~25 gwei, so the old 2 gwei was below +// base fee (the userOp couldn't pay actual gas). 40 gwei clears base + priority AND +// keeps the max prefund (sum of gas limits × maxFee ≈ 0.15 HEI) under the paymaster's +// 0.2 HEI EntryPoint deposit. (A future hardening reads the live base fee + buffers.) +const DEF_VERIFICATION_GAS_LIMIT: u128 = 1_500_000; +const DEF_CALL_GAS_LIMIT: u128 = 2_000_000; +const DEF_PRE_VERIFICATION_GAS: u128 = 100_000; +const DEF_MAX_PRIORITY_FEE: u128 = 1_000_000_000; +const DEF_MAX_FEE: u128 = 40_000_000_000; +const DEF_PAYMASTER_VERIFICATION_GAS: u128 = 200_000; +const DEF_PAYMASTER_POST_OP_GAS: u128 = 50_000; +const SPONSOR_WINDOW_SECS: u64 = 3600; + +/// Sponsor + chain config the build handler needs beyond the request, read from the +/// broker process env (wired by setup-broker-host.sh). All addresses 20-byte. +pub struct AcceptConfig { + pub rpc_url: String, + pub chain_id: u64, + pub entry_point: [u8; 20], + /// `Some` = sponsored (VerifyingPaymaster); `None` = unsponsored direct + /// `handleOps` (the default — the VerifyingPaymaster is not deployed). + pub paymaster: Option<[u8; 20]>, + pub broker_signer: [u8; 20], + pub registry: [u8; 20], + pub scope: [u8; 20], + pub account_gas_limits: [u8; 32], + pub pre_verification_gas: [u8; 32], + pub gas_fees: [u8; 32], + pub paymaster_verification_gas_limit: u128, + pub paymaster_post_op_gas_limit: u128, +} + +fn u256_word(n: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&n.to_be_bytes()); + w +} + +fn addr20(hex_s: &str, name: &str) -> Result<[u8; 20], String> { + let b = + hex::decode(hex_s.trim().trim_start_matches("0x")).map_err(|e| format!("{name}: {e}"))?; + b.try_into() + .map_err(|_| format!("{name} must be a 20-byte address")) +} + +/// Profile-aware env read: `BASE_` (e.g. `SIDECAR_REGISTRY_ADDRESS_HEIMA`), +/// falling back to the bare `BASE` — the same convention the operator env uses. +fn env_profile(base: &str) -> Result { + let p = std::env::var("AGENTKEYS_CHAIN") + .unwrap_or_else(|_| "heima".into()) + .to_uppercase() + .replace('-', "_"); + std::env::var(format!("{base}_{p}")) + .or_else(|_| std::env::var(base)) + .map_err(|_| format!("env {base}[_{p}] not set")) +} + +/// Load the chain config + the broker submitter key from env. +/// +/// `BROKER_SPONSOR_SIGNER_KEY` (hex secp256k1) is the broker EVM identity that +/// fronts the outer `EntryPoint.handleOps` tx (and, sponsored only, co-signs the +/// paymaster). **Required** — it's the funded submitter EOA. +/// +/// `PAYMASTER_ADDRESS` is **optional**: set ⇒ sponsored (VerifyingPaymaster); +/// unset ⇒ **unsponsored** direct `handleOps` (the default — the paymaster isn't +/// deployed; gas comes from the account's EntryPoint deposit, the submitter is +/// the `handleOps` beneficiary). `BROKER_SPONSOR_SIGNER_ADDRESS` is optional too +/// — it defaults to the submitter key's own address (the beneficiary). +pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> { + let rpc_url = std::env::var("AGENTKEYS_CHAIN_RPC_HTTP") + .map_err(|_| "env AGENTKEYS_CHAIN_RPC_HTTP not set".to_string())?; + let chain_id: u64 = env_profile("AGENTKEYS_CHAIN_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(212_013); + let key_hex = std::env::var("BROKER_SPONSOR_SIGNER_KEY") + .map_err(|_| "env BROKER_SPONSOR_SIGNER_KEY not set".to_string())?; + let key_bytes = hex::decode(key_hex.trim().trim_start_matches("0x")) + .map_err(|e| format!("BROKER_SPONSOR_SIGNER_KEY hex: {e}"))?; + let broker_sk = SigningKey::from_slice(&key_bytes) + .map_err(|e| format!("BROKER_SPONSOR_SIGNER_KEY invalid: {e}"))?; + + // Optional paymaster: present ⇒ sponsored; absent ⇒ unsponsored (default). + let paymaster = match env_profile("PAYMASTER_ADDRESS") { + Ok(s) => Some(addr20(&s, "PAYMASTER_ADDRESS")?), + Err(_) => None, + }; + // Beneficiary / co-sign address: explicit, else the submitter key's address. + let broker_signer = match env_profile("BROKER_SPONSOR_SIGNER_ADDRESS") { + Ok(s) => addr20(&s, "BROKER_SPONSOR_SIGNER_ADDRESS")?, + Err(_) => { + let derived = agentkeys_core::device_crypto::evm_address( + &k256::ecdsa::VerifyingKey::from(&broker_sk), + ); + addr20(&derived, "derived broker submitter address")? + } + }; + + let cfg = AcceptConfig { + rpc_url, + chain_id, + entry_point: addr20(&env_profile("ENTRYPOINT_ADDRESS")?, "ENTRYPOINT_ADDRESS")?, + paymaster, + broker_signer, + registry: addr20( + &env_profile("SIDECAR_REGISTRY_ADDRESS")?, + "SIDECAR_REGISTRY_ADDRESS", + )?, + scope: addr20( + &env_profile("SCOPE_CONTRACT_ADDRESS")?, + "SCOPE_CONTRACT_ADDRESS", + )?, + account_gas_limits: crate::sponsor::pack_u128_pair( + DEF_VERIFICATION_GAS_LIMIT, + DEF_CALL_GAS_LIMIT, + ), + pre_verification_gas: u256_word(DEF_PRE_VERIFICATION_GAS), + gas_fees: crate::sponsor::pack_u128_pair(DEF_MAX_PRIORITY_FEE, DEF_MAX_FEE), + paymaster_verification_gas_limit: DEF_PAYMASTER_VERIFICATION_GAS, + paymaster_post_op_gas_limit: DEF_PAYMASTER_POST_OP_GAS, + }; + Ok((cfg, broker_sk)) +} + +/// **PURE** — assemble the `/v1/accept/build` response from the request + chain reads +/// (master account + nonce) + config + the broker co-sign key. The axum handler does +/// the auth + eth_call reads + key load, then calls this. +pub fn build_accept_response( + req: &BuildAcceptRequest, + master_account: [u8; 20], + nonce: [u8; 32], + cfg: &AcceptConfig, + broker_sk: &SigningKey, + valid_until: u64, +) -> Result { + let (register, grant) = parse_register_and_grant(req)?; + let params = AcceptUserOpParams { + entry_point: cfg.entry_point, + chain_id: cfg.chain_id, + master_account, + registry: cfg.registry, + scope: cfg.scope, + nonce, + account_gas_limits: cfg.account_gas_limits, + pre_verification_gas: cfg.pre_verification_gas, + gas_fees: cfg.gas_fees, + paymaster: cfg.paymaster, + paymaster_verification_gas_limit: cfg.paymaster_verification_gas_limit, + paymaster_post_op_gas_limit: cfg.paymaster_post_op_gas_limit, + valid_until, + valid_after: 0, + broker_signer: cfg.broker_signer, + register: ®ister, + grant: &grant, + }; + let assembled = assemble_accept_userop(¶ms, broker_sk).map_err(|e| e.to_string())?; + Ok(assembled.into_build_response(&cfg.entry_point, cfg.chain_id)) +} + +fn aerr(status: StatusCode, msg: impl Into) -> (StatusCode, Json) { + (status, Json(serde_json::json!({ "error": msg.into() }))) +} + +fn norm_omni(s: &str) -> String { + s.trim().trim_start_matches("0x").to_lowercase() +} + +/// Minimal JSON-RPC `eth_call` (the broker already uses reqwest for reads). +async fn eth_call( + http: &reqwest::Client, + rpc: &str, + to: &[u8; 20], + data: &str, +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", + "params": [{ "to": format!("0x{}", hex::encode(to)), "data": data }, "latest"] + }); + let resp: serde_json::Value = http + .post(rpc) + .json(&body) + .send() + .await + .map_err(|e| format!("eth_call send: {e}"))? + .json() + .await + .map_err(|e| format!("eth_call decode: {e}"))?; + resp.get("result") + .and_then(|r| r.as_str()) + .map(String::from) + .ok_or_else(|| format!("eth_call no result: {resp}")) +} + +fn selector(sig: &str) -> String { + hex::encode(&agentkeys_core::device_crypto::keccak256(sig.as_bytes())[..4]) +} + +/// `eth_getCode(addr) != 0x` — true iff `addr` is a deployed contract. The accept +/// is an ERC-4337 `P256Account` UserOp, so the master MUST be a passkey-controlled +/// smart account, NOT a legacy EOA (the deprecated `heima-register-first-master.sh` +/// binds `operatorMasterWallet` to the deployer EOA, which has no `validateUserOp`). +async fn eth_address_has_code( + http: &reqwest::Client, + rpc: &str, + addr: &[u8; 20], +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_getCode", + "params": [format!("0x{}", hex::encode(addr)), "latest"] + }); + let resp: serde_json::Value = http + .post(rpc) + .json(&body) + .send() + .await + .map_err(|e| format!("eth_getCode send: {e}"))? + .json() + .await + .map_err(|e| format!("eth_getCode decode: {e}"))?; + let code = resp + .get("result") + .and_then(|r| r.as_str()) + .ok_or_else(|| format!("eth_getCode no result: {resp}"))?; + Ok(code != "0x" && !code.is_empty()) +} + +/// `eth_getTransactionReceipt(tx).status` read directly (NOT via cast/alloy, so +/// Heima's mixHash-less receipt doesn't break parsing). `Some(true)` = success +/// (`0x1`), `Some(false)` = reverted (`0x0`), `None` = no receipt yet / RPC error. +async fn eth_receipt_status(http: &reqwest::Client, rpc: &str, tx_hash: &str) -> Option { + let body = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_getTransactionReceipt", "params": [tx_hash] + }); + let resp: serde_json::Value = http + .post(rpc) + .json(&body) + .send() + .await + .ok()? + .json() + .await + .ok()?; + let receipt = resp.get("result")?; + if receipt.is_null() { + return None; + } + let status = receipt.get("status")?.as_str()?; + Some(status == "0x1") +} + +/// `SidecarRegistry.operatorMasterWallet(bytes32) -> address`. Zero address ⇒ no master. +async fn call_operator_master_wallet( + http: &reqwest::Client, + rpc: &str, + registry: &[u8; 20], + operator_omni: &str, +) -> Result<[u8; 20], String> { + let arg = format!("{:0>64}", norm_omni(operator_omni)); + let data = format!("0x{}{}", selector("operatorMasterWallet(bytes32)"), arg); + let raw = eth_call(http, rpc, registry, &data).await?; + let hexs = raw.trim_start_matches("0x"); + if hexs.len() < 64 { + return Err(format!("operatorMasterWallet short return: {raw}")); + } + addr20(&hexs[24..64], "operatorMasterWallet") +} + +/// `EntryPoint.getNonce(address sender, uint192 key=0) -> uint256`. +async fn call_entrypoint_nonce( + http: &reqwest::Client, + rpc: &str, + entry_point: &[u8; 20], + account: &[u8; 20], +) -> Result<[u8; 32], String> { + let sender = format!("{:0>64}", hex::encode(account)); + let key = "0".repeat(64); + let data = format!( + "0x{}{}{}", + selector("getNonce(address,uint192)"), + sender, + key + ); + let raw = eth_call(http, rpc, entry_point, &data).await?; + let b = hex::decode(raw.trim_start_matches("0x")).map_err(|e| format!("nonce hex: {e}"))?; + let mut w = [0u8; 32]; + if b.len() >= 32 { + w.copy_from_slice(&b[..32]); + } + Ok(w) +} + +fn bearer(headers: &HeaderMap) -> Result)> { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) + .ok_or_else(|| aerr(StatusCode::UNAUTHORIZED, "missing bearer token")) +} + +/// `POST /v1/accept/build` (J1_master) — assemble the sponsored accept-batch UserOp +/// and return the `userOpHash` the master K11-signs. +pub async fn accept_build( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // 1. J1_master auth — the session omni MUST equal the request operator_omni. + let token = bearer(&headers)?; + let claims = crate::jwt::verify::verify_session_jwt( + &state.session_keypair, + &state.config.oidc_issuer, + &token, + ) + .map_err(|e| aerr(StatusCode::UNAUTHORIZED, format!("session jwt: {e}")))?; + if norm_omni(&claims.agentkeys.omni_account) != norm_omni(&req.operator_omni) { + return Err(aerr(StatusCode::FORBIDDEN, "operator_mismatch")); + } + + // 2. config + co-sign key from env. + let (cfg, broker_sk) = + load_accept_config().map_err(|e| aerr(StatusCode::SERVICE_UNAVAILABLE, e))?; + + // 3. chain reads: the master account + its EntryPoint nonce. + let master_account = + call_operator_master_wallet(&state.http, &cfg.rpc_url, &cfg.registry, &req.operator_omni) + .await + .map_err(|e| aerr(StatusCode::BAD_GATEWAY, e))?; + if master_account == [0u8; 20] { + return Err(aerr( + StatusCode::CONFLICT, + "operator has no master account on chain (register the master first)", + )); + } + // The accept is an ERC-4337 `P256Account` UserOp — the master MUST be a deployed + // passkey-controlled smart account. If `operatorMasterWallet` is a legacy EOA + // (bound by the deprecated `heima-register-first-master.sh`, which signs + // `registerFirstMasterDevice` directly with the deployer EOA), it has no + // `validateUserOp` and `handleOps` would revert — wasting a Touch-ID ceremony + // and gas only to fail with a misleading "wrong passkey". Reject NOW with the + // actionable cause (the master-model mismatch, NOT the passkey). + if !eth_address_has_code(&state.http, &cfg.rpc_url, &master_account) + .await + .map_err(|e| aerr(StatusCode::BAD_GATEWAY, e))? + { + return Err(aerr( + StatusCode::CONFLICT, + format!( + "operator master 0x{} is a legacy EOA, not a passkey P256Account — the \ + Touch-ID accept requires a P256Account master. This operator was onboarded \ + via the deprecated EOA register (heima-register-first-master.sh); re-onboard \ + the master through the passkey P256Account register (erc4337-register-master.sh) \ + so operatorMasterWallet is the smart account. (No passkey selection can fix an \ + EOA master.)", + hex::encode(master_account) + ), + )); + } + let nonce = call_entrypoint_nonce(&state.http, &cfg.rpc_url, &cfg.entry_point, &master_account) + .await + .map_err(|e| aerr(StatusCode::BAD_GATEWAY, e))?; + + // 4. assemble + co-sign. + let valid_until = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + + SPONSOR_WINDOW_SECS; + let resp = build_accept_response(&req, master_account, nonce, &cfg, &broker_sk, valid_until) + .map_err(|e| aerr(StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(resp)) +} + +// ─── slice 3: POST /v1/accept/submit → EntryPoint.handleOps (Stage B) ───────── + +use crate::accept_assertion::{encode_browser_assertion_signature, BrowserAssertion}; +use crate::sponsored_accept::WireUserOp; + +/// Broker-side mirror of `agentkeys_backend_client::protocol::SubmitAcceptUserOpRequest`. +/// The broker encodes `assertion` into `user_op.signature` (the master's K11 WebAuthn +/// proof over `user_op_hash`) before `EntryPoint.handleOps` — the daemon forwards the +/// raw browser assertion, not a pre-encoded signature. +#[derive(Debug, Clone, Deserialize)] +pub struct SubmitAcceptRequest { + /// The op from `/v1/accept/build` (sponsored `paymasterAndData` already filled). + /// Its `signature` is (re)set by the broker from `assertion`. + pub user_op: WireUserOp, + /// The master's browser WebAuthn assertion over `user_op_hash`. The broker + /// derives the `operator_omni` (→ the `credIdHash` signer key) from the + /// verified J1 session, NOT a body field — the J1 omni is authoritative. + pub assertion: BrowserAssertion, +} + +const HANDLE_OPS_SIG: &str = + "handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address)"; + +/// Build the `cast send` tuple arg for `handleOps` from a signed `WireUserOp`. The +/// hex fields map directly to the `PackedUserOperation` tuple (nonce + +/// preVerificationGas are uint256 — cast accepts 0x-hex). Pure + deterministic. +fn cast_handleops_arg(op: &WireUserOp) -> String { + format!( + "[({},{},{},{},{},{},{},{},{})]", + op.sender, + op.nonce, + op.init_code, + op.call_data, + op.account_gas_limits, + op.pre_verification_gas, + op.gas_fees, + op.paymaster_and_data, + op.signature, + ) +} + +/// `POST /v1/accept/submit` (J1_master) — relay the K11-signed op to +/// `EntryPoint.handleOps`. The broker is the sponsor + submitter: the +/// VerifyingPaymaster covers the account gas, the broker EOA fronts the outer tx +/// (reimbursed). The broker host ships foundry (setup-broker-host.sh), so we relay +/// via `cast send` — the repo's chain-mutation pattern, E8-proven for handleOps. +pub async fn accept_submit( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let token = bearer(&headers)?; + let claims = crate::jwt::verify::verify_session_jwt( + &state.session_keypair, + &state.config.oidc_issuer, + &token, + ) + .map_err(|e| aerr(StatusCode::UNAUTHORIZED, format!("session jwt: {e}")))?; + + let (cfg, _sk) = load_accept_config().map_err(|e| aerr(StatusCode::SERVICE_UNAVAILABLE, e))?; + // NOTE: `--private-key` is ps-visible; production should move the submitter to a + // keystore (the broker fee-payer keystore) — tracked as a follow-up. + let key = std::env::var("BROKER_SPONSOR_SIGNER_KEY").map_err(|_| { + aerr( + StatusCode::SERVICE_UNAVAILABLE, + "BROKER_SPONSOR_SIGNER_KEY not set", + ) + })?; + + // Encode the browser WebAuthn assertion into the account UserOp signature + // (the master's K11 proof over user_op_hash) — the daemon forwards the raw + // assertion; the broker binds the operator-derived credIdHash here. + // operator_omni IS the verified J1 session omni (authoritative master id). + let operator_omni: [u8; 32] = { + let b = hex::decode(norm_omni(&claims.agentkeys.omni_account)) + .map_err(|e| aerr(StatusCode::BAD_REQUEST, format!("session omni hex: {e}")))?; + b.try_into() + .map_err(|_| aerr(StatusCode::BAD_REQUEST, "session omni must be 32 bytes"))? + }; + let sig = encode_browser_assertion_signature(&req.assertion, &operator_omni) + .map_err(|e| aerr(StatusCode::BAD_REQUEST, format!("assertion: {e}")))?; + let mut user_op = req.user_op; + user_op.signature = format!("0x{}", hex::encode(&sig)); + + let ep = format!("0x{}", hex::encode(cfg.entry_point)); + let beneficiary = format!("0x{}", hex::encode(cfg.broker_signer)); + let arg = cast_handleops_arg(&user_op); + + // Resolve `cast`: the broker runs as a systemd service whose PATH need not + // include a user-dir foundry (and ProtectHome=true hides $HOME/.foundry). + // AGENTKEYS_CAST_BIN (pinned by setup-broker-host.sh to an absolute path) + // overrides; the bare default works when cast is on PATH (e.g. /usr/local/bin). + let cast_bin = std::env::var("AGENTKEYS_CAST_BIN").unwrap_or_else(|_| "cast".to_string()); + + // cast send handleOps — Heima-robust (mirrors erc4337-register-master.sh): + // • `--gas-limit` (NOT eth_estimateGas): Heima reverts the handleOps gas + // estimation with a bare `0x` ("Failed to estimate gas: … revert, data: 0x"), + // so pin the limit and skip estimation. + // • NO `--json`: Heima's mixHash-less receipt makes cast/alloy fail to PARSE the + // receipt though the tx LANDS — so read the tx hash from the human output and + // verify the OUTCOME via a direct eth_getTransactionReceipt, never cast's exit. + let gas_limit = + std::env::var("AGENTKEYS_HANDLEOPS_GAS_LIMIT").unwrap_or_else(|_| "4000000".to_string()); + let out = tokio::process::Command::new(&cast_bin) + .args([ + "send", + &ep, + HANDLE_OPS_SIG, + &arg, + &beneficiary, + "--private-key", + &key, + "--rpc-url", + &cfg.rpc_url, + "--legacy", + "--gas-limit", + &gas_limit, + ]) + .output() + .await + .map_err(|e| { + aerr( + StatusCode::BAD_GATEWAY, + format!( + "spawn {cast_bin}: {e} — install foundry on the broker host \ + (curl -L https://foundry.paradigm.xyz | bash; foundryup) and/or set \ + AGENTKEYS_CAST_BIN to cast's absolute path" + ), + ) + })?; + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + // cast prints `transactionHash 0x…` before the receipt-parse may error. + let tx_hash = combined + .lines() + .find_map(|l| l.trim().strip_prefix("transactionHash").map(str::trim)) + .filter(|h| h.starts_with("0x") && h.len() >= 66) + .map(|h| h[..66].to_string()) + .unwrap_or_default(); + + if tx_hash.is_empty() { + // Never broadcast (bad nonce, submitter unfunded, malformed op, RPC down…). + let tail: Vec<&str> = combined.lines().rev().take(6).collect(); + return Err(aerr( + StatusCode::BAD_GATEWAY, + format!( + "handleOps did not broadcast: {}", + tail.into_iter().rev().collect::>().join(" ") + ), + )); + } + + // cast waited for the receipt before its parse errored, so the tx is mined now — + // read the status directly (mixHash-receipt-proof). + match eth_receipt_status(&state.http, &cfg.rpc_url, &tx_hash).await { + Some(false) => Err(aerr( + StatusCode::BAD_GATEWAY, + format!( + "handleOps reverted on-chain (tx {tx_hash}) — most likely the WRONG passkey \ + (P256Account SIG_VALIDATION_FAILED), an unregistered master, or a paymaster/scope issue" + ), + )), + // Some(true) = success; None = mined-but-receipt-not-yet-visible (rare) — + // treat as submitted (the UI can confirm on chain). + _ => Ok(Json(serde_json::json!({ "ok": true, "tx_hash": tx_hash, "block_number": "" }))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> BuildAcceptRequest { + BuildAcceptRequest { + operator_omni: format!("0x{}", "22".repeat(32)), + actor_omni: format!("0x{}", "33".repeat(32)), + device_key_hash: format!("0x{}", "11".repeat(32)), + agent_pop_sig: format!("0x{}", "55".repeat(65)), + link_code_redemption: "0xdeadbeef".into(), + services: vec!["memory:personal".into()], + read_only: true, + max_per_call: "1000".into(), + max_per_period: "0".into(), + max_total: "0".into(), + period_seconds: 86400, + } + } + + // keccak256("memory:personal") from `cast keccak` — the on-chain service id. + const MEMORY_PERSONAL_ID: &str = + "0x12f2770c904838cddb30299f5c22cd28df31b34fcdb44c342cd1f96c4a38ab27"; + + #[test] + fn parses_register_fields_and_keccak_service_ids() { + let (reg, grant) = parse_register_and_grant(&sample()).unwrap(); + assert_eq!(reg.device_key_hash, [0x11; 32]); + assert_eq!(reg.operator_omni, [0x22; 32]); + assert_eq!(reg.actor_omni, [0x33; 32]); + assert_eq!(reg.link_code_redemption, hex::decode("deadbeef").unwrap()); + assert_eq!(reg.agent_pop_sig, vec![0x55u8; 65]); + assert_eq!( + format!("0x{}", hex::encode(grant.services[0])), + MEMORY_PERSONAL_ID + ); + assert!(grant.read_only); + assert_eq!(grant.max_per_call, 1000); + assert_eq!(grant.period_seconds, 86400); + } + + #[test] + fn service_ids_are_lowercased_before_hashing() { + let mut req = sample(); + req.services = vec!["Memory:Personal".into()]; + let (_, grant) = parse_register_and_grant(&req).unwrap(); + assert_eq!( + format!("0x{}", hex::encode(grant.services[0])), + MEMORY_PERSONAL_ID + ); + } + + #[test] + fn rejects_bad_hex_and_non_numeric_caps() { + let mut bad_hex = sample(); + bad_hex.operator_omni = "0xZZ".into(); + assert!(parse_register_and_grant(&bad_hex).is_err()); + + let mut short = sample(); + short.device_key_hash = "0x1122".into(); // not 32 bytes + assert!(parse_register_and_grant(&short).is_err()); + + let mut bad_cap = sample(); + bad_cap.max_total = "not-a-number".into(); + assert!(parse_register_and_grant(&bad_cap).is_err()); + } + + #[test] + fn build_accept_response_assembles_the_batch_op() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_signer: [u8; 20] = { + let a = + agentkeys_core::device_crypto::evm_address(&k256::ecdsa::VerifyingKey::from(&sk)); + hex::decode(a.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap() + }; + let cfg = AcceptConfig { + rpc_url: "http://localhost".into(), + chain_id: 212_013, + entry_point: [0x66; 20], + paymaster: Some([0x55; 20]), + broker_signer, + registry: { + let mut a = [0u8; 20]; + a[19] = 0xa1; + a + }, + scope: { + let mut a = [0u8; 20]; + a[19] = 0xa2; + a + }, + account_gas_limits: crate::sponsor::pack_u128_pair(600_000, 2_000_000), + pre_verification_gas: u256_word(100_000), + gas_fees: crate::sponsor::pack_u128_pair(1_000_000_000, 2_000_000_000), + paymaster_verification_gas_limit: 200_000, + paymaster_post_op_gas_limit: 50_000, + }; + let master = [0x99u8; 20]; + let mut nonce = [0u8; 32]; + nonce[31] = 7; + let resp = + build_accept_response(&sample(), master, nonce, &cfg, &sk, 9_999_999_999).unwrap(); + assert_eq!(resp.user_op.sender, format!("0x{}", hex::encode(master))); + assert!(resp.user_op_hash.starts_with("0x") && resp.user_op_hash.len() == 66); + assert_eq!( + resp.entry_point, + format!("0x{}", hex::encode(cfg.entry_point)) + ); + assert_eq!(resp.chain_id, 212_013); + // the inner callData is the executeBatch (selector 47e1da2a, golden-tested in core). + assert!(resp.user_op.call_data.starts_with("0x47e1da2a")); + } + + #[test] + fn build_accept_response_unsponsored_empties_paymaster_and_data() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let cfg = AcceptConfig { + rpc_url: "http://localhost".into(), + chain_id: 212_013, + entry_point: [0x66; 20], + paymaster: None, // unsponsored direct handleOps + broker_signer: [0x77; 20], + registry: [0xa1; 20], + scope: [0xa2; 20], + account_gas_limits: crate::sponsor::pack_u128_pair(600_000, 2_000_000), + pre_verification_gas: u256_word(100_000), + gas_fees: crate::sponsor::pack_u128_pair(1_000_000_000, 2_000_000_000), + paymaster_verification_gas_limit: 200_000, + paymaster_post_op_gas_limit: 50_000, + }; + let mut nonce = [0u8; 32]; + nonce[31] = 7; + let resp = build_accept_response(&sample(), [0x99u8; 20], nonce, &cfg, &sk, 9_999_999_999) + .unwrap(); + // Unsponsored ⇒ no paymasterAndData; the master still K11-signs userOpHash. + assert_eq!(resp.user_op.paymaster_and_data, "0x"); + assert!(resp.user_op.call_data.starts_with("0x47e1da2a")); + assert!(resp.user_op_hash.starts_with("0x") && resp.user_op_hash.len() == 66); + } + + #[test] + fn cast_handleops_arg_formats_the_packed_tuple() { + let op = WireUserOp { + sender: "0xaa".into(), + nonce: "0x07".into(), + init_code: "0x".into(), + call_data: "0xdeadbeef".into(), + account_gas_limits: "0xagl".into(), + pre_verification_gas: "0x60".into(), + gas_fees: "0xfee".into(), + paymaster_and_data: "0xpmd".into(), + signature: "0xsig".into(), + }; + assert_eq!( + cast_handleops_arg(&op), + "[(0xaa,0x07,0x,0xdeadbeef,0xagl,0x60,0xfee,0xpmd,0xsig)]" + ); + } +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/decline.rs b/crates/agentkeys-broker-server/src/handlers/agent/decline.rs new file mode 100644 index 00000000..9f981c0e --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/decline.rs @@ -0,0 +1,51 @@ +//! `POST /v1/agent/pairing/decline` — the master declines a claimed pairing +//! request (§10.2). Removes the pending rendezvous row so it stops appearing in +//! `/v1/agent/pairing/pending` (the agent re-pairs if it still wants in). +//! +//! Gated by the master's `J1` session bearer ONLY — **no Touch ID / K11**. +//! Declining is not an on-chain mutation (nothing is bound), so it doesn't carry +//! the biometric gate the *accept* (registerAgentDevice + setScope) does. The +//! store scopes the DELETE to the claiming master's `operator_omni`, so a master +//! can only decline its own requests, and refuses an already-bound device (that's +//! an unpair, not a decline). Idempotent: declining an already-gone request is OK. + +use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::BrokerError; +use crate::handlers::grant::require_session_jwt; +use crate::state::SharedState; + +#[derive(Debug, Deserialize)] +pub struct PairingDeclineBody { + /// The `request_id` shown in the pending list (the master-side handle). + pub request_id: String, +} + +pub async fn pairing_decline( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let session = require_session_jwt(&headers, &state)?; + let master_omni = session.agentkeys.omni_account; + + let removed = state + .pairing_request_store + .decline(body.request_id.trim(), &master_omni)?; + + tracing::info!( + operator_omni = %master_omni, + request_id = %body.request_id, + removed, + "declined §10.2 pairing request" + ); + + // Idempotent: ok:true whether or not a row was removed (declining a gone + // request is a no-op success — the desired end state holds either way). + Ok(( + StatusCode::OK, + Json(json!({ "ok": true, "request_id": body.request_id, "removed": removed })), + )) +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/mod.rs b/crates/agentkeys-broker-server/src/handlers/agent/mod.rs index d81491a5..503c1777 100644 --- a/crates/agentkeys-broker-server/src/handlers/agent/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/agent/mod.rs @@ -25,6 +25,7 @@ //! chain agent self-revoke is out of this PR (→ #155). pub mod claim; +pub mod decline; pub mod pending; pub mod poll; pub mod request; diff --git a/crates/agentkeys-broker-server/src/handlers/agent/pending.rs b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs index 8b864b27..f9d1c4fd 100644 --- a/crates/agentkeys-broker-server/src/handlers/agent/pending.rs +++ b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs @@ -45,6 +45,8 @@ pub async fn pending_bindings( "device_pubkey": b.device_pubkey, "pop_sig": b.pop_sig, "device_key_hash": device_key_hash, + "pairing_code": b.pairing_code, + "created_at": b.created_at, }) }) .collect(); diff --git a/crates/agentkeys-broker-server/src/handlers/mod.rs b/crates/agentkeys-broker-server/src/handlers/mod.rs index e4e89df6..800fd098 100644 --- a/crates/agentkeys-broker-server/src/handlers/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod accept; pub mod agent; pub mod auth; pub mod broker_status; diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index 967943e8..600bc056 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod accept_assertion; pub mod audit; pub mod auth; pub mod boot; @@ -11,6 +12,7 @@ pub mod metrics; pub mod oidc; pub mod plugins; pub mod sponsor; +pub mod sponsored_accept; pub mod state; pub mod storage; pub mod sts; @@ -67,6 +69,10 @@ pub fn create_router(state: SharedState) -> Router { // Classifier-service compute-gate cap (#178 §15.6, #207 items 2-3). // op=Classify; data_class comes from the request body (spans data classes). .route("/v1/cap/classify", post(handlers::cap::cap_classify)) + // #225 / #164 E7 — Touch-ID-gated agent accept: assemble the sponsored + // executeBatch([registerAgentDevice, setScope]) UserOp + return the userOpHash. + .route("/v1/accept/build", post(handlers::accept::accept_build)) + .route("/v1/accept/submit", post(handlers::accept::accept_submit)) // Stage 7 §3.5 — pluggable auth surface. .route( "/v1/auth/wallet/start", @@ -88,6 +94,10 @@ pub fn create_router(state: SharedState) -> Router { "/v1/agent/pairing/claim", post(handlers::agent::claim::pairing_claim), ) + .route( + "/v1/agent/pairing/decline", + post(handlers::agent::decline::pairing_decline), + ) .route( "/v1/agent/pairing/poll", post(handlers::agent::poll::pairing_poll), diff --git a/crates/agentkeys-broker-server/src/sponsored_accept.rs b/crates/agentkeys-broker-server/src/sponsored_accept.rs new file mode 100644 index 00000000..e03d6146 --- /dev/null +++ b/crates/agentkeys-broker-server/src/sponsored_accept.rs @@ -0,0 +1,472 @@ +//! #225 / #164 E7 — assemble the **sponsored agent-accept UserOp**. +//! +//! Ties the two halves that already exist into one complete, ready-to-sign +//! `PackedUserOperation`: +//! - the **intent** — `agentkeys_core::erc4337::accept_batch_calldata` (the atomic +//! `executeBatch([registerAgentDevice, setScope])`, P.2 + P.3); +//! - the **sponsorship** — the broker EIP-191 co-signs the `VerifyingPaymaster` +//! `getHash` (the J1-gated Sybil gate = gas-free), via [`crate::sponsor`]. +//! +//! Output: the op with `paymasterAndData` filled, plus the `userOpHash` the master +//! passkey (K11) signs and the `getHash` the broker signed. **Pure** (takes the +//! broker key, no chain I/O): the caller fetches the on-chain 2D nonce + gas/fee +//! params and supplies them; submission (`EntryPoint.handleOps`) is the I/O step +//! that consumes [`AssembledAcceptUserOp::user_op`] once the account signature is +//! attached. +//! +//! Division of labour (unchanged from #200): browser/daemon K11-signs the +//! `userOpHash`; the broker co-signs the paymaster `getHash`; submission is Stage B. + +use crate::sponsor::{assemble_paymaster_and_data, broker_cosign, pack_u128_pair, PackedUserOp}; +use agentkeys_core::erc4337::{accept_batch_calldata, AgentRegister, ScopeGrant}; +use anyhow::Result; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; + +fn hex0x(b: &[u8]) -> String { + format!("0x{}", hex::encode(b)) +} + +/// Everything the composer needs that isn't the broker key. Chain-derived values +/// (nonce, gas, fees, validity window, addresses) are inputs — nothing hardcoded; +/// the caller reads them on-chain and passes them in. +pub struct AcceptUserOpParams<'a> { + pub entry_point: [u8; 20], + pub chain_id: u64, + + /// The operator's ERC-4337 P-256 master account (the `sender`). + pub master_account: [u8; 20], + /// `SidecarRegistry` (target of the `registerAgentDevice` inner call). + pub registry: [u8; 20], + /// `AgentKeysScope` (target of the `setScope` inner call). + pub scope: [u8; 20], + /// EntryPoint v0.7 2D nonce for `master_account` (read on-chain by the caller). + pub nonce: [u8; 32], + + /// `verificationGasLimit(16) ‖ callGasLimit(16)` — use [`pack_u128_pair`]. + pub account_gas_limits: [u8; 32], + pub pre_verification_gas: [u8; 32], + /// `maxPriorityFeePerGas(16) ‖ maxFeePerGas(16)` — use [`pack_u128_pair`]. + pub gas_fees: [u8; 32], + + /// `Some` = sponsored (VerifyingPaymaster co-signed); `None` = **unsponsored** + /// direct `handleOps` (empty `paymasterAndData`; gas from the account's + /// EntryPoint deposit, the submitter EOA fronts the outer tx + is the + /// beneficiary). The unsponsored path mirrors the mainnet-proven + /// `harness/scripts/erc4337-register-master.sh`. + pub paymaster: Option<[u8; 20]>, + pub paymaster_verification_gas_limit: u128, + pub paymaster_post_op_gas_limit: u128, + pub valid_until: u64, + pub valid_after: u64, + /// The broker EOA: sponsored → the signer the `VerifyingPaymaster` trusts + /// (recovers from the co-sign); unsponsored → the `handleOps` beneficiary. + pub broker_signer: [u8; 20], + + pub register: &'a AgentRegister, + pub grant: &'a ScopeGrant, +} + +/// The assembled op + the two digests. `user_op.signature` is still empty — the +/// account (K11) signs `user_op_hash` and the caller sets it before submit. +pub struct AssembledAcceptUserOp { + pub user_op: PackedUserOp, + /// The account (master passkey / K11) signs THIS — `EntryPoint.getUserOpHash`. + pub user_op_hash: [u8; 32], + /// The broker signed THIS — `VerifyingPaymaster.getHash` (returned for audit). + pub paymaster_get_hash: [u8; 32], +} + +/// Assemble the sponsored accept UserOp: build the batch callData, co-sign the +/// paymaster, fill `paymasterAndData`, and compute the `userOpHash`. +/// +/// The paymaster `getHash` commits `paymasterAndData[20:52]` (the gas limits), so +/// we set those bytes BEFORE hashing — a provisional `paymaster ‖ gasWord` — then +/// rebuild `paymasterAndData` with the real broker signature appended. The two +/// always agree on the gas word, which is what the on-chain `getHash` re-derives. +pub fn assemble_accept_userop( + p: &AcceptUserOpParams, + broker_sk: &SigningKey, +) -> Result { + let call_data = accept_batch_calldata(&p.registry, &p.scope, p.register, p.grant); + + let mut user_op = PackedUserOp { + sender: p.master_account, + nonce: p.nonce, + init_code: Vec::new(), + call_data, + account_gas_limits: p.account_gas_limits, + pre_verification_gas: p.pre_verification_gas, + gas_fees: p.gas_fees, + paymaster_and_data: Vec::new(), + signature: Vec::new(), + }; + + // `paymaster_get_hash` is [0;32] in the unsponsored path (nothing co-signed). + let paymaster_get_hash = match p.paymaster { + // Sponsored: provisional paymasterAndData = paymaster(20) ‖ gasWord(32) + // exposes [20:52] (the gas word) so paymaster_get_hash reads the limits + // the broker is approving; then rebuild with the real co-signature. + Some(paymaster) => { + let gas_word = pack_u128_pair( + p.paymaster_verification_gas_limit, + p.paymaster_post_op_gas_limit, + ); + let mut provisional = Vec::with_capacity(52); + provisional.extend_from_slice(&paymaster); + provisional.extend_from_slice(&gas_word); + user_op.paymaster_and_data = provisional; + + let get_hash = user_op.paymaster_get_hash( + p.valid_until, + p.valid_after, + &paymaster, + &p.broker_signer, + p.chain_id, + ); + let broker_sig = broker_cosign(&get_hash, broker_sk)?; + user_op.paymaster_and_data = assemble_paymaster_and_data( + &paymaster, + p.paymaster_verification_gas_limit, + p.paymaster_post_op_gas_limit, + p.valid_until, + p.valid_after, + &broker_sig, + )?; + get_hash + } + // Unsponsored: empty paymasterAndData. Gas is paid from the account's + // EntryPoint deposit; the submitter EOA fronts the outer `handleOps` tx + // and is reimbursed as the beneficiary. No broker co-sign. This is the + // mainnet-proven path (erc4337-register-master.sh). + None => { + user_op.paymaster_and_data = Vec::new(); + [0u8; 32] + } + }; + + let user_op_hash = user_op.user_op_hash(&p.entry_point, p.chain_id); + + Ok(AssembledAcceptUserOp { + user_op, + user_op_hash, + paymaster_get_hash, + }) +} + +/// Broker-side mirror of `agentkeys_backend_client::protocol::WireUserOp` — the +/// hex-encoded ERC-4337 `PackedUserOperation` on the `/v1/accept/*` wire. The +/// broker doesn't depend on `backend-client`; the frozen key-set test there + the +/// one below pin the two shapes together (same discipline as `BrokerCapRequest`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireUserOp { + pub sender: String, + pub nonce: String, + pub init_code: String, + pub call_data: String, + pub account_gas_limits: String, + pub pre_verification_gas: String, + pub gas_fees: String, + pub paymaster_and_data: String, + pub signature: String, +} + +impl WireUserOp { + pub fn from_packed(op: &PackedUserOp) -> Self { + Self { + sender: hex0x(&op.sender), + nonce: hex0x(&op.nonce), + init_code: hex0x(&op.init_code), + call_data: hex0x(&op.call_data), + account_gas_limits: hex0x(&op.account_gas_limits), + pre_verification_gas: hex0x(&op.pre_verification_gas), + gas_fees: hex0x(&op.gas_fees), + paymaster_and_data: hex0x(&op.paymaster_and_data), + signature: hex0x(&op.signature), + } + } +} + +/// Broker-side mirror of `BuildAcceptUserOpResponse` — the `/v1/accept/build` body +/// the daemon receives, then K11-signs `user_op_hash` and returns the filled +/// `user_op` to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptResponse { + pub user_op: WireUserOp, + pub user_op_hash: String, + pub entry_point: String, + pub chain_id: u64, +} + +impl AssembledAcceptUserOp { + /// Shape the assembled op into the `/v1/accept/build` response body. + pub fn into_build_response( + &self, + entry_point: &[u8; 20], + chain_id: u64, + ) -> BuildAcceptResponse { + BuildAcceptResponse { + user_op: WireUserOp::from_packed(&self.user_op), + user_op_hash: hex0x(&self.user_op_hash), + entry_point: hex0x(entry_point), + chain_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agentkeys_core::device_crypto::{ecrecover_eip191, evm_address}; + use k256::ecdsa::VerifyingKey; + + fn b32(x: u8) -> [u8; 32] { + [x; 32] + } + + fn sample_register() -> AgentRegister { + AgentRegister { + device_key_hash: b32(0x11), + operator_omni: b32(0x22), + actor_omni: b32(0x33), + link_code_redemption: hex::decode("deadbeef").unwrap(), + agent_pop_sig: vec![0x55; 65], + } + } + + fn sample_grant() -> ScopeGrant { + ScopeGrant { + services: vec![b32(0xaa), b32(0xbb)], + read_only: true, + max_per_call: 1000, + max_per_period: 2000, + max_total: 0, + period_seconds: 86400, + } + } + + fn params<'a>( + reg: &'a AgentRegister, + grant: &'a ScopeGrant, + broker_signer: [u8; 20], + ) -> AcceptUserOpParams<'a> { + AcceptUserOpParams { + entry_point: [0x66; 20], + chain_id: 212_013, + master_account: [0x99; 20], + registry: { + let mut a = [0u8; 20]; + a[19] = 0xa1; + a + }, + scope: { + let mut a = [0u8; 20]; + a[19] = 0xa2; + a + }, + nonce: { + let mut n = [0u8; 32]; + n[31] = 7; + n + }, + account_gas_limits: pack_u128_pair(300_000, 200_000), + pre_verification_gas: { + let mut w = [0u8; 32]; + w[28..].copy_from_slice(&60_000u32.to_be_bytes()); + w + }, + gas_fees: pack_u128_pair(1_000_000_000, 2_000_000_000), + paymaster: Some([0x55; 20]), + paymaster_verification_gas_limit: 80_000, + paymaster_post_op_gas_limit: 40_000, + valid_until: 9_999_999_999, + valid_after: 0, + broker_signer, + register: reg, + grant, + } + } + + #[test] + fn calldata_is_the_accept_batch_and_sender_is_the_master() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let out = assemble_accept_userop(&p, &sk).unwrap(); + + assert_eq!(out.user_op.sender, p.master_account); + // The callData is exactly the atomic accept batch. + assert_eq!( + out.user_op.call_data, + accept_batch_calldata(&p.registry, &p.scope, ®, &grant) + ); + // Signature is left for the account (K11) to fill. + assert!(out.user_op.signature.is_empty()); + // userOpHash is deterministic. + assert_eq!( + out.user_op_hash, + out.user_op.user_op_hash(&p.entry_point, p.chain_id) + ); + } + + #[test] + fn paymaster_and_data_carries_a_broker_cosign_over_the_get_hash() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let out = assemble_accept_userop(&p, &sk).unwrap(); + + // Layout: paymaster(20) ‖ vgl(16) ‖ postOp(16) ‖ validUntil(6) ‖ validAfter(6) ‖ sig(65). + let pad = &out.user_op.paymaster_and_data; + assert_eq!(pad.len(), 20 + 16 + 16 + 6 + 6 + 65); + assert_eq!(&pad[0..20], &p.paymaster.unwrap()); + // The trailing 65 bytes are the broker co-sign; it recovers to the broker + // EOA under the SAME EIP-191(getHash) the VerifyingPaymaster checks. + // Layout offsets: paymaster 0..20, vgl 20..36, postOp 36..52, + // validUntil 52..58, validAfter 58..64, sig 64..129. + let sig_hex = format!("0x{}", hex::encode(&pad[64..129])); + let recovered = ecrecover_eip191(&out.paymaster_get_hash, &sig_hex).unwrap(); + assert_eq!(recovered, broker_addr); + } + + #[test] + fn unsponsored_leaves_paymaster_and_data_empty_and_no_cosign() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let mut p = params(®, &grant, broker_bytes); + p.paymaster = None; // unsponsored direct handleOps + let out = assemble_accept_userop(&p, &sk).unwrap(); + + // No paymaster ⇒ empty paymasterAndData + a zero get-hash (nothing co-signed). + assert!(out.user_op.paymaster_and_data.is_empty()); + assert_eq!(out.paymaster_get_hash, [0u8; 32]); + // The batch callData + sender are unchanged; the account still K11-signs userOpHash. + assert_eq!(out.user_op.sender, p.master_account); + assert_eq!( + out.user_op.call_data, + accept_batch_calldata(&p.registry, &p.scope, ®, &grant) + ); + assert!(out.user_op.signature.is_empty()); + // userOpHash is deterministic over the empty-paymaster op. + assert_eq!( + out.user_op_hash, + out.user_op.user_op_hash(&p.entry_point, p.chain_id) + ); + // …and differs from the sponsored hash (paymasterAndData is part of the hash). + let sponsored = assemble_accept_userop(¶ms(®, &grant, broker_bytes), &sk) + .unwrap() + .user_op_hash; + assert_ne!(out.user_op_hash, sponsored); + } + + #[test] + fn changing_the_grant_changes_the_user_op_hash() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant_a = sample_grant(); + let mut grant_b = sample_grant(); + grant_b.read_only = false; // a different scope ⇒ different intent ⇒ different hash. + + let h_a = assemble_accept_userop(¶ms(®, &grant_a, broker_bytes), &sk) + .unwrap() + .user_op_hash; + let h_b = assemble_accept_userop(¶ms(®, &grant_b, broker_bytes), &sk) + .unwrap() + .user_op_hash; + assert_ne!(h_a, h_b); + } + + fn assembled() -> (AssembledAcceptUserOp, [u8; 20], u64, Vec) { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let expected_calldata = accept_batch_calldata(&p.registry, &p.scope, ®, &grant); + let out = assemble_accept_userop(&p, &sk).unwrap(); + (out, p.entry_point, p.chain_id, expected_calldata) + } + + fn unhex(s: &str) -> Vec { + hex::decode(s.trim_start_matches("0x")).unwrap() + } + + #[test] + fn wire_user_op_round_trips_every_field() { + let (out, _, _, _) = assembled(); + let w = WireUserOp::from_packed(&out.user_op); + assert_eq!(unhex(&w.sender), out.user_op.sender); + assert_eq!(unhex(&w.nonce), out.user_op.nonce); + assert_eq!(unhex(&w.init_code), out.user_op.init_code); + assert_eq!(unhex(&w.call_data), out.user_op.call_data); + assert_eq!(unhex(&w.account_gas_limits), out.user_op.account_gas_limits); + assert_eq!( + unhex(&w.pre_verification_gas), + out.user_op.pre_verification_gas + ); + assert_eq!(unhex(&w.gas_fees), out.user_op.gas_fees); + assert_eq!(unhex(&w.paymaster_and_data), out.user_op.paymaster_and_data); + assert_eq!(unhex(&w.signature), out.user_op.signature); + } + + #[test] + fn build_response_carries_the_batch_calldata_and_hash() { + let (out, entry_point, chain_id, expected_calldata) = assembled(); + let resp = out.into_build_response(&entry_point, chain_id); + assert_eq!(unhex(&resp.user_op.call_data), expected_calldata); + assert_eq!(unhex(&resp.user_op_hash), out.user_op_hash); + assert_eq!(unhex(&resp.entry_point), entry_point); + assert_eq!(resp.chain_id, chain_id); + } + + #[test] + fn wire_user_op_keys_match_backend_client_shape() { + // Server-side half of the #204 pin: a broker-side rename trips here, the + // backend-client `wire_user_op_keys_frozen` test catches the client side. + let (out, _, _, _) = assembled(); + let v = serde_json::to_value(WireUserOp::from_packed(&out.user_op)).unwrap(); + let mut keys: Vec = v.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "account_gas_limits", + "call_data", + "gas_fees", + "init_code", + "nonce", + "paymaster_and_data", + "pre_verification_gas", + "sender", + "signature", + ] + ); + } +} diff --git a/crates/agentkeys-broker-server/src/storage/pairing_requests.rs b/crates/agentkeys-broker-server/src/storage/pairing_requests.rs index 25b1b9b2..6e7e0fb3 100644 --- a/crates/agentkeys-broker-server/src/storage/pairing_requests.rs +++ b/crates/agentkeys-broker-server/src/storage/pairing_requests.rs @@ -113,6 +113,11 @@ pub struct PendingBinding { pub requested_scope: String, pub device_pubkey: String, pub pop_sig: String, + /// The agent's one-time `pairing_code` (the master claimed by it) — surfaced + /// so the operator can cross-reference the device's displayed code. + pub pairing_code: String, + /// Unix seconds when the agent requested pairing (`/request`). The UI formats it. + pub created_at: i64, } /// The `SELECT` shape `poll()` reads: @@ -449,7 +454,7 @@ impl PairingRequestStore { let mut stmt = conn .prepare( "SELECT request_id, child_omni, operator_omni, label, requested_scope, - device_pubkey, pop_sig + device_pubkey, pop_sig, pairing_code, created_at FROM pairing_requests WHERE operator_omni = ?1 AND claimed_at IS NOT NULL AND bound_at IS NULL ORDER BY claimed_at ASC", @@ -465,6 +470,8 @@ impl PairingRequestStore { requested_scope: row.get(4)?, device_pubkey: row.get(5)?, pop_sig: row.get(6)?, + pairing_code: row.get(7)?, + created_at: row.get(8)?, }) }) .map_err(|e| BrokerError::Internal(format!("query pending_bindings: {e}")))?; @@ -497,6 +504,23 @@ impl PairingRequestStore { Ok(n) } + /// Master DECLINES a claimed-but-unbound pairing request it owns — DELETE the + /// row so it leaves `pending_bindings` (the agent re-pairs). Scoped to the + /// claiming `operator_omni` so one master can't decline another's, and gated on + /// `bound_at IS NULL` (a device already bound on chain is unpaired, not + /// declined). Returns the row count (0 = unknown / not yours / already bound). + pub fn decline(&self, request_id: &str, operator_omni: &str) -> BrokerResult { + let conn = self.lock()?; + let n = conn + .execute( + "DELETE FROM pairing_requests + WHERE request_id = ?1 AND operator_omni = ?2 AND bound_at IS NULL", + params![request_id, operator_omni], + ) + .map_err(|e| BrokerError::Internal(format!("decline pairing_request: {e}")))?; + Ok(n) + } + /// Janitor — DELETE expired requests that were never claimed. Claimed rows are /// kept (the master may still need to bind them — a binding doesn't expire). pub fn purge_expired(&self, now: i64, retention_seconds: i64) -> BrokerResult { diff --git a/crates/agentkeys-chain/VERSION b/crates/agentkeys-chain/VERSION new file mode 100644 index 00000000..be586341 --- /dev/null +++ b/crates/agentkeys-chain/VERSION @@ -0,0 +1 @@ +0.3 diff --git a/crates/agentkeys-chain/src/SidecarRegistry.sol b/crates/agentkeys-chain/src/SidecarRegistry.sol index e869e983..d0abb77a 100644 --- a/crates/agentkeys-chain/src/SidecarRegistry.sol +++ b/crates/agentkeys-chain/src/SidecarRegistry.sol @@ -76,6 +76,11 @@ contract SidecarRegistry { } K11Verifier public immutable k11Verifier; + /// @notice The deployer (captured at construction). The ONLY caller allowed to + /// `resetMaster` — a dev/recovery affordance to unbind a stranded operator + /// (lost/deleted master passkey) WITHOUT redeploying the whole contract set. + /// registerFirstMasterDevice is otherwise first-master-only + immutable. + address public immutable owner; mapping(bytes32 => DeviceEntry) public devices; mapping(bytes32 => bytes32[]) private operatorDevices; @@ -94,12 +99,15 @@ contract SidecarRegistry { event DeviceRevoked(bytes32 indexed deviceKeyHash, bytes32 indexed operatorOmni); event OperatorBootstrapped(bytes32 indexed operatorOmni, address indexed masterWallet); event RecoveryThresholdSet(bytes32 indexed operatorOmni, uint8 newThreshold); + event MasterReset(bytes32 indexed operatorOmni, address indexed clearedMaster, uint256 deviceCount); error DeviceAlreadyRegistered(bytes32 deviceKeyHash); + error NotOwner(address caller); error DeviceNotRegistered(bytes32 deviceKeyHash); error DeviceAlreadyRevoked(bytes32 deviceKeyHash); error OperatorNotRegistered(bytes32 operatorOmni); error NotAuthorized(address caller, address expected); + error MasterMustBeAccount(address caller); error K11VerificationFailed(); error InvalidAttestingDevice(bytes32 deviceKeyHash); error InsufficientQuorum(uint8 got, uint8 required); @@ -110,22 +118,51 @@ contract SidecarRegistry { constructor(address k11VerifierAddr) { k11Verifier = K11Verifier(k11VerifierAddr); + owner = msg.sender; // the deployer — the only resetMaster caller + } + + /// @notice **Dev/recovery affordance** — unbind an operator's master so a fresh + /// `registerFirstMasterDevice` can re-bind (e.g. the operator deleted/lost + /// the master passkey, so the on-chain account is unusable). Without this, + /// first-master-only makes `operatorMasterWallet` immutable and the ONLY + /// recovery is redeploying the whole contract set. Deletes EVERY device for + /// the operator (master + agents) and clears the wallet/threshold/nonce, so + /// the operator re-onboards from scratch. + /// @dev OWNER-ONLY (the deployer). This is a privileged escape hatch: the owner + /// can wipe any operator's binding. Acceptable for the dev/test deployment; + /// a production registry would gate this on M-of-N guardian recovery (the + /// account's `recover()` path) instead. The local daemon's + /// `POST /v1/master/reset` calls this via the deployer key. + function resetMaster(bytes32 operatorOmni) external { + if (msg.sender != owner) revert NotOwner(msg.sender); + address cleared = operatorMasterWallet[operatorOmni]; + bytes32[] storage dks = operatorDevices[operatorOmni]; + uint256 n = dks.length; + for (uint256 i = 0; i < n; ++i) { + emit DeviceRevoked(dks[i], operatorOmni); + delete devices[dks[i]]; // registeredAt → 0, so re-register passes its guard + } + delete operatorDevices[operatorOmni]; + operatorMasterWallet[operatorOmni] = address(0); + recoveryThreshold[operatorOmni] = 0; + operatorNonce[operatorOmni] = 0; + emit MasterReset(operatorOmni, cleared, n); } // ─── Master device registration ────────────────────────────────────── /// @notice Register the FIRST master device for an operator. First call wins; /// subsequent master mutations need this sender. - /// @dev Bootstrap requires a K11 **self-attestation**: the new device's own - /// platform authenticator signs a challenge that binds `msg.sender` + - /// the operator/actor omni + device-key-hash + the K11 pubkey being - /// registered + chainid + this contract. This defeats the mempool - /// front-run (issue #165): a captured attestation cannot be replayed by - /// a different sender — the contract rebinds the challenge to the new - /// `msg.sender`, so the embedded clientDataJSON challenge no longer - /// matches and `verifyAssertion` rejects — and an attacker cannot forge - /// the operator's K11 signature. There is no chicken-and-egg: the - /// attesting key IS the key being registered (a *self*-attestation), - /// which exists by bootstrap time (enrolled in stage 2 per arch.md §9). + /// @dev Account model (#164 E3/E7): the caller MUST be the operator's + /// ERC-4337 P-256 `P256Account` — a deployed contract reached via + /// `account.execute` from a passkey-signed UserOp. The account's + /// `validateUserOp` (run by the EntryPoint) already verified the passkey + /// signed the userOpHash, which commits THIS `registerFirstMasterDevice` + /// calldata — so the call itself proves the passkey authorized + /// registering this `operatorOmni`, and `msg.sender` is the + /// passkey-controlled account we record as `operatorMasterWallet`. The + /// explicit #166 self-attestation is subsumed by the account model for + /// the dev system (see docs/plan/web-flow/onboarding-p256account-master.md + /// §8); an EOA `msg.sender` is rejected (no `validateUserOp`). function registerFirstMasterDevice( bytes32 deviceKeyHash, bytes32 operatorOmni, @@ -134,8 +171,7 @@ contract SidecarRegistry { bytes32 k11RpIdHash, uint256 k11PubX, uint256 k11PubY, - uint8 roles, - K11Assertion calldata selfAttestation + uint8 roles ) external { if (devices[deviceKeyHash].registeredAt != 0) { revert DeviceAlreadyRegistered(deviceKeyHash); @@ -144,37 +180,18 @@ contract SidecarRegistry { // Operator already has a first master; use registerAdditionalMasterDevice. revert DeviceAlreadyRegistered(deviceKeyHash); } - - // ── Anti-front-run (issue #165): K11 self-attestation bound to msg.sender ── - // Verified against the pubkey BEING registered (k11PubX/k11PubY) — there is - // no prior device to attest with at bootstrap. The challenge commits the - // sender so a captured assertion is non-transferable to another sender. - bytes32 expectedChallenge = keccak256( - abi.encode( - OP_REGISTER_1ST_MASTER, - operatorOmni, - actorOmni, - deviceKeyHash, - k11PubX, - k11PubY, - roles, - msg.sender, - block.chainid, - address(this) - ) - ); - bool ok = k11Verifier.verifyAssertion( - expectedChallenge, - k11RpIdHash, - selfAttestation.authenticatorData, - selfAttestation.clientDataJSON, - selfAttestation.challengeLocation, - selfAttestation.r, - selfAttestation.s, - k11PubX, - k11PubY - ); - if (!ok) revert K11VerificationFailed(); + // Account model (#164 E3/E7): the master MUST be a smart-contract account + // (the operator's P256Account), never an EOA. The caller reaches here via + // account.execute from a passkey-signed UserOp, so the EntryPoint already + // verified the passkey signed THIS calldata (committing operatorOmni) — + // msg.sender IS the passkey-authorized account; record it as the master. + // An EOA has no validateUserOp, so an EOA master could never sign the + // downstream ERC-4337 master mutations (scope grant / agent accept) — reject + // it at the source (this structurally retires the deprecated EOA register). + // The explicit #166 self-attestation is SUBSUMED by the account model for + // the dev system; the first-master front-run binding is a production- + // hardening follow-up — see docs/plan/web-flow/onboarding-p256account-master.md §8. + if (msg.sender.code.length == 0) revert MasterMustBeAccount(msg.sender); operatorMasterWallet[operatorOmni] = msg.sender; recoveryThreshold[operatorOmni] = 1; @@ -190,7 +207,7 @@ contract SidecarRegistry { tier: TIER_MASTER, roles: roles, registeredAt: uint64(block.timestamp), - lastSignCount: k11Verifier.readSignCount(selfAttestation.authenticatorData), + lastSignCount: 0, revoked: false }); operatorDevices[operatorOmni].push(deviceKeyHash); diff --git a/crates/agentkeys-chain/test/AgentKeysV1.t.sol b/crates/agentkeys-chain/test/AgentKeysV1.t.sol index eaf6f03b..6c8d30bf 100644 --- a/crates/agentkeys-chain/test/AgentKeysV1.t.sol +++ b/crates/agentkeys-chain/test/AgentKeysV1.t.sol @@ -63,6 +63,11 @@ contract AgentKeysV1Test is Test { function setUp() public { master = makeAddr("master"); + // Account model (#164 E7): the master is the operator's P256Account — a + // contract. Give `master` code so the registry's account guard + // (msg.sender.code.length > 0) passes; the registry never calls into it, + // so a stub byte suffices. `attacker` stays an EOA (no code). + vm.etch(master, hex"00"); attacker = makeAddr("attacker"); p256 = new P256Verifier(); k11 = new K11Verifier(address(p256)); @@ -104,19 +109,21 @@ contract AgentKeysV1Test is Test { k11RpIdHash, k11PubX, k11PubY, - 7, - _bogusAssertion(bytes32(0)) + 7 ); } - /// @notice Issue #165: the bootstrap is no longer unauthenticated. Without a - /// valid K11 self-attestation the call reverts — closing the - /// first-call-wins front-run's enabler. - function test_RegisterFirstMaster_RejectsBogusSelfAttestation() public { - // No mock → the real K11Verifier runs against a bogus self-attestation and - // reverts (challenge/P-256 mismatch). - vm.prank(master); - vm.expectRevert(); + /// @notice Account model (#164 E7): the master MUST be a smart-contract account + /// (the operator's P256Account), never an EOA. An EOA `msg.sender` has + /// no `validateUserOp`, so it could never sign the downstream ERC-4337 + /// master mutations — the registry rejects it at bootstrap. `attacker` + /// is an EOA (no code). This structurally retires the EOA-master class + /// of bug (an EOA master made the #225 accept's handleOps revert). + function test_RegisterFirstMaster_RejectsEoaMaster() public { + vm.prank(attacker); // EOA — no code + vm.expectRevert( + abi.encodeWithSelector(SidecarRegistry.MasterMustBeAccount.selector, attacker) + ); registry.registerFirstMasterDevice( deviceKeyHashMaster, operatorOmni, @@ -125,88 +132,74 @@ contract AgentKeysV1Test is Test { k11RpIdHash, k11PubX, k11PubY, - 7, - _bogusAssertion(bytes32(0)) + 7 ); assertEq(registry.operatorMasterWallet(operatorOmni), address(0)); } - /// @notice Issue #165: a captured self-attestation is non-transferable to a - /// different sender. The challenge binds msg.sender, so a front-runner - /// replaying the victim's assertion with their own sender is rejected, - /// while the legitimate operator still bootstraps. - function test_RegisterFirstMaster_RejectsFrontRunWithDifferentSender() public { - uint8 roles = 7; - SidecarRegistry.K11Assertion memory att = _bogusAssertion(bytes32(0)); - // The verifier accepts ONLY the challenge that binds `master` as the sender. - bytes32 victimChallenge = keccak256( - abi.encode( - registry.OP_REGISTER_1ST_MASTER(), - operatorOmni, - actorOmniMaster, - deviceKeyHashMaster, - k11PubX, - k11PubY, - roles, - master, - block.chainid, - address(registry) - ) - ); - vm.mockCall( - address(k11), - abi.encodeWithSelector( - K11Verifier.verifyAssertion.selector, - victimChallenge, - k11RpIdHash, - att.authenticatorData, - att.clientDataJSON, - att.challengeLocation, - att.r, - att.s, - k11PubX, - k11PubY - ), - abi.encode(true) - ); - vm.mockCall( - address(k11), - abi.encodeWithSelector(K11Verifier.readSignCount.selector), - abi.encode(uint32(0)) - ); - - // Attacker front-runs with the victim's omni/pubkey/assertion but their own - // sender → contract recomputes the challenge with msg.sender = attacker → - // no mock match → real verifier → revert. Victim's omni stays unclaimed. + // NOTE: the former `test_RegisterFirstMaster_RejectsFrontRunWithDifferentSender` + // (#165 self-attestation front-run) was REMOVED — E7 subsumes the explicit + // self-attestation into the account model (the master is a passkey-controlled + // P256Account; the UserOp signature is the passkey proof). The first-master + // front-run binding is a documented production-hardening follow-up — see + // docs/plan/web-flow/onboarding-p256account-master.md §8. + + // ─── SidecarRegistry: resetMaster (dev/recovery escape hatch, #225 E7) ─ + /// @notice resetMaster is OWNER-ONLY (the deployer). An attacker cannot wipe + /// another operator's binding; the binding stays put. + function test_ResetMaster_RejectsNonOwner() public { + _registerFirstMaster(); vm.prank(attacker); - vm.expectRevert(); - registry.registerFirstMasterDevice( - deviceKeyHashMaster, - operatorOmni, - actorOmniMaster, - k11CredId, - k11RpIdHash, - k11PubX, - k11PubY, - roles, - att + vm.expectRevert(abi.encodeWithSelector(SidecarRegistry.NotOwner.selector, attacker)); + registry.resetMaster(operatorOmni); + assertEq(registry.operatorMasterWallet(operatorOmni), master); + } + + /// @notice The deployer unbinds a stranded operator (lost/deleted master + /// passkey) so a FRESH first-master registration re-binds — without + /// redeploying the contract set. This is what the daemon's + /// `POST /v1/master/reset` does via the deployer key (#225 E7). The + /// reset wipes the WHOLE device list (master + agents) and clears + /// wallet/threshold/nonce, so a fresh passkey → fresh P256Account → + /// fresh deviceKeyHash re-onboards from scratch. + function test_ResetMaster_ClearsBindingAndAllowsReRegister() public { + _registerFirstMaster(); + vm.prank(master); + registry.registerAgentDevice( + deviceKeyHashAgentA, operatorOmni, actorOmniAgentA, hex"deadbeef", hex"cafe" ); + assertEq(registry.getOperatorDevices(operatorOmni).length, 2); + + // The test contract deployed the registry in setUp → it IS `owner`, so a + // direct (un-pranked) call passes the owner gate. + registry.resetMaster(operatorOmni); assertEq(registry.operatorMasterWallet(operatorOmni), address(0)); + assertEq(uint256(registry.recoveryThreshold(operatorOmni)), 0); + assertEq(uint256(registry.operatorNonce(operatorOmni)), 0); + assertEq(registry.getOperatorDevices(operatorOmni).length, 0); + // devices deleted → registeredAt 0 → the re-register guard passes. + assertEq(uint256(registry.getDevice(deviceKeyHashMaster).registeredAt), 0); + assertFalse(registry.isActive(deviceKeyHashAgentA)); - // The legitimate operator bootstraps — its challenge matches the mock. - vm.prank(master); + // A fresh passkey → fresh P256Account → fresh device re-binds cleanly. + address freshMaster = makeAddr("fresh-master"); + vm.etch(freshMaster, hex"00"); + bytes32 freshDeviceHash = keccak256("D_pub_master_fresh"); + uint8 fullRoles = + registry.ROLE_CAP_MINT() | registry.ROLE_RECOVERY() | registry.ROLE_SCOPE_MGMT(); + vm.prank(freshMaster); registry.registerFirstMasterDevice( - deviceKeyHashMaster, + freshDeviceHash, operatorOmni, actorOmniMaster, k11CredId, k11RpIdHash, k11PubX, k11PubY, - roles, - att + fullRoles ); - assertEq(registry.operatorMasterWallet(operatorOmni), master); + assertEq(registry.operatorMasterWallet(operatorOmni), freshMaster); + assertEq(uint256(registry.recoveryThreshold(operatorOmni)), 1); } // ─── SidecarRegistry: 2nd master device requires K11 ──────────────── @@ -540,20 +533,11 @@ contract AgentKeysV1Test is Test { function _registerFirstMaster() internal { uint8 fullRoles = registry.ROLE_CAP_MINT() | registry.ROLE_RECOVERY() | registry.ROLE_SCOPE_MGMT(); - // Real P-256 verification is covered by K11Verifier.t.sol / P256Verifier.t.sol; - // here we mock the verifier so registry liveness tests don't need a real - // self-attestation. Mocks are cleared after bootstrap so later assertions - // (e.g. RejectsInvalidK11) exercise the real verifier. - vm.mockCall( - address(k11), - abi.encodeWithSelector(K11Verifier.verifyAssertion.selector), - abi.encode(true) - ); - vm.mockCall( - address(k11), - abi.encodeWithSelector(K11Verifier.readSignCount.selector), - abi.encode(uint32(0)) - ); + // Account model (#164 E7): no self-attestation — the master is the + // operator's P256Account (a contract; `master` is etched in setUp), and the + // call records operatorMasterWallet = msg.sender. Real passkey verification + // is the account's validateUserOp (EntryPoint), exercised in the Rust/CLI + // integration tests; the registry only gates on msg.sender being a contract. vm.prank(master); registry.registerFirstMasterDevice( deviceKeyHashMaster, @@ -563,10 +547,8 @@ contract AgentKeysV1Test is Test { k11RpIdHash, k11PubX, k11PubY, - fullRoles, - _bogusAssertion(bytes32(0)) + fullRoles ); - vm.clearMockedCalls(); } /// @dev Bogus assertion for SidecarRegistry — fails challenge or P-256 diff --git a/crates/agentkeys-cli/Cargo.toml b/crates/agentkeys-cli/Cargo.toml index 7563e8b3..c9d56bdf 100644 --- a/crates/agentkeys-cli/Cargo.toml +++ b/crates/agentkeys-cli/Cargo.toml @@ -17,6 +17,7 @@ agentkeys-core = { workspace = true } agentkeys-memory-engine = { workspace = true } agentkeys-memory-openviking = { workspace = true } agentkeys-provisioner = { path = "../agentkeys-provisioner" } +agentkeys-backend-client = { workspace = true } # #216 cred-fetch primitive (agent vaulted-key) clap = { version = "4", features = ["derive", "env"] } tokio = { workspace = true } serde_json = { workspace = true } diff --git a/crates/agentkeys-cli/src/cred_admin.rs b/crates/agentkeys-cli/src/cred_admin.rs new file mode 100644 index 00000000..540da6f4 --- /dev/null +++ b/crates/agentkeys-cli/src/cred_admin.rs @@ -0,0 +1,120 @@ +//! Agent-side credential fetch (#216) — the agent pulls its AUTHORIZED +//! credential (e.g. its LLM key) from the vault to *use* it. Unlike the master's +//! store/list (which never reveal a secret), this returns the decrypted +//! plaintext: the agent needs the actual secret to make calls. It is gated by the +//! agent's `cred:` scope — the broker won't mint a cred-fetch cap the +//! actor isn't scoped for, and the worker re-checks the cap. +//! +//! Routes through the shared `agentkeys-backend-client` (issue #204): cap-mint +//! (`CredFetch`) → per-actor STS under the VAULT role → cred worker +//! `/v1/cred/fetch` → decrypt → plaintext. No re-typed wire shapes. + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; + +use agentkeys_backend_client::{ + normalize_omni_0x, BackendClient, CapMintOp, CapMintRequest, CredFetchInput, CredStoreInput, +}; + +/// Fetch + decrypt the credential `service` the actor is authorized for, returning +/// the plaintext secret. `operator_omni` == `actor_omni` for a master-self fetch; +/// for an agent they are (master, agent). The omnis are normalized to the broker's +/// `0x`-prefixed shape (issue #200 — the bare-vs-0x drift normalizer). +#[allow(clippy::too_many_arguments)] +pub async fn cred_fetch( + service: &str, + operator_omni: &str, + actor_omni: &str, + device_key_hash: &str, + session_bearer: &str, + broker_url: &str, + cred_url: &str, + vault_role_arn: &str, + region: &str, +) -> Result { + let client = BackendClient::new( + Some(broker_url.to_string()), + None, // memory_url + None, // audit_url + Some(cred_url.to_string()), + Some(session_bearer.to_string()), // agent_session_bearer → per-actor STS + None, // memory_role_arn + Some(vault_role_arn.to_string()), + region.to_string(), + ); + let cap = client + .cap_mint( + CapMintOp::CredFetch, + CapMintRequest { + operator_omni: normalize_omni_0x(operator_omni), + actor_omni: normalize_omni_0x(actor_omni), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds: 300, + }, + session_bearer, + ) + .await + .with_context(|| format!("cap-mint cred-fetch for service `{service}`"))?; + let result = client + .cred_fetch(CredFetchInput { cap }) + .await + .with_context(|| format!("cred worker fetch for service `{service}`"))?; + let bytes = STANDARD + .decode(&result.plaintext_b64) + .context("decode cred plaintext_b64")?; + String::from_utf8(bytes).context("cred plaintext is not valid UTF-8") +} + +/// Vault the credential `service` = `secret` (the symmetric store half of +/// [`cred_fetch`]). `operator_omni` == `actor_omni` for a master-self store (the +/// master vaulting into its OWN vault — the common case, e.g. seeding the agent's +/// LLM key). Returns the worker's S3 key. Routes through the shared +/// `agentkeys-backend-client` (#204): cap-mint (`CredStore`) → per-actor STS under +/// the VAULT role → cred worker `/v1/cred/store` → encrypt + S3 PUT. +#[allow(clippy::too_many_arguments)] +pub async fn cred_store( + service: &str, + secret: &str, + operator_omni: &str, + actor_omni: &str, + device_key_hash: &str, + session_bearer: &str, + broker_url: &str, + cred_url: &str, + vault_role_arn: &str, + region: &str, +) -> Result { + let client = BackendClient::new( + Some(broker_url.to_string()), + None, // memory_url + None, // audit_url + Some(cred_url.to_string()), + Some(session_bearer.to_string()), // session bearer → per-actor STS + None, // memory_role_arn + Some(vault_role_arn.to_string()), + region.to_string(), + ); + let cap = client + .cap_mint( + CapMintOp::CredStore, + CapMintRequest { + operator_omni: normalize_omni_0x(operator_omni), + actor_omni: normalize_omni_0x(actor_omni), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds: 300, + }, + session_bearer, + ) + .await + .with_context(|| format!("cap-mint cred-store for service `{service}`"))?; + let result = client + .cred_store(CredStoreInput { + cap, + plaintext_b64: STANDARD.encode(secret.as_bytes()), + }) + .await + .with_context(|| format!("cred worker store for service `{service}`"))?; + Ok(result.s3_key) +} diff --git a/crates/agentkeys-cli/src/k11_webauthn.rs b/crates/agentkeys-cli/src/k11_webauthn.rs index 16258eca..e6734ae1 100644 --- a/crates/agentkeys-cli/src/k11_webauthn.rs +++ b/crates/agentkeys-cli/src/k11_webauthn.rs @@ -1025,6 +1025,70 @@ pub fn extract_chain_assertion( }) } +/// The 5 on-chain fields a browser WebAuthn `get()` assertion (over a UserOp +/// hash) decodes into — what the registry/account verifier needs. The daemon +/// web-flow register/accept submit path (issue #225 / E7) uses this. +pub struct WebUserOpAssertion { + /// `0x` || hex(authenticatorData). + pub authenticator_data_hex: String, + /// `0x` || hex(clientDataJSON utf-8 bytes). + pub client_data_json_hex: String, + /// Byte offset of the challenge value in clientDataJSON (after `"challenge":"`). + pub challenge_location: u64, + /// `0x` || hex(r), 32-byte big-endian. + pub r_hex: String, + /// `0x` || hex(s), 32-byte big-endian. + pub s_hex: String, +} + +/// Decode a browser WebAuthn `get()` assertion into the on-chain fields the +/// `K11Verifier`/`P256Account` needs (DER → (r,s); challenge offset in the +/// clientDataJSON). The three inputs are base64url, exactly as +/// `apps/parent-control/lib/webauthn.ts::getAssertionOverHash` emits them. Does +/// NOT verify the signature (the chain does) — it only extracts the fields. +pub fn decode_web_userop_assertion( + authenticator_data_b64url: &str, + client_data_json_b64url: &str, + signature_der_b64url: &str, +) -> Result { + let b64 = |field: &str, s: &str| -> Result, WebauthnError> { + URL_SAFE_NO_PAD + .decode(s.trim()) + .map_err(|e| WebauthnError::SerdeJson(format!("{field} base64url: {e}"))) + }; + let authenticator_data = b64("authenticator_data", authenticator_data_b64url)?; + let client_data_json = b64("client_data_json", client_data_json_b64url)?; + let signature_der = b64("signature", signature_der_b64url)?; + + let sig = Signature::from_der(&signature_der) + .map_err(|e| WebauthnError::SigParse(format!("der → (r,s): {e}")))?; + let sig_bytes = sig.to_bytes(); + if sig_bytes.len() != 64 { + return Err(WebauthnError::SigParse(format!( + "sig.to_bytes() returned {} bytes; expected 64", + sig_bytes.len() + ))); + } + + let cdj_utf8 = std::str::from_utf8(&client_data_json) + .map_err(|e| WebauthnError::SerdeJson(format!("cdj utf-8: {e}")))?; + let needle = "\"challenge\":\""; + let challenge_location = cdj_utf8 + .find(needle) + .map(|p| (p + needle.len()) as u64) + .ok_or_else(|| { + WebauthnError::SerdeJson(format!("clientDataJSON missing {needle:?}: {cdj_utf8}")) + })?; + + Ok(WebUserOpAssertion { + authenticator_data_hex: format!("0x{}", hex::encode(&authenticator_data)), + client_data_json_hex: format!("0x{}", hex::encode(&client_data_json)), + challenge_location, + r_hex: format!("0x{}", hex::encode(&sig_bytes[0..32])), + s_hex: format!("0x{}", hex::encode(&sig_bytes[32..64])), + }) +} + struct AttestedCredential { rp_id_hash: Vec, flags: u8, diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index d94f486b..3339b80c 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; pub mod agent_admin; +pub mod cred_admin; pub mod device_session; pub mod hook; pub mod k11; diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index cb0f3839..ecef8689 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -392,6 +392,69 @@ enum Commands { #[command(subcommand)] action: AgentAction, }, + /// Credential fetch (#216) — the agent pulls its authorized `cred:` + /// from the vault to *use* it (e.g. its LLM key) at wire time. + Cred { + #[command(subcommand)] + action: CredAction, + }, +} + +#[derive(Subcommand)] +enum CredAction { + /// Fetch + decrypt a stored credential's secret (#216). Gated by the actor's + /// `cred:` scope; prints the plaintext to stdout. The agent's + /// identity/session come from the wire context (flags or env). + Fetch { + /// The credential service id (e.g. `openrouter`). + service: String, + #[arg(long, env = "AGENTKEYS_OPERATOR_OMNI")] + operator_omni: String, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor_omni: String, + #[arg(long, env = "AGENTKEYS_DEVICE_KEY_HASH")] + device_key_hash: String, + #[arg(long, env = "AGENTKEYS_SESSION_BEARER")] + session_bearer: String, + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + broker_url: String, + #[arg(long, env = "AGENTKEYS_WORKER_CRED_URL")] + cred_url: String, + #[arg(long, env = "VAULT_ROLE_ARN")] + vault_role_arn: String, + #[arg(long, env = "REGION", default_value = "us-east-1")] + region: String, + }, + /// Vault a credential (#216, the store half of `fetch`). Master-self by + /// default (operator == actor); seeds the agent's authorized key (e.g. the + /// LLM key the agent later cred-fetches). Prints the worker S3 key. + Store { + /// The credential service id (e.g. `openrouter`). + service: String, + /// The secret to vault. Prefer `--secret-env NAME` to keep it off argv. + #[arg(long, conflicts_with = "secret_env")] + secret: Option, + /// Read the secret from this env var instead of `--secret` (keeps the + /// plaintext out of the process list / shell history). + #[arg(long)] + secret_env: Option, + #[arg(long, env = "AGENTKEYS_OPERATOR_OMNI")] + operator_omni: String, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor_omni: String, + #[arg(long, env = "AGENTKEYS_DEVICE_KEY_HASH")] + device_key_hash: String, + #[arg(long, env = "AGENTKEYS_SESSION_BEARER")] + session_bearer: String, + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + broker_url: String, + #[arg(long, env = "AGENTKEYS_WORKER_CRED_URL")] + cred_url: String, + #[arg(long, env = "VAULT_ROLE_ARN")] + vault_role_arn: String, + #[arg(long, env = "REGION", default_value = "us-east-1")] + region: String, + }, } #[derive(Subcommand)] @@ -1389,6 +1452,72 @@ async fn main() { session_bearer, } => agentkeys_cli::agent_admin::agent_pending(broker_url, session_bearer).await, }, + Commands::Cred { action } => match action { + CredAction::Fetch { + service, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + } => { + agentkeys_cli::cred_admin::cred_fetch( + service, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + ) + .await + } + CredAction::Store { + service, + secret, + secret_env, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + } => { + let resolved: anyhow::Result = match (secret, secret_env) { + (Some(s), _) => Ok(s.clone()), + (None, Some(env_name)) => std::env::var(env_name).map_err(|_| { + anyhow::anyhow!("--secret-env {env_name} is not set in the environment") + }), + (None, None) => Err(anyhow::anyhow!( + "provide the secret via --secret or --secret-env " + )), + }; + match resolved { + Ok(secret_value) => agentkeys_cli::cred_admin::cred_store( + service, + &secret_value, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + ) + .await + .map(|s3_key| format!("stored `{service}` → {s3_key}")), + Err(e) => Err(e), + } + } + }, }; match result { diff --git a/crates/agentkeys-core/chain-profiles/heima.json b/crates/agentkeys-core/chain-profiles/heima.json index 323c9eb9..44033402 100644 --- a/crates/agentkeys-core/chain-profiles/heima.json +++ b/crates/agentkeys-core/chain-profiles/heima.json @@ -41,25 +41,25 @@ "contracts": [ { "name": "AgentKeysScope", - "address": "0xd44b375daefc65768f417d0f0125b68d5ba7df3b", + "address": "0xe6421A01C0469C034a25f561D01A62B520A3DB0b", "purpose": "per-actor scope grants — services, namespaces, spend/time windows", "deployed_at": "Heima mainnet · stage-1" }, { "name": "SidecarRegistry", - "address": "0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE", + "address": "0xC63E6f6441A54093E3593d704a2Dc4E13382EF4E", "purpose": "D_pub ↔ (operator_omni, actor_omni, roles) device bindings + K11 cred storage", "deployed_at": "Heima mainnet · stage-1" }, { "name": "K3EpochCounter", - "address": "0x6c9e675c699a06acefbc156afdee6bfbfe32ccb3", + "address": "0x9B872ccd570336164D78ea58ACbb060C8B4505D6", "purpose": "current K3 epoch; bumps trigger signer-side KEK + K4 derivation rotation", "deployed_at": "Heima mainnet · stage-1" }, { "name": "CredentialAudit", - "address": "0x63c4545ac01c77cc74044f25b8edea3880224577", + "address": "0x49BD708A296df20Aa1aE6C938877A24Cae0BA153", "purpose": "per-actor append-only audit log + tier-2 Merkle root anchor every 2 min", "deployed_at": "Heima mainnet · stage-1" }, @@ -74,6 +74,25 @@ "address": "0x5a441431f08e0f5f5ed10659620cb4e0e814e627", "purpose": "K11 WebAuthn assertion verifier used by scope + registry master mutations", "deployed_at": "Heima mainnet · pre-deployed" + }, + { + "name": "EntryPoint", + "address": "0x6672E1b315332167aBA12E0B1d3532a7e9B1ADE9", + "purpose": "ERC-4337 v0.7 EntryPoint (canonical eth-infinitism) — handleOps entry for passkey-account master UserOps", + "deployed_at": "Heima mainnet · #164" + }, + { + "name": "P256AccountFactory", + "address": "0x1ccCe65b22De81aDA4F378FeAf7503d93f5d27a3", + "purpose": "CREATE2 factory for P256Account passkey-master smart-accounts; constructor(entryPoint, k11Verifier)", + "deployed_at": "Heima mainnet · #164" + }, + { + "name": "VerifyingPaymaster", + "address": "0xca36550d30e2E4dF927c53C3a5272A319D427602", + "purpose": "#164 E6 / #225 — broker-co-signed gas sponsorship for the K11-gated accept UserOp (one shared EntryPoint deposit; the J1 Sybil gate)", + "deployed_at": "Heima mainnet · #225" } - ] + ], + "contract_set_version": "0.3" } diff --git a/crates/agentkeys-core/src/audit/calldata.rs b/crates/agentkeys-core/src/audit/calldata.rs index 37984955..7bc4a0b9 100644 --- a/crates/agentkeys-core/src/audit/calldata.rs +++ b/crates/agentkeys-core/src/audit/calldata.rs @@ -183,15 +183,16 @@ pub const REGISTRY: &[FnDef] = &[ ("assertion", "(bytes32,bytes,bytes,uint256,uint256,uint256)"), ], }, - // src↔deploy divergence (codex review #153): the DEPLOYED AgentKeysScope - // (mainnet 0xd44b375…, bytecode-verified) + the operator scripts use the + // Account-auth cutover landed 2026-06-08 (#164 E3 / #225): the LIVE + // AgentKeysScope (address in the chain profile) is now the no-tuple `setScope` + // (sel 0xd8e9e3c6) / `revokeScope(bytes32,bytes32)` (sel 0xdcff8c5b) form + // below — authorization moved upstream to the 4337 account's + // validateUserOp. The pre-cutover tuple forms // `setScopeWithWebauthn(...,K11Assertion)` / `revokeScope(...,K11Assertion)` - // forms above — selectors 0x864ae93c / 0x6f37dd80 (the struct expands in the - // selector). `src/AgentKeysScope.sol` (#164) has since moved to the no-tuple - // `setScope` / - // `revokeScope(bytes32,bytes32)`. Register BOTH so the decoder recognizes - // live calldata today AND post-redeploy calldata tomorrow (distinct - // selectors, no collision). Drop the tuple forms once the redeploy lands. + // (selectors 0x864ae93c / 0x6f37dd80) are retained here — distinct + // selectors, no collision — ONLY so this decoder can still resolve orphaned + // pre-cutover calldata at the old address 0xd44b375…. The daemon's LIVE + // scope.grant mapping (audit_decode::onchain_fn) points at `setScope`. FnDef { contract: "AgentKeysScope", name: "setScope", diff --git a/crates/agentkeys-core/src/chain_profile.rs b/crates/agentkeys-core/src/chain_profile.rs index 2dd7db3e..7f6cacd4 100644 --- a/crates/agentkeys-core/src/chain_profile.rs +++ b/crates/agentkeys-core/src/chain_profile.rs @@ -89,13 +89,23 @@ pub struct ChainProfile { pub finality: FinalityConfig, pub gas: GasConfig, pub deploy: DeployConfig, - /// Deployed stage-1 contract registry for this chain — the addresses the + /// Deployed contract registry for this chain — the addresses the /// broker/daemon/workers read and the parent-control UI displays (#153). - /// Empty for chains where AgentKeys contracts aren't deployed. This is the - /// single embedded source of truth (mirrors `docs/spec/deployed-contracts.md`); - /// operators targeting a custom deploy override it via a profile file. + /// Empty for chains where AgentKeys contracts aren't deployed. **This is the + /// single machine-readable source of truth for deployed addresses**; the + /// human view `docs/spec/deployed-contracts.md` points HERE, and + /// `scripts/heima-bring-up.sh` rewrites this array (+ `contract_set_version`) + /// programmatically on every fresh deploy. Operators targeting a custom + /// deploy override it via a profile file. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub contracts: Vec, + /// Version of the deployed contract SET for this chain (e.g. `"0.1"`). The + /// idempotency key: `crates/agentkeys-chain/VERSION` is the EXPECTED source + /// version; a deploy redeploys + bumps this only when they differ (no + /// bytecode comparison — Solidity metadata + immutables make it unreliable). + /// `None` for chains with no deployed contracts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contract_set_version: Option, /// Present for dev/test chains; absent for production. See /// `DevEnvironment` doc-comment for the convention around /// `is_development_default`. @@ -434,36 +444,53 @@ mod tests { } #[test] - fn heima_carries_stage1_contract_registry() { + fn heima_carries_full_contract_registry_and_version() { let p = ChainProfile::load_builtin("heima").unwrap(); - // The 4 stage-1 core contracts the audit decode + UI reference must be - // present with the canonical mainnet addresses (mirrors - // docs/spec/deployed-contracts.md). Pin them so a profile edit that - // drops/renames one fails CI. - for (name, addr) in [ - ( - "AgentKeysScope", - "0xd44b375daefc65768f417d0f0125b68d5ba7df3b", - ), - ( - "SidecarRegistry", - "0x1Ac62f1C2D828476a5D784e850a700dC1f17e0bE", - ), - ( - "K3EpochCounter", - "0x6c9e675c699a06acefbc156afdee6bfbfe32ccb3", - ), - ( - "CredentialAudit", - "0x63c4545ac01c77cc74044f25b8edea3880224577", - ), - ] { + // heima.json is now the machine-readable SOURCE OF TRUTH for deployed + // addresses (scripts/heima-bring-up.sh rewrites the contracts[] array + + // contract_set_version on every deploy). So pin SHAPE + COMPLETENESS, + // NOT exact address values: every expected contract present, a purpose, + // a well-formed 20-byte address, and a contract-set version. Pinning + // exact addresses here would just duplicate the JSON and break this test + // on every legitimate redeploy. + let expected = [ + "AgentKeysScope", + "SidecarRegistry", + "K3EpochCounter", + "CredentialAudit", + "P256Verifier", + "K11Verifier", + "EntryPoint", + "P256AccountFactory", + "VerifyingPaymaster", + ]; + for name in expected { let c = p .contract(name) .unwrap_or_else(|| panic!("heima profile must carry {name}")); - assert_eq!(c.address, addr, "{name} address drift"); assert!(!c.purpose.is_empty(), "{name} must carry a purpose"); + let hexpart = c.address.strip_prefix("0x").unwrap_or(&c.address); + assert_eq!( + hexpart.len(), + 40, + "{name} address must be 20 bytes: {}", + c.address + ); + assert!( + hexpart.chars().all(|ch| ch.is_ascii_hexdigit()), + "{name} address must be hex: {}", + c.address + ); } + // The contract-set version (the deploy idempotency key) must be recorded. + let version = p + .contract_set_version + .as_deref() + .expect("heima profile must record contract_set_version"); + assert!( + !version.is_empty(), + "contract_set_version must be non-empty" + ); // Case-insensitive lookup + miss path. assert!(p.contract("credentialaudit").is_some()); assert!(p.contract("NotAContract").is_none()); @@ -474,7 +501,7 @@ mod tests { // #153: the chain page + audit decode link to the live Heima EVM // explorer — contracts under /contract/, accounts under /address/. let p = ChainProfile::load_builtin("heima").unwrap(); - let addr = "0x63c4545ac01c77cc74044f25b8edea3880224577"; + let addr = "0x8336968273D26C4AcB7B29a76A442339FC10533D"; assert_eq!( p.explorer.contract_url(addr), format!("https://explorer.heima.network/contract/{addr}") diff --git a/crates/agentkeys-core/src/erc4337.rs b/crates/agentkeys-core/src/erc4337.rs new file mode 100644 index 00000000..a5c587fd --- /dev/null +++ b/crates/agentkeys-core/src/erc4337.rs @@ -0,0 +1,386 @@ +//! ERC-4337 accept-UserOp callData builders (#225 / #164 E7). +//! +//! Pure ABI encoders for the inner calls of the **agent-accept batch** — the +//! single `executeBatch` UserOp that lands the device binding (P.2) and the scope +//! grant (P.3) atomically, in one block, gated by ONE master passkey (K11) +//! signature over the `userOpHash`: +//! +//! ```text +//! P256Account.executeBatch( +//! [SidecarRegistry, AgentKeysScope ], +//! [0, 0 ], +//! [registerAgentDevice(...), setScope(...) ]) +//! ``` +//! +//! These functions produce the raw bytes that become [`sponsor::PackedUserOp`]'s +//! `call_data` (the broker owns the sponsored-UserOp envelope; this owns the +//! inner intent). They are byte-exact with the deployed contracts +//! (`crates/agentkeys-chain/src/{SidecarRegistry,AgentKeysScope,P256Account}.sol`) +//! and golden-tested against `cast calldata` (see `tests`). +//! +//! Why a hand-rolled encoder (no `alloy`/`ethabi`): the repo keeps the EVM +//! surface dependency-free and byte-explicit — mirroring +//! `agentkeys-broker-server/src/sponsor.rs` and `audit/calldata.rs`, whose public +//! [`selector`] this reuses so selectors never drift. + +use crate::audit::calldata::selector; +use crate::device_crypto::keccak256; + +const WORD: usize = 32; + +/// Prefix of the master-account credential-id preimage (see [`master_cred_id_hash`]). +/// **Terminology source-of-truth:** the bash literal in +/// `harness/scripts/erc4337-register-master.sh` (`cast keccak +/// "agentkeys-register-cred:0x$omni"`) MUST match this exactly. +pub const MASTER_CRED_ID_PREFIX: &str = "agentkeys-register-cred:0x"; + +/// The synthetic credential-id hash that keys a master's `P256Account` signer: +/// `keccak256("agentkeys-register-cred:0x" + lowercase_hex(operator_omni))`. +/// +/// This is the value `erc4337-register-master.sh` creates the account with (the +/// on-chain `signers[credIdHash]` entry + the CREATE2 salt input), so the +/// accept-submit UserOp signature MUST carry the SAME hash — `P256Account` looks +/// the signer up by it (reverts `UnknownSigner` otherwise). The browser's raw +/// credential id is NOT the key; this operator-derived value is. +pub fn master_cred_id_hash(operator_omni: &[u8; 32]) -> [u8; 32] { + let preimage = format!("{MASTER_CRED_ID_PREFIX}{}", hex::encode(operator_omni)); + keccak256(preimage.as_bytes()) +} + +/// Args for `SidecarRegistry.registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)` +/// — the P.2 device binding. `actor_omni` is also the agent's omni for the P.3 +/// scope grant, so [`accept_batch_calldata`] threads it into both calls. +#[derive(Clone, Debug)] +pub struct AgentRegister { + pub device_key_hash: [u8; 32], + pub operator_omni: [u8; 32], + pub actor_omni: [u8; 32], + pub link_code_redemption: Vec, + pub agent_pop_sig: Vec, +} + +/// Args for `AgentKeysScope.setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` +/// — the P.3 scope grant. `services` are the signed `bytes32` service ids +/// (`memory:` / `cred:`); the caps mirror the on-chain `Scope`. +#[derive(Clone, Debug)] +pub struct ScopeGrant { + pub services: Vec<[u8; 32]>, + pub read_only: bool, + pub max_per_call: u128, + pub max_per_period: u128, + pub max_total: u128, + pub period_seconds: u32, +} + +fn word_u128(n: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&n.to_be_bytes()); + w +} + +fn addr_word(a: &[u8; 20]) -> [u8; 32] { + let mut w = [0u8; 32]; + w[12..].copy_from_slice(a); + w +} + +fn bool_word(b: bool) -> [u8; 32] { + let mut w = [0u8; 32]; + w[31] = b as u8; + w +} + +/// ABI `bytes`: `len(32) ‖ data ‖ zero-pad to a 32-byte multiple`. +fn enc_bytes(b: &[u8]) -> Vec { + let pad = (WORD - (b.len() % WORD)) % WORD; + let mut out = Vec::with_capacity(WORD + b.len() + pad); + out.extend_from_slice(&word_u128(b.len() as u128)); + out.extend_from_slice(b); + out.resize(out.len() + pad, 0); + out +} + +/// `registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)` calldata (P.2). +pub fn register_agent_device_calldata(r: &AgentRegister) -> Vec { + let sel = selector("registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)"); + let enc_link = enc_bytes(&r.link_code_redemption); + let enc_pop = enc_bytes(&r.agent_pop_sig); + // Head: dkh, op, actor (inline bytes32) + 2 offsets for the dynamic `bytes`. + let head = 5 * WORD; + let off_link = head; + let off_pop = head + enc_link.len(); + + let mut out = Vec::with_capacity(4 + head + enc_link.len() + enc_pop.len()); + out.extend_from_slice(&sel); + out.extend_from_slice(&r.device_key_hash); + out.extend_from_slice(&r.operator_omni); + out.extend_from_slice(&r.actor_omni); + out.extend_from_slice(&word_u128(off_link as u128)); + out.extend_from_slice(&word_u128(off_pop as u128)); + out.extend_from_slice(&enc_link); + out.extend_from_slice(&enc_pop); + out +} + +/// `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` calldata (P.3). +pub fn set_scope_calldata( + operator_omni: &[u8; 32], + agent_omni: &[u8; 32], + g: &ScopeGrant, +) -> Vec { + let sel = selector("setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)"); + // Head is 8 words; `services` is the only dynamic arg, its data follows the head. + let off_services = 8 * WORD; + + let mut out = Vec::new(); + out.extend_from_slice(&sel); + out.extend_from_slice(operator_omni); + out.extend_from_slice(agent_omni); + out.extend_from_slice(&word_u128(off_services as u128)); + out.extend_from_slice(&bool_word(g.read_only)); + out.extend_from_slice(&word_u128(g.max_per_call)); + out.extend_from_slice(&word_u128(g.max_per_period)); + out.extend_from_slice(&word_u128(g.max_total)); + out.extend_from_slice(&word_u128(g.period_seconds as u128)); + // Tail: services array — len ‖ each bytes32 element. + out.extend_from_slice(&word_u128(g.services.len() as u128)); + for s in &g.services { + out.extend_from_slice(s); + } + out +} + +/// `executeBatch(address[],uint256[],bytes[])` calldata for [`P256Account`] — runs +/// each `(dest[i], values[i], func[i])` call atomically (any inner revert reverts +/// the whole batch). `values` are wei (u128 covers every realistic call value; the +/// accept batch uses 0). +pub fn execute_batch_calldata(dest: &[[u8; 20]], values: &[u128], func: &[Vec]) -> Vec { + let sel = selector("executeBatch(address[],uint256[],bytes[])"); + + // address[] dest: len ‖ each address word. + let mut enc_dest = Vec::with_capacity(WORD * (1 + dest.len())); + enc_dest.extend_from_slice(&word_u128(dest.len() as u128)); + for a in dest { + enc_dest.extend_from_slice(&addr_word(a)); + } + + // uint256[] values: len ‖ each value word. + let mut enc_value = Vec::with_capacity(WORD * (1 + values.len())); + enc_value.extend_from_slice(&word_u128(values.len() as u128)); + for n in values { + enc_value.extend_from_slice(&word_u128(*n)); + } + + // bytes[] func: len ‖ offset words (relative to AFTER the len word) ‖ each bytes elem. + let elems: Vec> = func.iter().map(|f| enc_bytes(f)).collect(); + let mut enc_func = Vec::new(); + enc_func.extend_from_slice(&word_u128(func.len() as u128)); + let mut running = func.len() * WORD; + for e in &elems { + enc_func.extend_from_slice(&word_u128(running as u128)); + running += e.len(); + } + for e in &elems { + enc_func.extend_from_slice(e); + } + + // Head: 3 offsets (dest, values, func), each relative to the args start. + let head = 3 * WORD; + let off_dest = head; + let off_value = head + enc_dest.len(); + let off_func = head + enc_dest.len() + enc_value.len(); + + let mut out = Vec::with_capacity(4 + head + enc_dest.len() + enc_value.len() + enc_func.len()); + out.extend_from_slice(&sel); + out.extend_from_slice(&word_u128(off_dest as u128)); + out.extend_from_slice(&word_u128(off_value as u128)); + out.extend_from_slice(&word_u128(off_func as u128)); + out.extend_from_slice(&enc_dest); + out.extend_from_slice(&enc_value); + out.extend_from_slice(&enc_func); + out +} + +/// **The #225 headline** — the atomic accept batch as one `executeBatch` callData. +/// +/// Composes `registerAgentDevice` (P.2) + `setScope` (P.3) into a single +/// [`execute_batch_calldata`] over `[registry, scope]`. The agent's `actor_omni` +/// from the register IS the `agentOmni` of the scope grant, threaded here by +/// construction so the two inner calls can never disagree on which agent they +/// bind. The result is signed once (K11) as the master UserOp's `call_data`. +pub fn accept_batch_calldata( + registry: &[u8; 20], + scope: &[u8; 20], + reg: &AgentRegister, + grant: &ScopeGrant, +) -> Vec { + let register_cd = register_agent_device_calldata(reg); + let scope_cd = set_scope_calldata(®.operator_omni, ®.actor_omni, grant); + execute_batch_calldata( + &[*registry, *scope], + &[0u128, 0u128], + &[register_cd, scope_cd], + ) +} + +/// `abi.encode(bytes32 credIdHash, bytes authenticatorData, bytes clientDataJSON, +/// uint256 challengeLocation, uint256 r, uint256 s)` — the **P256Account UserOp +/// signature** (`P256Account.sol::validateUserOp`, identical to the byte spec the +/// CLI's `k11 webauthn-userop-sign` + `harness/erc4337-master-e8.sh` produce). The +/// browser's WebAuthn assertion (`navigator.credentials.get()` over the userOpHash) +/// is encoded into this so `EntryPoint.handleOps` accepts the op. Golden-tested. +pub fn encode_webauthn_signature( + cred_id_hash: &[u8; 32], + authenticator_data: &[u8], + client_data_json: &[u8], + challenge_location: u128, + r: &[u8; 32], + s: &[u8; 32], +) -> Vec { + let enc_auth = enc_bytes(authenticator_data); + let head = 6 * WORD; + let off_auth = head; + let off_cdj = head + enc_auth.len(); + + let mut out = Vec::with_capacity(head + enc_auth.len() + WORD + client_data_json.len()); + out.extend_from_slice(cred_id_hash); + out.extend_from_slice(&word_u128(off_auth as u128)); + out.extend_from_slice(&word_u128(off_cdj as u128)); + out.extend_from_slice(&word_u128(challenge_location)); + out.extend_from_slice(r); + out.extend_from_slice(s); + out.extend_from_slice(&enc_auth); + out.extend_from_slice(&enc_bytes(client_data_json)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b32(x: u8) -> [u8; 32] { + [x; 32] + } + + fn sample_register() -> AgentRegister { + AgentRegister { + device_key_hash: b32(0x11), + operator_omni: b32(0x22), + actor_omni: b32(0x33), + link_code_redemption: hex::decode("deadbeef").unwrap(), + agent_pop_sig: vec![0x55; 65], + } + } + + fn sample_grant() -> ScopeGrant { + ScopeGrant { + services: vec![b32(0xaa), b32(0xbb)], + read_only: true, + max_per_call: 1000, + max_per_period: 2000, + max_total: 0, + period_seconds: 86400, + } + } + + fn addr(last: u8) -> [u8; 20] { + let mut a = [0u8; 20]; + a[19] = last; + a + } + + // Golden vectors produced by foundry `cast calldata` for the exact same inputs + // (the authoritative ABI encoder); see the commit message for the commands. + fn norm(s: &str) -> String { + s.trim().trim_start_matches("0x").to_string() + } + const GOLDEN_REGISTER: &str = include_str!("testdata/erc4337_register.hex"); + const GOLDEN_SET_SCOPE: &str = include_str!("testdata/erc4337_set_scope.hex"); + const GOLDEN_EXECUTE_BATCH: &str = include_str!("testdata/erc4337_execute_batch.hex"); + + #[test] + fn register_agent_device_matches_cast() { + let got = hex::encode(register_agent_device_calldata(&sample_register())); + assert_eq!(got, norm(GOLDEN_REGISTER)); + } + + #[test] + fn set_scope_matches_cast() { + let got = hex::encode(set_scope_calldata(&b32(0x22), &b32(0x33), &sample_grant())); + assert_eq!(got, norm(GOLDEN_SET_SCOPE)); + } + + #[test] + fn accept_batch_matches_cast() { + // dest = [registry 0x..a1, scope 0x..a2], values = [0,0], + // func = [registerAgentDevice(...), setScope(...)] — the atomic P.2+P.3 batch. + let got = hex::encode(accept_batch_calldata( + &addr(0xa1), + &addr(0xa2), + &sample_register(), + &sample_grant(), + )); + assert_eq!(got, norm(GOLDEN_EXECUTE_BATCH)); + } + + #[test] + fn batch_is_atomic_pair_of_the_two_inner_calls() { + // The batch's func[] is exactly [register_cd, set_scope_cd] — the property the + // one-block win relies on (no third call can sneak in; both bind the same agent). + let reg = sample_register(); + let grant = sample_grant(); + let register_cd = register_agent_device_calldata(®); + let scope_cd = set_scope_calldata(®.operator_omni, ®.actor_omni, &grant); + let batch = accept_batch_calldata(&addr(0xa1), &addr(0xa2), ®, &grant); + // both inner callDatas appear verbatim inside the batch bytes. + assert!(find_subslice(&batch, ®ister_cd).is_some()); + assert!(find_subslice(&batch, &scope_cd).is_some()); + // setScope's agentOmni is the register's actor_omni (threaded by construction). + assert_eq!(&scope_cd[4 + 32..4 + 64], ®.actor_omni); + } + + fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) + } + + #[test] + fn webauthn_signature_matches_cast() { + // cast abi-encode "x(bytes32,bytes,bytes,uint256,uint256,uint256)" + // 0xcc..cc 0xdead 0xbeef 13 7 9 + let golden = include_str!("testdata/erc4337_webauthn_sig.hex") + .trim() + .trim_start_matches("0x") + .to_string(); + let mut r = [0u8; 32]; + r[31] = 7; + let mut s = [0u8; 32]; + s[31] = 9; + let got = hex::encode(encode_webauthn_signature( + &[0xcc; 32], + &hex::decode("dead").unwrap(), + &hex::decode("beef").unwrap(), + 13, + &r, + &s, + )); + assert_eq!(got, golden); + } + + #[test] + fn master_cred_id_hash_pins_the_register_convention() { + // Pins the EXACT preimage erc4337-register-master.sh hashes: + // keccak256("agentkeys-register-cred:0x" + lowercase-hex(omni)), + // with NO `0x` on the omni and NO uppercase. A drift here = an + // on-chain `UnknownSigner` at accept time. + let omni = [0x22u8; 32]; + let expected = keccak256( + b"agentkeys-register-cred:0x2222222222222222222222222222222222222222222222222222222222222222", + ); + assert_eq!(master_cred_id_hash(&omni), expected); + assert_eq!(MASTER_CRED_ID_PREFIX, "agentkeys-register-cred:0x"); + assert_ne!( + master_cred_id_hash(&[0x22; 32]), + master_cred_id_hash(&[0x33; 32]) + ); + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index 5b5926c4..b3e544a7 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod backend; pub mod chain_profile; pub mod clear_signing; pub mod device_crypto; +pub mod erc4337; pub mod init_flow; pub mod mock_client; pub mod otp; diff --git a/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex b/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex new file mode 100644 index 00000000..a356f9e6 --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex @@ -0,0 +1 @@ +0x47e1da2a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000a100000000000000000000000000000000000000000000000000000000000000a20000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001649847ca9511111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164d8e9e3c6222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000007d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb00000000000000000000000000000000000000000000000000000000 diff --git a/crates/agentkeys-core/src/testdata/erc4337_register.hex b/crates/agentkeys-core/src/testdata/erc4337_register.hex new file mode 100644 index 00000000..0367ae9a --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_register.hex @@ -0,0 +1 @@ +0x9847ca9511111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555500000000000000000000000000000000000000000000000000000000000000 diff --git a/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex b/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex new file mode 100644 index 00000000..e6ac7ba7 --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex @@ -0,0 +1 @@ +0xd8e9e3c6222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000007d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb diff --git a/crates/agentkeys-core/src/testdata/erc4337_webauthn_sig.hex b/crates/agentkeys-core/src/testdata/erc4337_webauthn_sig.hex new file mode 100644 index 00000000..a624aed2 --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_webauthn_sig.hex @@ -0,0 +1 @@ +0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000090000000000000000000000000000000000000000000000000000000000000002dead0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000 diff --git a/crates/agentkeys-daemon/src/audit_decode.rs b/crates/agentkeys-daemon/src/audit_decode.rs index 5a8baeac..b9abbb01 100644 --- a/crates/agentkeys-daemon/src/audit_decode.rs +++ b/crates/agentkeys-daemon/src/audit_decode.rs @@ -163,9 +163,13 @@ fn onchain_fn(kind: &str) -> Option<(&'static FnDef, &'static str)> { "cap.pair" | "device.paired" => "registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)", "device.revoked" => "revokeAgentDevice(bytes32)", "scope.grant" => { - // Deployed mainnet form — the K11Assertion struct expands in the - // selector (0x864ae93c), so the canonical signature carries it. - "setScopeWithWebauthn(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32,(bytes32,bytes,bytes,uint256,uint256,uint256))" + // Account-auth form (live since the #164/#225 cutover, 2026-06-08): + // AgentKeysScope.setScope (selector 0xd8e9e3c6) — no inline K11 + // tuple; authorization moved upstream to the 4337 account's + // validateUserOp. (The pre-cutover setScopeWithWebauthn form, + // selector 0x864ae93c, is retained in calldata::REGISTRY only to + // decode orphaned pre-cutover calldata.) + "setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)" } _ => return None, }; @@ -282,6 +286,8 @@ fn calldata_args( ] } "device.revoked" => vec![json!(hash_hex(&event.actor_id))], + // Account-auth `setScope` (8 params, no inline K11 assertion tuple) — + // live since the #164/#225 cutover. Matches calldata::REGISTRY setScope. "scope.grant" => vec![ op, ac, @@ -291,7 +297,6 @@ fn calldata_args( json!(1u64), json!(10u64), json!(86400u64), - Value::Null, // assertion tuple — noted, not decoded ], _ => vec![], } @@ -352,9 +357,12 @@ mod tests { assert_eq!(out["tier"], json!("tier-2")); let tx = &out["tx"]; assert_eq!(tx["to_contract"], json!("CredentialAudit")); + // Redeploy-proof: assert against the SAME profile the decoder resolved from, + // not a pinned literal — the address changes on every contract redeploy + // (this test broke on the v0.2 redeploy because it hardcoded the v0.1 addr). assert_eq!( tx["to_address"], - json!("0x63c4545ac01c77cc74044f25b8edea3880224577") + json!(profile().contract("CredentialAudit").unwrap().address) ); let dec = &tx["decoded"]; assert_eq!(dec["selector"], json!("0xc1bf0e32")); @@ -375,11 +383,11 @@ mod tests { fn scope_grant_decodes_static_args_and_notes_tuple() { let out = decode_event(&event("scope.grant"), None, None, &profile()); let dec = &out["tx"]["decoded"]; - assert_eq!(dec["function"], json!("setScopeWithWebauthn")); + // Live form since the #164/#225 account-auth cutover. + assert_eq!(dec["function"], json!("setScope")); assert_eq!(out["tx"]["to_contract"], json!("AgentKeysScope")); assert_eq!(dec["args"][3]["name"], json!("readOnly")); assert_eq!(dec["args"][3]["value"], json!(false)); - assert!(dec["note"].as_str().is_some(), "tuple must be noted"); assert_eq!(out["envelope"]["op_kind_label"], json!("scope.grant")); } diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index 53e9972e..a9ad10f6 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -654,12 +654,20 @@ fn acquire_pairing_lock(path: &str) -> anyhow::Result { /// `(request_id, device_pubkey, pop_sig)`); it is written only to the 0600 /// `state_file`, which `--retrieve-pairing` reads by default and from which an /// explicit workflow can source it. +/// Default broker for the agent-side pairing one-shots (`--request-pairing` / +/// `--retrieve-pairing`) when neither `--broker-url` nor `AGENTKEYS_BROKER_URL` is +/// given. These commands ALWAYS need a broker, so prod is the sane default (override +/// with the flag/env for a test broker). Deliberately NOT applied to `--ui-bridge`, +/// where an unset `broker_url` means "fall back to pre-sourced AWS creds" (§191). +const DEFAULT_PAIRING_BROKER_URL: &str = "https://broker.litentry.org"; + async fn run_request_pairing(args: Args) -> anyhow::Result<()> { use agentkeys_core::device_crypto::DeviceKey; - let broker_url = args.broker_url.clone().ok_or_else(|| { - anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --request-pairing") - })?; + let broker_url = args + .broker_url + .clone() + .unwrap_or_else(|| DEFAULT_PAIRING_BROKER_URL.to_string()); let base = broker_url.trim_end_matches('/').to_string(); // Serialize the ENTIRE --request-pairing flow (K10 load/generate → guard → @@ -775,7 +783,8 @@ async fn run_request_pairing(args: Args) -> anyhow::Result<()> { info!( target: "agentkeys.daemon.init", device = %device_pubkey, - "agentkeys-daemon opened §10.2 pairing request — show this code to your owner to claim: {pairing_code}" + device_key_hash = %device_key_hash, + "agentkeys-daemon opened §10.2 pairing request — show your owner the code to claim: {pairing_code}; they cross-check device_key_hash={device_key_hash} on the master before approving (#224)" ); // Machine artifact on STDOUT (logs are on stderr). The owner reads @@ -806,9 +815,10 @@ async fn run_request_pairing(args: Args) -> anyhow::Result<()> { async fn run_retrieve_pairing(args: Args) -> anyhow::Result<()> { use agentkeys_core::device_crypto::DeviceKey; - let broker_url = args.broker_url.clone().ok_or_else(|| { - anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --retrieve-pairing") - })?; + let broker_url = args + .broker_url + .clone() + .unwrap_or_else(|| DEFAULT_PAIRING_BROKER_URL.to_string()); let base = broker_url.trim_end_matches('/').to_string(); // Load the device key FIRST: its device_pubkey keys the per-device state file diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index 34b67955..84501dcf 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -168,6 +168,30 @@ pub struct UiBridgeState { /// re-onboarding). Distinct from `onboarding_session`, which is set ONLY while /// the J1 is live (the "actively logged in" signal). pub master_session: RwLock>, + /// #225 / E7: the master register is two-phase (the browser passkey signs the + /// register UserOp BETWEEN build + submit). K11-finish runs the `build` + /// (deploy the P256Account + assemble the register UserOp) and stashes the + /// build context HERE; `POST /v1/master/register/submit` consumes it after the + /// browser signs `userop_hash`. `None` ⇒ no register in flight. Single-master, + /// so one slot suffices. + pub pending_register: RwLock>, +} + +/// #225 / E7: build-phase output of the two-phase master register, held between +/// `/v1/k11/enroll/finish` (build) and `/v1/master/register/submit`. +#[derive(Clone, Debug)] +pub struct PendingMasterRegister { + /// `erc4337-register-master.sh build` state file (ACCOUNT/NONCE/CALLDATA/…), + /// read back by the `submit` sub-command. + pub state_file: String, + /// The deployed P256Account (the operatorMasterWallet-to-be). + pub account: String, + /// `master_cred_id_hash(omni)` — the account's signer key + a submit arg. + pub cred_id_hash: String, + /// `keccak(operator_omni)` — what cap-mint sends once registered. + pub device_key_hash: String, + /// The session omni the master registers under. + pub operator_omni: String, } /// Issue #196: outcome of the on-chain master-device registration shell-out. @@ -183,6 +207,9 @@ pub struct RegisteredMaster { /// `registerFirstMasterDevice` tx hash. `None` on idempotent skip (the device /// was already on chain from a prior login). pub tx_hash: Option, + /// #225 / E7: the master's on-chain P256Account address (`operatorMasterWallet[omni]`), + /// surfaced on the actor page. `None` for a pre-E7 / EOA-bound master. + pub account: Option, } /// A master-actor memory entry. `content_hash` is the dedup key — @@ -523,6 +550,15 @@ pub struct EnrollFinishResponse { /// hitting a confusing cap-mint failure at plant time (issue #90 fail-loud). #[serde(skip_serializing_if = "Option::is_none")] pub chain_error: Option, + /// #225 / E7: when `chain == "register-pending"`, the userOpHash the browser + /// passkey must sign (a second Touch ID) and POST to `/v1/master/register/submit` + /// to finish binding the master P256Account. `None` on skip/error. + #[serde(skip_serializing_if = "Option::is_none")] + pub register_userop_hash: Option, + /// #225 / E7: the deployed master P256Account address (operatorMasterWallet-to-be), + /// shown in the ceremony UI. Present on both `register-pending` + skip. + #[serde(skip_serializing_if = "Option::is_none")] + pub register_account: Option, } // ── W1 onboarding: real email magic-link verify (broker-backed) ── @@ -643,6 +679,8 @@ pub fn build_router(state: SharedUiBridgeState, allowed_origin: &str) -> Router .route("/healthz", get(healthz)) .route("/v1/k11/enroll/begin", post(enroll_begin)) .route("/v1/k11/enroll/finish", post(enroll_finish)) + .route("/v1/master/register/submit", post(master_register_submit)) + .route("/v1/master/reset", post(master_reset)) .route("/v1/auth/email/start", post(auth_email_start)) .route("/v1/auth/email/status", get(auth_email_status)) .route("/v1/onboarding/state", get(onboarding_state)) @@ -679,7 +717,12 @@ pub fn build_router(state: SharedUiBridgeState, allowed_origin: &str) -> Router // (agents it claimed, awaiting on-chain register) for the pairing screen. .route("/v1/agent/pairing/pending", get(list_pairing_requests)) .route("/v1/agent/pairing/claim", post(claim_pairing)) + .route("/v1/agent/pairing/decline", post(decline_pairing)) + .route("/v1/agent/pairing/ack", post(ack_pairing)) .route("/v1/agent/pairing/register", post(register_pairing)) + // #225 E7 — the Touch-ID-gated accept (browser K11-signs the userOpHash): + .route("/v1/accept/build", post(accept_build_proxy)) + .route("/v1/accept/submit", post(accept_submit_proxy)) .route("/v1/dev/seed", post(dev_seed)) .route("/v1/dev/event", post(dev_emit_event)) .layer(cors) @@ -768,6 +811,7 @@ pub fn build_state( chain_profile, master_session_store, master_session: RwLock::new(None), + pending_register: RwLock::new(None), })) } @@ -1002,6 +1046,111 @@ async fn logout(State(state): State) -> Json) -> Json { + // Capture the operator omni BEFORE clearing local state (the on-chain reset needs + // it). Prefer the registered-master omni (what's actually bound on chain), then the + // persisted session, then the live onboarding session. Each read guard is dropped at + // its statement end — none held across an await. + let from_registered = state + .registered_master + .read() + .await + .as_ref() + .map(|rm| rm.operator_omni.clone()); + let from_session = state + .master_session + .read() + .await + .as_ref() + .map(|ms| ms.operator_omni.clone()); + let from_onboarding = state + .onboarding_session + .read() + .await + .as_ref() + .map(|s| s.omni.clone()); + let operator_omni = [from_registered, from_session, from_onboarding] + .into_iter() + .flatten() + .find(|o| !o.is_empty()) + .map(|o| agentkeys_backend_client::normalize_omni_0x(&o)); + + // (1) ON-CHAIN unbind via the deployer-owned resetMaster. + let onchain = match (state.register_master_script.clone(), operator_omni.clone()) { + (Some(script), Some(omni)) => match reset_master_onchain(&script, &omni).await { + Ok(v) if v.get("skipped").is_some() => { + tracing::info!(target: "agentkeys.daemon.ui_bridge", omni = %omni, "master reset — on-chain already unbound"); + serde_json::json!({ "status": "skipped", "reason": "already-unbound", "operator_omni": omni }) + } + Ok(v) => { + let tx = v + .get("tx_hash") + .and_then(|t| t.as_str()) + .unwrap_or_default(); + tracing::info!(target: "agentkeys.daemon.ui_bridge", omni = %omni, tx = %tx, "master reset — on-chain operatorMasterWallet cleared"); + serde_json::json!({ "status": "reset", "tx_hash": tx, "operator_omni": omni }) + } + Err(e) => { + tracing::warn!(target: "agentkeys.daemon.ui_bridge", omni = %omni, "master reset — on-chain unbind FAILED: {e}"); + serde_json::json!({ "status": "failed", "error": e, "operator_omni": omni }) + } + }, + (None, _) => { + serde_json::json!({ "status": "skipped", "reason": "no-register-script-configured" }) + } + (_, None) => serde_json::json!({ "status": "skipped", "reason": "no-operator-omni-known" }), + }; + + // (2) LOCAL clear (always — even if the on-chain step failed, so the UI isn't stuck). + *state.registered_master.write().await = None; + *state.pending_register.write().await = None; + *state.master_session.write().await = None; + if let Some(store) = state.master_session_store.as_ref() { + if let Err(e) = store.clear_all() { + tracing::warn!("ui-bridge: master reset failed to clear persisted session: {e}"); + } + } + tracing::info!( + target: "agentkeys.daemon.ui_bridge", + "master reset — local binding cleared; the OS passkey is NOT touched (WebAuthn forbids site deletion)" + ); + + let status = onchain.get("status").and_then(|s| s.as_str()); + let onchain_cleared = status == Some("reset") + || (status == Some("skipped") + && onchain.get("reason").and_then(|s| s.as_str()) == Some("already-unbound")); + let note = if onchain_cleared { + "local + ON-CHAIN master binding cleared — delete the master passkey in your OS password \ + manager (System Settings ▸ Passwords), then re-onboard with a fresh passkey." + } else { + "LOCAL master binding cleared, but the ON-CHAIN binding was NOT cleared (see onchain.error / \ + onchain.reason). Re-onboarding will still fail with SIG_VALIDATION until it is — confirm the \ + registry is VERSION>=0.3 (has resetMaster) and the deployer key is available, then retry, or \ + run scripts/heima-reset-master.sh --operator-omni manually." + }; + + Json(serde_json::json!({ "ok": true, "onchain": onchain, "note": note })) +} + /// Persist the current master session coordinates to /// `~/.agentkeys/daemon-/master-session.json` and refresh the in-memory /// `master_session` mirror (issue #220). No-op when persistence is disabled @@ -1084,6 +1233,9 @@ pub async fn rehydrate_master_session(state: &UiBridgeState) { device_key_hash: record.device_key_hash.clone(), operator_omni: record.operator_omni.clone(), tx_hash: None, + // The account address isn't persisted in the #220 session record yet; + // list_actors reads it from chain when absent (E7 actor-page follow-up). + account: None, }); tracing::info!( target: "agentkeys.daemon.ui_bridge", @@ -1210,7 +1362,7 @@ async fn enroll_finish( // passkey just enrolled. Best-effort: a chain failure does NOT void the // passkey enrollment — it surfaces in `chain_error` so the operator funds + // retries instead of hitting a confusing cap-mint failure at plant time. - let (chain_tx_hash, chain, chain_error) = finish_chain_register( + let build = finish_chain_register( &state, &credential_id_b64, attestation_object_b64.as_deref(), @@ -1220,9 +1372,11 @@ async fn enroll_finish( Ok(Json(EnrollFinishResponse { credential_id: credential_id_b64, registered_at_unix, - chain_tx_hash, - chain, - chain_error, + chain_tx_hash: build.chain_tx_hash, + chain: build.chain, + chain_error: build.chain_error, + register_userop_hash: build.register_userop_hash, + register_account: build.register_account, })) } @@ -1232,71 +1386,198 @@ async fn enroll_finish( /// — `("none", None)` with no error — when no register script is configured /// (dev / no-infra). A missing session or a shell-out failure returns a /// `chain_error` so the web UI can surface "fund + retry". +/// #225 / E7 build-phase result. `chain`: `"register-pending"` (account built; +/// the browser must sign `register_userop_hash` next via `/v1/master/register/submit`), +/// `"master-registered"` (idempotent skip — the operator already has a master), +/// or `"none"` (no script / error in `chain_error`). +struct ChainRegisterBuild { + register_userop_hash: Option, + register_account: Option, + chain_tx_hash: Option, + chain: String, + chain_error: Option, +} + async fn finish_chain_register( state: &SharedUiBridgeState, - credential_id_b64url: &str, + _credential_id_b64url: &str, attestation_object_b64: Option<&str>, -) -> (Option, String, Option) { +) -> ChainRegisterBuild { + let none = |chain: &str, err: Option| ChainRegisterBuild { + register_userop_hash: None, + register_account: None, + chain_tx_hash: None, + chain: chain.to_string(), + chain_error: err, + }; let Some(script) = state.register_master_script.clone() else { - return (None, "none".to_string(), None); + return none("none", None); }; - let session = state.onboarding_session.read().await.clone(); - let Some(session) = session else { + let Some(session) = state.onboarding_session.read().await.clone() else { let msg = "K11 enrolled but no onboarding session — verify email first, \ then re-enroll to register the master device on chain" .to_string(); tracing::warn!("ui-bridge register-master: {msg}"); - return (None, "none".to_string(), Some(msg)); + return none("none", Some(msg)); }; if session.omni.is_empty() { - return ( - None, - "none".to_string(), + return none( + "none", Some("onboarding session has no EVM omni (managed-wallet attestation skipped)".into()), ); } let Some(att_b64) = attestation_object_b64 else { - return ( - None, - "none".to_string(), + return none( + "none", Some("credential missing response.attestationObject — cannot derive K11 pubkey".into()), ); }; let k11 = match decode_web_k11(att_b64) { Ok(k) => k, + Err(e) => return none("none", Some(format!("K11 pubkey extract: {e}"))), + }; + let (pub_x, pub_y) = match split_cose_xy(&k11.cose_pubkey_hex) { + Ok(xy) => xy, + Err(e) => return none("none", Some(e)), + }; + let cred_id_hash = match master_cred_id_hash_hex(&session.omni) { + Ok(h) => h, + Err(e) => return none("none", Some(format!("cred-id-hash: {e}"))), + }; + let omni0x = if session.omni.starts_with("0x") { + session.omni.clone() + } else { + format!("0x{}", session.omni) + }; + let state_file = register_state_file(&session.omni); + + // BUILD: deploy the P256Account + fund the 5-HEI deposit + assemble the + // register UserOp (deployer pays gas; the passkey authorizes). Returns the + // userOpHash the browser signs, OR a {skipped:"already-registered"}. + let json = match register_master_build( + &script, + &omni0x, + &pub_x, + &pub_y, + &cred_id_hash, + &k11.rp_id_hash_hex, + &state_file, + ) + .await + { + Ok(v) => v, Err(e) => { - return ( - None, - "none".to_string(), - Some(format!("K11 pubkey extract: {e}")), - ) + tracing::error!("ui-bridge register-master build failed: {e}"); + return none("none", Some(e)); } }; - match register_master_device(&script, &session.omni, &k11, credential_id_b64url).await { - Ok(rm) => { - tracing::info!( - target: "agentkeys.daemon.ui_bridge", - operator_omni = %rm.operator_omni, - device_key_hash = %rm.device_key_hash, - tx = rm.tx_hash.as_deref().unwrap_or("(already-registered)"), - "issue #196: master device registered on chain (CAP_MINT) — cap-mint will resolve this device" - ); - let tx = rm.tx_hash.clone(); - *state.registered_master.write().await = Some(rm); - // Issue #220: K11-finish is the richest onboarding moment (omni + J1 + - // on-chain device hash all known) — persist so a restart resumes with - // the registered device, zero prompts. - persist_master_session(state).await; - (tx, "master-registered".to_string(), None) - } - Err(e) => { - tracing::error!("ui-bridge register-master shell-out failed: {e}"); - (None, "none".to_string(), Some(e)) - } + let device_key_hash = json + .get("device_key_hash") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let account = json + .get("account") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let operator_omni = json + .get("operator_omni") + .and_then(|v| v.as_str()) + .unwrap_or(&omni0x) + .to_string(); + + // Idempotent skip: the operator already has a master on chain (no re-register). + if json.get("skipped").is_some() { + *state.registered_master.write().await = Some(RegisteredMaster { + device_key_hash, + operator_omni, + tx_hash: None, + account: (!account.is_empty()).then_some(account.clone()), + }); + persist_master_session(state).await; + return ChainRegisterBuild { + register_userop_hash: None, + register_account: (!account.is_empty()).then_some(account), + chain_tx_hash: None, + chain: "master-registered".to_string(), + chain_error: None, + }; + } + + // Built: stash the pending register; the browser signs userop_hash next. + let userop_hash = json + .get("userop_hash") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + if userop_hash.is_empty() || account.is_empty() { + return none( + "none", + Some(format!("build returned no userop_hash/account: {json}")), + ); + } + *state.pending_register.write().await = Some(PendingMasterRegister { + state_file, + account: account.clone(), + cred_id_hash, + device_key_hash, + operator_omni, + }); + tracing::info!( + target: "agentkeys.daemon.ui_bridge", + account = %account, + "E7: master P256Account built + funded — awaiting the browser register signature" + ); + ChainRegisterBuild { + register_userop_hash: Some(userop_hash), + register_account: Some(account), + chain_tx_hash: None, + chain: "register-pending".to_string(), + chain_error: None, } } +/// Split a SEC1 uncompressed COSE pubkey (`04 || X || Y`, 130 hex) → (0xX, 0xY). +fn split_cose_xy(cose_pubkey_hex: &str) -> Result<(String, String), String> { + let h = cose_pubkey_hex.trim().trim_start_matches("0x"); + if h.len() != 130 || !h.starts_with("04") { + return Err(format!( + "cose pubkey expected 130 hex (04||X||Y); got {} chars", + h.len() + )); + } + Ok((format!("0x{}", &h[2..66]), format!("0x{}", &h[66..130]))) +} + +/// `master_cred_id_hash(omni)` as `0x`-hex — the P256Account signer key (matches +/// the accept's assertion; NOT `keccak(rawId)`). +fn master_cred_id_hash_hex(operator_omni: &str) -> Result { + let bare = operator_omni.trim().trim_start_matches("0x"); + let bytes = hex::decode(bare).map_err(|e| format!("omni hex: {e}"))?; + let omni: [u8; 32] = bytes + .try_into() + .map_err(|_| "omni must be 32 bytes".to_string())?; + Ok(format!( + "0x{}", + hex::encode(agentkeys_core::erc4337::master_cred_id_hash(&omni)) + )) +} + +/// Per-omni temp state-file path for the register `build`→`submit` handoff. +fn register_state_file(operator_omni: &str) -> String { + let short: String = operator_omni + .trim_start_matches("0x") + .chars() + .take(16) + .collect(); + std::env::temp_dir() + .join(format!("agentkeys-register-{short}")) + .to_string_lossy() + .into_owned() +} + /// Decode the attestationObject (b64url) → the on-chain K11 material (pubkey + /// rpIdHash). Reuses the CLI's tested CBOR/COSE parser. fn decode_web_k11( @@ -1314,35 +1595,20 @@ fn decode_web_k11( /// submit `registerFirstMasterDevice` under the session omni, signed by the /// local deployer key (msg.sender). Parses the trailing JSON line for the /// device_key_hash (what cap-mint sends) + tx_hash (None on idempotent skip). -async fn register_master_device( - script: &str, - session_omni: &str, - k11: &agentkeys_cli::k11_webauthn::WebK11Material, - credential_id_b64url: &str, -) -> Result { +async fn run_register_script(script: &str, args: &[&str]) -> Result { let output = tokio::process::Command::new("bash") .arg(script) - .arg("--operator-omni") - .arg(session_omni) - .arg("--actor-omni") - .arg(session_omni) - .arg("--k11-cose-hex") - .arg(&k11.cose_pubkey_hex) - .arg("--k11-cred-id") - .arg(credential_id_b64url) - .arg("--rp-id-hash") - .arg(&k11.rp_id_hash_hex) + .args(args) .output() .await .map_err(|e| format!("spawn {script}: {e}"))?; - if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Surface the last few stderr lines (the script logs `fail `). let tail: String = stderr .lines() .rev() - .take(6) + .take(8) .collect::>() .into_iter() .rev() @@ -1350,7 +1616,6 @@ async fn register_master_device( .join("\n"); return Err(format!("register script exited {}: {tail}", output.status)); } - // The script prints human logs on stderr and a single JSON line on stdout. let stdout = String::from_utf8_lossy(&output.stdout); let json_line = stdout @@ -1358,29 +1623,175 @@ async fn register_master_device( .rev() .find(|l| l.trim_start().starts_with('{')) .ok_or_else(|| format!("register script produced no JSON on stdout: {stdout}"))?; - let parsed: serde_json::Value = - serde_json::from_str(json_line.trim()).map_err(|e| format!("register JSON parse: {e}"))?; + serde_json::from_str(json_line.trim()).map_err(|e| format!("register JSON parse: {e}")) +} - let device_key_hash = parsed - .get("device_key_hash") - .and_then(|v| v.as_str()) - .ok_or_else(|| format!("register JSON missing device_key_hash: {json_line}"))? - .to_string(); - let operator_omni = parsed - .get("operator_omni") - .and_then(|v| v.as_str()) - .unwrap_or(session_omni) - .to_string(); - let tx_hash = parsed - .get("tx_hash") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); +/// E7 BUILD: `erc4337-register-master.sh build` — deploy the P256Account + fund +/// its EntryPoint deposit (5 HEI, deployer-paid) + assemble the register UserOp. +/// Returns the JSON: `{userop_hash, account, device_key_hash}` OR +/// `{skipped:"already-registered", …}` (operator already has a master). +async fn register_master_build( + script: &str, + operator_omni: &str, + pub_x: &str, + pub_y: &str, + cred_id_hash: &str, + rpid_hash: &str, + state_file: &str, +) -> Result { + run_register_script( + script, + &[ + "build", + "--operator-omni", + operator_omni, + "--pubx", + pub_x, + "--puby", + pub_y, + "--cred-id-hash", + cred_id_hash, + "--rpid-hash", + rpid_hash, + "--state-file", + state_file, + ], + ) + .await +} - Ok(RegisteredMaster { - device_key_hash, - operator_omni, - tx_hash, - }) +/// E7 SUBMIT: `erc4337-register-master.sh submit` — encode the browser assertion +/// into the UserOp signature + `EntryPoint.handleOps`. Returns `{tx_hash, account, …}`. +#[allow(clippy::too_many_arguments)] +async fn register_master_submit( + script: &str, + state_file: &str, + cred_id_hash: &str, + authdata: &str, + clientdata: &str, + challenge_loc: &str, + r: &str, + s: &str, +) -> Result { + run_register_script( + script, + &[ + "submit", + "--state-file", + state_file, + "--cred-id-hash", + cred_id_hash, + "--authdata", + authdata, + "--clientdata", + clientdata, + "--challenge-loc", + challenge_loc, + "--r", + r, + "--s", + s, + ], + ) + .await +} + +/// `POST /v1/master/register/submit` body (#225 / E7). +#[derive(Debug, Deserialize)] +pub struct MasterRegisterSubmitRequest { + pub assertion: BrowserRegisterAssertion, +} + +/// The raw browser `get()` assertion (base64url), exactly as +/// `apps/parent-control/lib/webauthn.ts::getAssertionOverHash` emits it over the +/// register `userop_hash`. +#[derive(Debug, Deserialize)] +pub struct BrowserRegisterAssertion { + pub authenticator_data: String, + pub client_data_json: String, + pub signature: String, + // `credential_id` (b64url) may also be present on the wire (serde ignores it) — + // the signer key is `cred_id_hash` from the pending build, not the raw id. +} + +/// `POST /v1/master/register/submit` (#225 / E7) — phase 2 of the master register. +/// The browser passkey signed the `userop_hash` from K11-finish's build; encode +/// the assertion into the UserOp signature and land `EntryPoint.handleOps`, binding +/// `operatorMasterWallet[omni]` = the master P256Account. +async fn master_register_submit( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let pending = state.pending_register.read().await.clone().ok_or_else(|| { + err( + StatusCode::CONFLICT, + "no master register in flight — finish K11 enrollment (build) first", + "no-pending-register", + ) + })?; + let script = state.register_master_script.clone().ok_or_else(|| { + err( + StatusCode::SERVICE_UNAVAILABLE, + "on-chain register not configured (no --register-master-script)", + "no-register-script", + ) + })?; + + let a = req.assertion; + let dec = agentkeys_cli::k11_webauthn::decode_web_userop_assertion( + &a.authenticator_data, + &a.client_data_json, + &a.signature, + ) + .map_err(|e| { + err( + StatusCode::BAD_REQUEST, + format!("assertion decode: {e}"), + "assertion-decode", + ) + })?; + + let loc = dec.challenge_location.to_string(); + let json = register_master_submit( + &script, + &pending.state_file, + &pending.cred_id_hash, + &dec.authenticator_data_hex, + &dec.client_data_json_hex, + &loc, + &dec.r_hex, + &dec.s_hex, + ) + .await + .map_err(|e| err(StatusCode::BAD_GATEWAY, e, "register-submit-failed"))?; + + let registered = RegisteredMaster { + device_key_hash: pending.device_key_hash.clone(), + operator_omni: pending.operator_omni.clone(), + tx_hash: json + .get("tx_hash") + .and_then(|v| v.as_str()) + .map(String::from), + account: Some(pending.account.clone()), + }; + tracing::info!( + target: "agentkeys.daemon.ui_bridge", + account = %pending.account, + operator_omni = %registered.operator_omni, + tx = registered.tx_hash.as_deref().unwrap_or("(none)"), + "E7: master P256Account registered — operatorMasterWallet bound" + ); + let resp = serde_json::json!({ + "ok": true, + "chain": "master-registered", + "tx_hash": registered.tx_hash, + "account": pending.account, + "device_key_hash": pending.device_key_hash, + }); + *state.registered_master.write().await = Some(registered); + *state.pending_register.write().await = None; + persist_master_session(&state).await; + Ok(Json(resp)) } fn base64url_encode(bytes: &[u8]) -> String { @@ -1411,25 +1822,74 @@ fn now_ts_hms() -> String { async fn list_actors(State(state): State) -> impl IntoResponse { let guard = state.actors.read().await; let mut actors: Vec = guard.values().cloned().collect(); + drop(guard); // Stable order: master first, then by id. actors.sort_by(|a, b| { let a_master = if a.role == "master" { 0 } else { 1 }; let b_master = if b.role == "master" { 0 } else { 1 }; a_master.cmp(&b_master).then_with(|| a.id.cmp(&b.id)) }); + let master_account = master_account_address(&state).await; + let actors: Vec = actors + .iter() + .map(|a| enrich_actor_account(a, master_account.as_deref())) + .collect(); Json(serde_json::json!({ "actors": actors })) } async fn get_actor( State(state): State, Path(id): Path, -) -> Result, (StatusCode, Json)> { - let guard = state.actors.read().await; - guard - .get(&id) - .cloned() - .map(Json) - .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found")) +) -> Result, (StatusCode, Json)> { + let actor = { + let guard = state.actors.read().await; + guard.get(&id).cloned() + } + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + let master_account = master_account_address(&state).await; + Ok(Json(enrich_actor_account( + &actor, + master_account.as_deref(), + ))) +} + +/// The master's on-chain P256Account (`operatorMasterWallet[omni]`), from the +/// register flow. `None` for a pre-E7 / not-yet-registered / restored-from-disk +/// master (the actor page then shows the register CTA). +async fn master_account_address(state: &SharedUiBridgeState) -> Option { + state + .registered_master + .read() + .await + .as_ref() + .and_then(|m| m.account.clone()) +} + +/// #225 / E7: attach the actor's on-chain account address + type to its serialized +/// JSON for the actor page. master → its passkey **P256Account** (the smart account +/// that holds master authority); agents → their K10 **device** identity. The +/// `account_type` lets the UI distinguish a bound smart-account master (`p256account`) +/// from an unbound one (`none` → "register on chain" CTA). +fn enrich_actor_account(a: &ApiActor, master_account: Option<&str>) -> serde_json::Value { + let mut v = serde_json::to_value(a).unwrap_or_else(|_| serde_json::json!({})); + let (addr, ty): (Option, &str) = if a.role == "master" { + match master_account { + Some(acc) => (Some(acc.to_string()), "p256account"), + None => (None, "none"), + } + } else { + // Agents are K10 devices (not ERC-4337 accounts) — surface the omni identity. + (Some(a.omni_hex.clone()), "device") + }; + if let Some(obj) = v.as_object_mut() { + obj.insert( + "account_address".into(), + addr.map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null), + ); + obj.insert("account_type".into(), serde_json::Value::from(ty)); + } + v } async fn list_caps( @@ -1615,17 +2075,58 @@ async fn revoke_device( Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { - let mut guard = state.actors.write().await; - let actor = guard - .get_mut(&id) - .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; - actor.status = "bad".into(); - actor.last_active = "revoked".into(); - if !actor.label.ends_with(" (revoked)") { - actor.label.push_str(" (revoked)"); - } - let snapshot = actor.clone(); - drop(guard); + // Read the actor's on-chain device key hash + label first. The revoke must land + // ON CHAIN before we flip local state — a binding is not gone until + // SidecarRegistry.revokeAgentDevice says so (the "also need on-chain" rule). + let label = { + let guard = state.actors.read().await; + let actor = guard + .get(&id) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + actor.label.clone() + }; + + // On-chain revokeAgentDevice via heima-device-revoke.sh (resolved the same way + // register_pairing resolves heima-agent-create.sh). Agent-tier needs no K11; the + // script is idempotent (skips when already revoked). + let master_script = state.register_master_script.clone().ok_or_else(|| { + err( + StatusCode::SERVICE_UNAVAILABLE, + "chain not configured (no --register-master-script) — cannot revoke on chain", + "chain-unconfigured", + ) + })?; + let revoke_script = + resolve_repo_script(&master_script, "heima-device-revoke.sh").ok_or_else(|| { + err( + StatusCode::SERVICE_UNAVAILABLE, + "heima-device-revoke.sh not found (looked next to --register-master-script and in /scripts/)", + "revoke-script-missing", + ) + })?; + let tx = revoke_agent_device(&revoke_script, &label) + .await + .map_err(|e| { + err( + StatusCode::BAD_GATEWAY, + format!("on-chain revoke failed: {e}"), + "revoke-onchain-failed", + ) + })?; + + // On-chain revoke landed (or idempotent-skip) → flip local state + drop caps. + let snapshot = { + let mut guard = state.actors.write().await; + let actor = guard + .get_mut(&id) + .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such actor", "actor-not-found"))?; + actor.status = "bad".into(); + actor.last_active = "revoked".into(); + if !actor.label.ends_with(" (revoked)") { + actor.label.push_str(" (revoked)"); + } + actor.clone() + }; // Invalidate every cap minted for this actor (TTL → 0). state.caps.write().await.remove(&id); @@ -1637,8 +2138,9 @@ async fn revoke_device( actor: "master".into(), kind: "device.revoked".into(), detail: format!( - "{} · intent='{}' · fields={}", + "{} · on-chain revokeAgentDevice{} · intent='{}' · fields={}", id, + tx.map(|h| format!(" tx={h}")).unwrap_or_default(), req.intent_text, req.intent_fields.len() ), @@ -1963,8 +2465,10 @@ async fn list_pairing_requests( /// Map one broker `PendingBinding` row → the web UI's `PairingRequest` JSON /// (`apps/parent-control/app/_components/types.ts`). These rows are POST-claim -/// (the one-time code was consumed at claim), so `pairCode` carries the real -/// request handle and the UI presents them as "awaiting your on-chain approval". +/// (awaiting on-chain approval); `pairCode` carries the agent's REAL one-time +/// pairing code (the master claimed by it) so the operator can confirm it matches +/// the device, and `requestedAt` carries the broker `created_at` unix seconds (the +/// UI formats it). fn pending_binding_to_request(b: &serde_json::Value) -> serde_json::Value { let field = |k: &str| { b.get(k) @@ -1975,8 +2479,15 @@ fn pending_binding_to_request(b: &serde_json::Value) -> serde_json::Value { let request_id = field("request_id"); let label = field("label"); let device_pubkey = field("device_pubkey"); + let device_key_hash = field("device_key_hash"); let pop_sig = field("pop_sig"); let requested_scope = field("requested_scope"); + let pairing_code = field("pairing_code"); + // created_at: broker unix seconds (the agent's /request). The UI formats it. + let created_at = b + .get("created_at") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); // char-safe head…tail elision for long hex handles. let short = |v: &str| -> String { let n = v.chars().count(); @@ -2013,10 +2524,17 @@ fn pending_binding_to_request(b: &serde_json::Value) -> serde_json::Value { "runtime": "hermes", "dpub": short(&device_pubkey), "dpubFull": device_pubkey, - "pairCode": short(&request_id), + // #224: the operator cross-checks `deviceKeyHash` (+ `dpubFull`, both printed + // by the agent's `--request-pairing`) before `accept · Touch ID`. `id` is the + // full request_id (the master-side handle). `pairCode` is now the agent's REAL + // one-time code (the master claimed by it) — shown so the operator can confirm + // it matches the code on the agent device. + "deviceKeyHash": device_key_hash.clone(), + "deviceKeyHashShort": short(&device_key_hash), + "pairCode": pairing_code, "derivation": format!("//{label}"), "requested": requested, - "requestedAt": "awaiting on-chain approval", + "requestedAt": created_at, "attestation": format!("PoP verified · {}", short(&pop_sig)), }) } @@ -2029,6 +2547,46 @@ struct ClaimPairingRequest { requested_scope: String, } +/// POST /v1/agent/pairing/decline — forward the master's decline to the broker +/// (removes the pending rendezvous row so it stops reappearing). J1-gated, **no +/// Touch ID** — declining isn't an on-chain mutation. Untyped relay, like the +/// accept proxies. +async fn decline_pairing( + State(state): State, + Json(body): Json, +) -> axum::response::Response { + let Some(broker) = state.broker_url.clone() else { + return pairing_err(StatusCode::SERVICE_UNAVAILABLE, "no broker configured"); + }; + let j1 = match state.onboarding_session.read().await.as_ref() { + Some(s) if !s.j1.is_empty() => s.j1.clone(), + _ => return pairing_err(StatusCode::FORBIDDEN, "no master session"), + }; + forward_to_broker(&broker, "/v1/agent/pairing/decline", &j1, &body).await +} + +/// POST /v1/agent/pairing/ack — mark a claimed pairing as BOUND so the broker drops +/// it from the pending list. The E7 accept (`/v1/accept/{build,submit}`) registers the +/// agent on-chain but its `SubmitAcceptRequest` carries no `request_id`, so the broker +/// can't drop the rendezvous row itself — the master acks it here (the same +/// `mark_bound` the legacy `register_pairing` path runs). Without this, an accepted +/// request keeps reappearing in `GET /v1/agent/pairing/pending`. J1-gated, **no Touch +/// ID** (acking isn't an on-chain mutation — the accept already did that). Forwards +/// `{request_id}` to the broker's `/v1/agent/pending-bindings/ack`. +async fn ack_pairing( + State(state): State, + Json(body): Json, +) -> axum::response::Response { + let Some(broker) = state.broker_url.clone() else { + return pairing_err(StatusCode::SERVICE_UNAVAILABLE, "no broker configured"); + }; + let j1 = match state.onboarding_session.read().await.as_ref() { + Some(s) if !s.j1.is_empty() => s.j1.clone(), + _ => return pairing_err(StatusCode::FORBIDDEN, "no master session"), + }; + forward_to_broker(&broker, "/v1/agent/pending-bindings/ack", &j1, &body).await +} + /// POST /v1/agent/pairing/claim — the master claims an agent's one-time pairing /// code (#214, §10.2 P.1). Binds the agent under the HDKD child omni for `label` /// and declares its requested scope, via the broker, using the master's J1 @@ -2095,6 +2653,125 @@ struct RegisterPairingRequest { request_id: String, } +/// #225 / #164 E7 — the Touch-ID-gated accept (slice 4: daemon proxy). The browser +/// calls `build` (→ broker assembles the sponsored executeBatch UserOp + returns the +/// `userOpHash`), does `navigator.credentials.get()` (Touch ID) over that hash, then +/// calls `submit` with the signed op. The daemon forwards both to the broker with the +/// master J1; the device fields come from the broker's AUTHORITATIVE binding (never +/// the browser), the scope from the master's UI approval. +#[derive(Debug, Deserialize)] +pub struct DaemonAcceptBuildRequest { + pub request_id: String, + pub services: Vec, + pub read_only: bool, + pub max_per_call: String, + pub max_per_period: String, + pub max_total: String, + pub period_seconds: u32, +} + +/// POST /v1/accept/build — resolve the binding + forward to the broker's +/// `/v1/accept/build`, returning the `userOpHash` the browser K11-signs. +async fn accept_build_proxy( + State(state): State, + Json(req): Json, +) -> axum::response::Response { + let Some(broker) = state.broker_url.clone() else { + return pairing_err(StatusCode::SERVICE_UNAVAILABLE, "no broker configured"); + }; + let (j1, operator_omni) = match state.onboarding_session.read().await.as_ref() { + Some(s) if !s.j1.is_empty() => (s.j1.clone(), s.omni.clone()), + _ => return pairing_err(StatusCode::FORBIDDEN, "no master session"), + }; + let bindings = match agentkeys_cli::agent_admin::agent_pending_value(&broker, &j1).await { + Ok(v) => v, + Err(e) => return pairing_err(StatusCode::BAD_GATEWAY, &format!("broker pending: {e:#}")), + }; + let row = bindings + .get("pending") + .and_then(|p| p.as_array()) + .and_then(|rows| { + rows.iter() + .find(|r| { + r.get("request_id").and_then(|v| v.as_str()) == Some(req.request_id.as_str()) + }) + .cloned() + }); + let Some(row) = row else { + return pairing_err( + StatusCode::NOT_FOUND, + "no pending binding for that request_id", + ); + }; + let field = |k: &str| { + row.get(k) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string() + }; + let body = serde_json::json!({ + "operator_omni": operator_omni, + "actor_omni": field("child_omni"), + "device_key_hash": field("device_key_hash"), + "agent_pop_sig": field("pop_sig"), + "link_code_redemption": "0x", // accepted-but-unused by registerAgentDevice + "services": req.services, + "read_only": req.read_only, + "max_per_call": req.max_per_call, + "max_per_period": req.max_per_period, + "max_total": req.max_total, + "period_seconds": req.period_seconds, + }); + forward_to_broker(&broker, "/v1/accept/build", &j1, &body).await +} + +/// POST /v1/accept/submit — forward the K11-signed op to the broker's +/// `/v1/accept/submit` (→ EntryPoint.handleOps). +async fn accept_submit_proxy( + State(state): State, + Json(body): Json, +) -> axum::response::Response { + let Some(broker) = state.broker_url.clone() else { + return pairing_err(StatusCode::SERVICE_UNAVAILABLE, "no broker configured"); + }; + let j1 = match state.onboarding_session.read().await.as_ref() { + Some(s) if !s.j1.is_empty() => s.j1.clone(), + _ => return pairing_err(StatusCode::FORBIDDEN, "no master session"), + }; + forward_to_broker(&broker, "/v1/accept/submit", &j1, &body).await +} + +/// POST `body` to `` with the master J1 bearer; relay the broker's +/// status + JSON body back to the browser verbatim. +async fn forward_to_broker( + broker: &str, + path: &str, + j1: &str, + body: &serde_json::Value, +) -> axum::response::Response { + let url = format!("{}{}", broker.trim_end_matches('/'), path); + match reqwest::Client::new() + .post(&url) + .bearer_auth(j1) + .json(body) + .send() + .await + { + Ok(resp) => { + let st = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let txt = resp.text().await.unwrap_or_default(); + ( + st, + [(axum::http::header::CONTENT_TYPE, "application/json")], + txt, + ) + .into_response() + } + Err(e) => pairing_err(StatusCode::BAD_GATEWAY, &format!("broker {path}: {e}")), + } +} + /// POST /v1/agent/pairing/register — the master approves a claimed agent: submit /// `registerAgentDevice` on chain for its sandbox-generated device key, then ack /// the broker so it clears from pending (#214, §10.2 P.2). The device fields come @@ -2129,14 +2806,32 @@ async fn register_pairing( "on-chain register not configured (--register-master-script) — cannot register the agent device", ); }; - let agent_script = match std::path::Path::new(&master_script).parent() { - Some(dir) => dir.join("heima-agent-create.sh"), - None => { - return pairing_err( - StatusCode::INTERNAL_SERVER_ERROR, - "cannot derive heima-agent-create.sh path", - ) - } + // `heima-agent-create.sh` canonically lives in `/scripts/`, while the + // master register script (`--register-master-script`) may be in + // `/harness/scripts/` (dev.sh) — so it is NOT always a sibling. Try the + // sibling first (co-located case), then `/scripts/` derived from the + // master script path. (#214 register-pairing path-mismatch fix — a missing + // script otherwise surfaced as a confusing 502 on `accept pairing`.) + let master_path = std::path::Path::new(&master_script); + let agent_script_candidates = [ + master_path + .parent() + .map(|d| d.join("heima-agent-create.sh")), + master_path + .parent() + .and_then(|d| d.parent()) + .and_then(|d| d.parent()) + .map(|repo| repo.join("scripts").join("heima-agent-create.sh")), + ]; + let Some(agent_script) = agent_script_candidates + .into_iter() + .flatten() + .find(|p| p.exists()) + else { + return pairing_err( + StatusCode::SERVICE_UNAVAILABLE, + "heima-agent-create.sh not found (looked next to --register-master-script and in /scripts/)", + ); }; // Pull the authoritative binding from the broker (device fields, never the UI). let bindings = match agentkeys_cli::agent_admin::agent_pending_value(broker, &j1).await { @@ -2304,6 +2999,99 @@ async fn register_agent_device( Ok(tx) } +/// Resolve a `/scripts/` helper the same way `register_pairing` finds +/// `heima-agent-create.sh`: a sibling of the master register script first +/// (co-located dev case), then `/scripts/` derived from it. +fn resolve_repo_script(master_script: &str, name: &str) -> Option { + let p = std::path::Path::new(master_script); + [ + p.parent().map(|d| d.join(name)), + p.parent() + .and_then(|d| d.parent()) + .and_then(|d| d.parent()) + .map(|repo| repo.join("scripts").join(name)), + ] + .into_iter() + .flatten() + .find(|c| c.exists()) +} + +/// Shell out to `heima-device-revoke.sh --agent