issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned)#95
Open
hanwencheng wants to merge 14 commits into
Open
issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned)#95hanwencheng wants to merge 14 commits into
hanwencheng wants to merge 14 commits into
Conversation
Refresh of issue #82 against v2 architecture (#87/#92). Original issue targeted v1 (mock-server-as-signer, daemon-side metadata, broker SQLite audit); plan was rewritten to the v2 surfaces (signer typed RPC, worker audit rows with intent commitments, ERC-7730 catalog as a §22 pluggable surface). Plan: docs/spec/plans/issue-82-erc7730-v2-aligned.md. ## What ships in this PR ### Phase 1 — EIP-712 typed-data signing at the signer * New endpoint `POST /dev/sign-typed-data` on the mock-server signer: accepts canonical EIP-712 v4 JSON (matches MetaMask `eth_signTypedData_v4`), parses + hashes internally (never trusts a caller-supplied prehash), returns the 65-byte canonical signature + every intermediate digest (`primary_type_hash`, `domain_separator`, final `digest`). * `DevKeyService::sign_eip712` + `Eip712SignResult` envelope. * New `SignerError::InvalidTypedData` (400) + propagation through `SignerClientError`. * `SignerClient::sign_eip712` trait method + `HttpSignerClient` impl. * Wire signer-only + full routers in agentkeys-mock-server. ### Phase 2 — clear_signing module in agentkeys-core New crate module at `crates/agentkeys-core/src/clear_signing/`: * `eip712.rs` — EIP-712 v4 encoder (no external dep). Supports string/bytes/bool/address, uint{8..256}, int{8..256}, bytes{1..32}, static/dynamic arrays, nested struct types. Cycle detection on type graph. Spec reference vector (`Mail` example) matches exactly. * `parser.rs` — ERC-7730 v2 JSON parser (subset for v0). * `format.rs` — per-field formatters (tokenAmount with decimals+ticker, address with truncation, integer, date as ISO-8601 UTC, bool, raw) + `{name}` intent interpolator. * `binding.rs` — domain-{name,version,chainId,verifyingContract} → 7730-file lookup; case-insensitive on address; refuses wildcard matches. * `catalog.rs` — bundled set (USDC permit fixture) + filesystem dir loading via `extend_from_dir` (operators ship custom files via `$AGENTKEYS_7730_DIR`). * `mod.rs::build_preview` — top-level "render this typed-data against this catalog" returning `intent_text` + `intent_commitment` = `keccak256(intent_text || 0x7c || digest)`. ### Phase 3 — CLI preview surfaces Two new subcommands under `agentkeys signer`: * `sign-typed-data` — call `/dev/sign-typed-data`. With `--preview-7730`, renders + prints operator intent + per-field review before signing. * `preview-7730` — render WITHOUT signing. Dry-run for new 7730 files before plumbing them into automated agent signing. Both pick up `$AGENTKEYS_7730_DIR` for operator-custom 7730 files; both support `--json` for machine-readable output. ### Phase 4 — audit-row intent-commitment schema (arch.md only) `arch.md §15.3` extended with two optional audit-row fields (`signed_intent_text`, `signed_intent_hash`). Schema is backwards- compatible — pre-#82 rows have the fields absent; worker reads/writes land in a follow-up PR (broker cap-mint propagation + on-chain `CredentialAudit` event extension also follow-up). ### Docs * `docs/spec/signer-protocol.md` — full `/dev/sign-typed-data` wire contract documented (request, response, supported type-string subset, errors). * `docs/spec/architecture.md` §14.2 + §15.3 + §22 — typed-data RPC in the signer surface, audit-row intent-commitment fields, clear-signing metadata as a pluggable surface (bundled → registry → on-chain progression). * `docs/spec/plans/issue-82-erc7730-v2-aligned.md` — full refreshed plan, including the K11-binding-on-high-value-signs follow-up (Phase 5 — out of scope here, tracked as separate issue since it needs a ScopeContract extension). ## Test plan * `cargo test --workspace` — 600+ tests across the workspace, all pass. * New tests added in this PR: - 30 unit tests under `agentkeys-core::clear_signing` (EIP-712 spec reference vector, cyclic type detection, integer range checks, array length validation, U256 dec/hex roundtrip, two's-complement negation, parser, formatter, binding, catalog). - 2 sign_eip712 unit tests in `dev_key_service.rs` (recovers-to-derived-address, malformed-typed-data rejection). - 6 route tests in `dev_key_service_routes.rs` (200 / 400-unknown- primary / 400-out-of-range-uint / 503-signer-disabled / address- matches-derive / full-sig-recovery-roundtrip). * `cargo clippy` — clean on all new code; pre-existing warnings unchanged. * Signature roundtrip verified: HKDF-derived secp256k1 key signs the EIP-712 digest, `ecrecover` returns the same address that `derive_address` produces for the same `omni_account`. ## What did NOT land in this PR Tracked as follow-ups so this PR stays scoped: * **Broker cap-mint policy gate** — the broker cap-mint endpoint doesn't yet require an `intent_commitment` for typed-data signs. Today the daemon goes direct to the signer via `signer_client`. When broker mediation lands, the cap-token carries the commitment. * **Worker audit-row wiring** — `agentkeys-worker-audit` doesn't read the new schema fields yet (forward-compatible; unknown fields are silently ignored). Schema is documented in arch.md §15.3 so the follow-up PR has a fixed target. * **On-chain `CredentialAudit` event extension** — needs a contract revision + redeploy; out of scope for a signer + worker change. * **Registry fetch (v1 source)** — `github.com/ethereum/clear-signing- erc7730-registry` integration is the v1 catalog source per arch.md §22 (the bundled set is the v0 default that ships in this PR). * **EIP-4337 UserOp clear signing** — out of scope per original #82. * **K11 binding on high-value signs** — Phase 5 in the plan; needs a ScopeContract extension to express "agent A may sign EIP-712 binding to chainId=1 verifyingContract=$X with tokenAmount ≤ Y". Plan-completion summary: * **What landed**: Plan refresh, signer-protocol.md update, arch.md §14.2/§15.3/§22 updates, `/dev/sign-typed-data` endpoint, signer-side EIP-712 hashing (no external dep), `clear_signing` module (parser + formatter + binding + catalog + EIP-712), bundled USDC permit fixture, CLI `sign-typed-data` + `preview-7730` subcommands, audit-row intent- commitment schema doc, full sig-recovery roundtrip test. * **What did NOT land**: Broker cap-mint policy gate, worker audit-row wiring, on-chain `CredentialAudit` event extension, registry-fetch catalog source, K11-on-high-value-signs (Phase 5). All tracked explicitly in the plan doc as follow-ups.
Defines the unified abstract audit message format that every audit-producing surface (creds, memory, signer, broker, payment-service, email-service, SidecarRegistry, K3EpochCounter) MUST emit going forward, and that the chain + explorer + indexer consume. ## What this section adds * **Envelope schema** — version, ts_unix, actor_omni, operator_omni, op_kind (u8), op_body (CBOR), result, intent_text + intent_commitment (PR #95). Canonical CBOR per RFC 8949 §4.2.1. * **Wire shape** — `POST /v1/audit/append` accepts the envelope; `GET /v1/audit/envelope/<hash>` returns the full envelope on demand (used by explorers). * **On-chain shape** — `CredentialAudit.appendV2(operatorOmni, actorOmni, opKind, envelopeHash)` + `appendRootV2(... opKindBitmap)` lands additively alongside the v1 `append`/`appendRoot`. New events `AuditAppendedV2` + `AuditRootAppendedV2` with `indexed opKind` topic so explorers can filter via `eth_getLogs`. * **Canonical op_kind table** — 17 op_kinds across 8 families (creds=0..2, memory=10..12, signs=20..21, payments=30..31, scope=40..41, device=50..52, email=60..61, K3=70). Grouped by 10s leaves room for related ops. PRs adding new op_kinds MUST append a row; numbers never reused, never reordered. * **Eight non-break invariants** — the cost of adding a new op_kind is "uglier UI temporarily for old explorers" — never "broken explorer / dropped event." Open enum, stable envelope-level fields, version gating, fallback renderer, opaque body pass-through, op-kind-agnostic contract, canonical table, 3-test contract per new op_kind. * **5-phase migration** — A (this doc) → B (worker + core migration) → C (contract revision) → D (subscan-essentials decoder) → E (subscan-essentials-ui-react renderer) → F (extend op_kind coverage). Phases B / C / F tracked at agentkeys#97; phases D / E tracked at subscan-essentials#12. ## Why this matters Today's audit surface only has 3 op_kinds (STORE / READ / TEARDOWN) and those are credential-CRUD-only. A typed-data sign event, a scope mutation, a device add, a payment, a memory put, an email send, a K3 epoch advance — none of these have a row to render in the explorer. With this section in place, the explorer can render a uniform timeline across all of them, and adding a new op_kind doesn't require the explorer to ship a release before AgentKeys can ship the feature. ## What does NOT land in this PR This is the schema lock-in (Phase A). The implementation phases (worker migration, contract redeploy, explorer decoder, UI renderer) ship as follow-ups in their respective repos. agentkeys#97 + subscan-essentials#12 are the tracking issues.
Lands the canonical AuditEnvelope shape as live code, not just a doc. Documented in arch.md §15.3a; this commit ships the worker side. Contract revision (Phase C) + emit-site migration across signer/scope/device/payment/ memory/email/K3 (Phase F) remain follow-ups in #97. ## What ships ### `agentkeys-core::audit` — canonical envelope (new module) * `AuditEnvelope` struct — version + ts_unix + actor_omni + operator_omni + op_kind (u8 open enum) + op_body (ciborium::Value) + result + intent_text + intent_commitment. Envelope-level fields are stable across all op_kinds. * `AuditOpKind` repr-u8 enum — 18 variants matching arch.md §15.3a canonical table (creds=0..2, memory=10..12, signs=20..21, payments=30..31, scope=40..41, device=50..52, email=60..61, K3=70). Open enum: `from_u8` returns Option, never panics. * `AuditResult` repr-u8 enum (Success=0, Failure=1, NotPermitted=2). * Per-op_kind typed body schemas in `audit::bodies` — 18 structs with serde derives matching the canonical table field-for-field. * Canonical CBOR codec in `audit::cbor` — deterministic per RFC 8949 §4.2.1. Encoder builds the envelope as an ordered CBOR map with keys sorted by canonical CBOR ordering. Decoder ignores unknown envelope-level keys (forward-compat) and rejects unsupported envelope versions. * `envelope_hash()` = keccak256(canonical_cbor). The 32-byte commitment that lands on chain as the second arg to the future `CredentialAudit.appendV2(operatorOmni, actorOmni, opKind, hash)`. * `commit_intent()` helper — same scheme as `clear_signing::commit_intent` (PR #95); verified by a test that asserts byte-for-byte equality between the two. ### `agentkeys-worker-audit` — V2 endpoints * `POST /v1/audit/append/v2` — accept envelope (as JSON), convert op_body to CBOR, compute envelope_hash, store CBOR by hash. Returns `{envelope_hash}`. * `GET /v1/audit/envelope/:hash` — return canonical CBOR bytes for the envelope (200 application/cbor) or 404 envelope_not_found. Explorers fetch via this endpoint after seeing the on-chain hash. * V1 endpoints (`/v1/audit/append`, `/v1/audit/flush/:op`, etc.) retained so existing callers keep working through the migration cycle. * `state.rs` extended with `envelopes: Mutex<HashMap<String, Vec<u8>>>` — in-memory v0; persistent S3 storage is a separate concern tracked alongside Phase C. ### Non-break invariants enforced by code Per arch.md §15.3a: 1. ✅ `op_kind` is `u8`, never a sealed enum (open enum design; `AuditOpKind::from_u8` returns Option). 2. ✅ Envelope-level fields decode for ANY op_kind, even op_kind=250 (test: `unknown_op_kind_still_decodes_envelope_level_fields`). 3. ✅ `version` bumped only on envelope-level breakage; new op_kinds stay at v1. 4. ✅ Worker accepts unknown op_kinds + stores the opaque body for explorers to fetch (test: `append_v2_accepts_unknown_op_kind`). 5. ✅ Decoder ignores unknown envelope-level keys (forward-compat for future versions; test: `decoder_ignores_unknown_envelope_keys`). 6. ✅ No contract-side decode of op_body — only `(opKind, envelopeHash)` would land on chain (Phase C scope; out of this PR). 7. ✅ Canonical op_kind table in arch.md §15.3a — `op_kind.rs::tests` asserts no byte collisions + all variants roundtrip. ## Tests * 17 unit tests in `agentkeys-core::audit` — envelope encode/decode, envelope hash determinism, unknown-op_kind tolerance, version refusal, typed body decode, op_kind byte uniqueness, commit_intent parity with `clear_signing::commit_intent`. * 7 integration tests in `agentkeys-worker-audit::tests::envelope_v2`: - append → 200 + envelope_hash with correct shape - GET → 200 application/cbor with canonical bytes - GET unknown hash → 404 envelope_not_found - reject envelope version 99 - reject malformed actor_omni - accept unknown op_kind (non-break invariant #1 + #4) - envelope_hash deterministic across appends - ts_unix=0 gets server-assigned * `cargo test --workspace` — 600+ tests, **0 failures, 1 ignored** (network-dependent test; pre-existing). * `cargo clippy` — clean on all new code. ## What does NOT land in this PR Tracked in #97 as Phases C + F: * On-chain `CredentialAudit.appendV2` + `appendRootV2` + new events with indexed opKind topic — needs contract revision + Heima Mainnet redeploy. * Migration of credentials-service + memory-service + signer + broker emit sites from legacy `AuditEvent` to `AuditEnvelope`. Each new op_kind PR will append a row to the arch.md §15.3a table + add the worker emit-site call. * Persistent storage for envelopes (S3 `audit/envelopes/<hash>.cbor`). In-memory v0 is sufficient for the worker's lifecycle; if the worker restarts before chain commitment lands, callers re-emit. * Subscan-essentials indexer decoder + UI renderer (subscan-essentials#12).
…ndpoints
Future emit sites (credentials-service, memory-service, signer, broker,
payment-service, email-service, SidecarRegistry, K3EpochCounter) all need
the same `POST /v1/audit/append/v2` + `GET /v1/audit/envelope/<hash>` wire
shape. Putting the client in agentkeys-core means each emitter consumes the
contract from one place — and the wire-level test surface is centralized.
## What ships
* `agentkeys_core::audit::AuditClient`:
- `new(base_url)` / `from_env()` (reads `$AGENTKEYS_AUDIT_WORKER_URL`,
defaults to `https://audit.litentry.org`).
- `append(envelope)` → returns `{ok, envelope_hash}` from the worker.
- `get_envelope(hash)` → `Option<Vec<u8>>` (None on 404).
* `envelope_for(actor, operator, op_kind, op_body, result, intent_text,
intent_commitment)` convenience builder — constructs an envelope from
a typed body (any `serde::Serialize`), wires the canonical CBOR.
## Emit-and-forget semantics
Per arch.md §15.3a, chain commitment is the durability mechanism — the
worker's in-memory envelope map is best-effort cache. Emitters that need
guaranteed delivery either retry on transient failure or fall back to
direct on-chain `CredentialAudit.append`.
## Tests
Two unit tests added in `audit::client::tests`:
* `envelope_for_builds_typed_body` — round-trip through the typed body
decoder: `SignEip712Body` → envelope → `typed_body()` returns the same
body.
* `envelope_for_emits_canonical_cbor` — same inputs produce same
`envelope_hash` regardless of build path (cross-encoder stability).
Total audit-module tests now 19. Full workspace `cargo test --workspace`
clean (600+ tests, 0 failures).
…code only) Adds the V2 surface to the CredentialAudit contract per arch.md §15.3a. V1 (`append` + `appendRoot`) is retained unchanged so existing indexers + the live tier-A worker keep working through the migration cycle. ## What ships * `appendV2(operatorOmni, actorOmni, opKind, envelopeHash)` — emits `AuditAppendedV2(operatorOmni indexed, actorOmni indexed, opKind indexed, envelopeHash)`. **Event-only — no on-chain storage.** The full envelope lives off-chain at the audit-service worker, addressed by `envelopeHash = keccak256(canonical_cbor(AuditEnvelope))`. The `opKind` indexed topic lets explorers filter `eth_getLogs` by op_kind without scanning every row. * `appendRootV2(operatorOmni, merkleRoot, opKindBitmap, batchEntryCount)` — emits `AuditRootAppendedV2`. `opKindBitmap` is `bytes32` where bit N = op_kind N is present in the batch. Lets explorers filter batches by op_kind without fetching every leaf from the worker. Gated to the operator's master wallet (same as V1 `appendRoot`, codex M1). * No on-chain decode of `op_body` — the contract stays op-kind-agnostic (non-break invariant #6 per arch.md §15.3a). New op_kinds need ZERO contract redeploys. ## Forge tests 5 new tests in `AgentKeysV1.t.sol` (alongside 4 existing CredentialAudit tests): * `test_CredentialAudit_AppendV2_EmitsEvent` — confirms the event topics carry operator + actor + opKind for `eth_getLogs` filtering. * `test_CredentialAudit_AppendV2_AcceptsAnyOpKind` — invariant #1 + invariant #6: op_kind=250 (reserved future byte) accepted without revert. * `test_CredentialAudit_AppendV2_OpenToAnyCaller` — `appendV2` is open to any caller (chain ordering + gas is the safety; indexer filters out attacker-emitted noise via canonical envelope hashes). * `test_CredentialAudit_AppendRootV2_EmitsEvent` — Merkle-batch path with multi-op_kind bitmap (bits 0 + 21 + 40 = CredStore + SignEip712 + ScopeGrant set). * `test_CredentialAudit_AppendRootV2_RejectsNonMaster` — gated to operator's master wallet per codex M1. * `test_CredentialAudit_V1_And_V2_Coexist` — V1 `append` + V2 `appendV2` write to disjoint paths; V2 emits don't touch V1's `entries` storage. Forge: 9/9 CredentialAudit tests pass; full forge suite 39/39 tests pass. Workspace cargo test still clean. ## Redeploy: operator action This commit ships the contract code + tests. The actual Heima Mainnet redeploy via `scripts/heima-bring-up.sh --upgrade` is operator action gated on PR review — left for a follow-up operator step. Until redeployed, the live `CredentialAudit` on Heima still has only V1 methods, so callers of `agentkeys-worker-audit::handlers::append_v2` can store envelopes off-chain but can't commit `envelopeHash` to chain until redeploy lands. Migration sequence per arch.md §15.3a Phase C: 1. Operator reviews this PR. 2. Operator runs `bash scripts/heima-bring-up.sh --upgrade` (idempotent — redeploys CredentialAudit if address bytecode hash changed). 3. Operator captures new address into `scripts/operator-workstation.env` + `docs/spec/deployed-contracts.md`. 4. Run `AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh`. 5. Run harness/v2-stage1-demo.sh through 3 to confirm no regression (V1 path still works on the redeployed contract).
Address two architect-review findings against earlier commits in this PR (reviewer: oh-my-claudecode:architect on PR #95). ## Fix 1 — recursive op_body canonicalization (cross-language hash determinism) Architect finding (section 4): the canonical CBOR encoder sorted only envelope-level keys, not `op_body` map keys recursively. The Rust ecosystem happened to produce stable hashes because `serde_json::Value:: Object` is `BTreeMap`-backed, but a Go or TypeScript encoder building `op_body` with unsorted keys would have produced different CBOR bytes and a different `envelope_hash` — silently breaking the chain-commitment property for cross-language clients. `audit::cbor::canonicalize()` now walks `op_body` recursively: every nested map's keys are sorted by their canonical CBOR-encoded bytes (RFC 8949 §4.2.3). Arrays preserve order (semantic ordering). Two new tests prove the property: * `op_body_key_order_does_not_affect_hash` — flat map, alphabetical vs reverse-alphabetical insertion order → identical envelope_hash. * `op_body_nested_map_key_order_does_not_affect_hash` — nested map recursion check. Total audit-module tests now 21. Workspace cargo test clean. ## Fix 2 — arch.md event signatures match the actual contract Architect finding (section 3): arch.md §15.3a `AuditAppendedV2` / `AuditRootAppendedV2` declarations included `entryIndex` / `rootIndex` fields that the actual `CredentialAudit.sol` events do NOT emit. Explorer implementers reading arch.md would have expected fields that aren't there. Doc updated to match the live contract surface. Added a sentence explaining V2's event-only design: position within the operator's stream is derivable from `(block_number, log_index)` so the contract doesn't need to carry `entryIndex` explicitly. ## What this PR ships (cumulative across all commits) Phase A — arch.md §15.3a (canonical schema + table + non-break invariants + migration phases) ✅ Phase B — agentkeys-core::audit module + worker V2 endpoints + AuditClient ✅ Phase C — CredentialAudit.appendV2 + appendRootV2 (code + 5 forge tests; redeploy is operator action) ✅ Phase D / E (subscan-essentials decoder + UI) tracked at subscan-essentials#12. Phase F (extend emit coverage to sign/scope/device/payment/email/K3) tracked at agentkeys#97.
PR #96 retired legacy CLI commands (cmd_link, cmd_recover, cmd_usage) and the bulk broker endpoints. This PR (#95) independently added two new signer subcommands (cmd_signer_sign_typed_data, cmd_signer_preview_7730). The conflict in main.rs was the import list — kept the new additions, dropped the retired ones. Workspace build clean; full cargo test suite passes; no behaviour change from the merge resolution beyond combining the two PRs' independent additions/removals.
… rule Three related changes addressing user request after the #97 op-kind work: ## 1. How-to-add-a-new-op-kind documentation ### arch.md §15.3b — the 5-step ritual Brief operator-facing ritual: (1) pick the byte from the appropriate family range, (2) append a row to §15.3a canonical table, (3) add the Rust variant in `audit::{op_kind,bodies,mod}`, (4) wire the emit site via `envelope_for` + `AuditClient::append`, (5) ship 3 tests (CBOR roundtrip + explorer Unknown(byte) fallback + arch.md row uniqueness). Critical invariant called out: never bump ENVELOPE_VERSION for a new op_kind. The version is reserved for envelope-level breakage; open-enum op_kinds are the whole point. ### wiki/audit-envelope-add-op-kind.md — detailed worked example Walks through adding `PaymentRefund` (byte 32) end-to-end: - Step-by-step code for op_kind.rs / bodies.rs / mod.rs. - Sample emit-site wiring in a worker handler. - Complete PR checklist + the explicit "what you DON'T need to do" list (no contract redeploy, no version bump, no migration, no synchronous rollout). Lives under `./wiki/` per CLAUDE.md "Wiki-location policy" — auto- publishes to the GitHub wiki on every push to main. ## 2. scripts/setup-heima.sh — single idempotent entry point Mirrors the `scripts/setup-broker-host.sh` pattern: one operator-facing orchestrator that runs the entire Heima chain bring-up + binding flow end-to-end in 15 idempotent steps. Delegates to the existing per-action helpers (`heima-bring-up.sh`, `heima-device-register.sh`, `heima-agent-create.sh`, `heima-scope-set.sh`, `heima-credential-audit.sh`, `heima-worker-smoke.sh`, `verify-heima-contracts.sh`) so: - Each helper's existing idempotency check (`cast call <view-fn>`, `cast code <addr>`, `cast balance ≥ amount`, file-exists guards) is preserved. - Per-action helpers stay callable directly for surgical re-runs (e.g. `bash scripts/heima-scope-set.sh ...` for just the scope work). - The orchestrator is THE entry point operators run — same posture as setup-broker-host.sh. Flag surface mirrors the harness orchestrators: `--chain`, `--session-id`, `--agent-label`, `--service`, `--webauthn`, `--yes`, `--from-step N`, `--to-step N`, `--only-step N`, `--help`. Two append-only steps (13 audit append + 14 tier-A relay) are explicitly called out in the header per the CLAUDE.md rule: "If a remote-setup script you're writing CAN'T be made idempotent (...append-only audit event), explicitly call it out." `bash -n` clean; `--help` renders correctly. ## 3. CLAUDE.md — idempotent remote-setup rule New section "Idempotent remote-setup rule (CLOUD / BLOCKCHAIN / CI / VM)" makes the existing implicit pattern an explicit project policy: - Every remote-mutation script (AWS / Heima / CI / VM / Cloudflare / Tencent / IAM / DNS) MUST be idempotent. Re-runs MUST exit 0 without re-applying. - Three reasons: operators retry, CI re-runs, the harness re-runs as a regression gate. - Concrete pre-check / short-circuit table for 9 mutation types (contract deploy, chain tx, fund EVM account, AWS resource, systemd unit, env file, nginx vhost, DNS A record, key gen). - Output convention: `ok proceeding` / `skip <reason>` / `fail <reason>` so the harness can read state per step. - Exception clause: if truly non-idempotent (one-shot CAS-burn cap, append-only audit event), explicitly call it out in script header AND runbook. Also adds "Heima chain (single entry point)" section pointing at the new `setup-heima.sh`.
4 tasks
The previous version of this guide stopped at the agentKeys-side ritual
and left explorer work as a one-line bullet ('explorer-side PR'). Per
follow-up request — flesh out what 'update the explorer' actually means
across the two separate repos (subscan-essentials + subscan-essentials-
ui-react) so an operator working through the guide doesn't have to
reverse-engineer the seam.
## New section structure
The page now has three parallel tracks:
1. **agentKeys-side PR** — the original 5-step ritual (unchanged).
2. **Indexer-side PR** ([litentry/subscan-essentials](https://github.com/litentry/subscan-essentials)): Go
decoder registration, typed XxxDecoder impl, REST shape, three
tests (canonical-fixture decode + unknown-byte non-break +
cross-language hash match).
3. **UI-side PR** ([litentry/subscan-essentials-ui-react](https://github.com/litentry/subscan-essentials-ui-react)):
React renderer component, registration in OP_KIND_RENDERERS map,
Storybook story + fallback story.
## What the new explorer section adds
- **§A1-A4**: Concrete Go code samples for the new PaymentRefund (byte
32) example — decoder table entry, typed body struct with CBOR tags,
REST shape function, generic event-handler dispatch that stays
op-kind-agnostic, and the three required tests.
- **§B1-B3**: React renderer component with Field/Card layout, registry
entry, Storybook expectation.
- **§C**: Shared cross-language test vectors as the load-bearing
cross-encoder determinism guard. Tracked as a follow-up alongside
the next new op_kind.
- **Phasing table**: Visual confirmation of the non-break trade-off at
each column (operator emit-site → chain event → worker → indexer →
UI), showing that at every step the system is functional and the
only visible degradation between phases is 'uglier UI temporarily
for old explorers.'
## PR checklist split
The checklist is now three sub-checklists — one per repo — so a PR
author can see exactly what lands in each of the three independent
PRs. The agentKeys-side PR is fully self-contained; the other two land
on their own cadence per the non-break design.
## The gap (what the user asked)
Before this commit, the K11 WebAuthn ceremony's localhost confirmation
page showed the operator ONLY:
Operator 0xb3224706…
RP ID localhost
Challenge 0xdead…beef ← 32 bytes — what's actually signed
The operator had no way to tell WHAT they were authorizing — just the
opaque 32-byte challenge hex. WebAuthn's OS-level Touch ID prompt is
fixed by the platform; it can't show application text either. So the
operator was blind-signing — exact same failure mode arch.md §15.3a
called out for typed-data signs, but at the K11 binding site.
## What this commit changes
`crates/agentkeys-cli/src/k11_webauthn.rs`:
* **New public type** — `K11IntentContext { text: Option<String>,
fields: Vec<(String, String)> }`. Display-only operator-readable
intent description + per-field rows.
* **New public entry points**:
- `assert_webauthn_with_intent(operator_omni, message, rp_id, intent)`
— assert with operator intent rendered.
- `assert_webauthn_for_chain_with_intent(operator_omni,
expected_challenge, rp_id, intent)` — chain-ready variant.
* **Legacy entry points unchanged**: `assert_webauthn`,
`assert_webauthn_with_rp`, `assert_webauthn_for_chain` still work —
they pass `K11IntentContext::empty()` internally, so existing call
sites + existing tests are bit-identical to before.
* **Confirmation page HTML** now renders a bordered intent block above
the raw challenge dump when intent is supplied:
YOU ARE ABOUT TO AUTHORIZE:
Grant agent demo-agent access to openrouter
Agent omni 0xb3224706…cc999E02
Service openrouter
Max calls / hour 100
K3 epoch 1
Expires 2026-06-20T22:13:20Z
Review the above BEFORE pressing Sign. The Touch ID prompt itself
cannot show this text — your eyes are the last line of defense
between the daemon's claim and the signature.
* **New `html_escape` helper** + 3 tests proving malicious daemon-supplied
intent strings cannot inject `<script>` into the page. The daemon
controls the intent payload but the page's safety properties
(operator sees real intent, localhost-only origin, OS prompt fires)
hold regardless.
* **Challenge label updated** to `Challenge (raw)` + meta-text
`"32-byte commitment — what WebAuthn actually signs"` so the
operator understands the relationship between the intent text + the
challenge bytes.
## Cryptographic binding (unchanged)
The intent parameter is DISPLAY-ONLY. The signed payload is still:
challenge_bytes = sha256(message) # or pre-computed for chain submission
clientDataJSON = {"type":"webauthn.get","challenge":b64url(challenge_bytes),"origin":"..."}
authData = rpIdHash || flags || signCount
signature = ECDSA-P256(sha256(authData || sha256(clientDataJSON)))
Adding the intent does NOT change any existing signature consumer
(broker / on-chain K11Verifier / audit-row verifier).
## Audit binding — intent_commitment
The same intent string fed to the WebAuthn page SHOULD populate
`AuditEnvelope.intent_text` + `AuditEnvelope.intent_commitment`. The
audit commitment is `keccak256(intent_text || 0x7c || op_payload_digest)`
— so auditors later can verify the operator saw text T AND the audit
row commits to T. Closes the "what did the operator actually see?"
forensics gap end-to-end (page-render → operator-eyes → audit-row →
chain-commitment).
## Documentation
* `wiki/k11-webauthn-intent-rendering.md` (NEW, 200+ lines):
- The OS-level constraint (why custom Touch ID prompts are
impossible).
- Where AgentKeys closes the gap (localhost confirmation page).
- The intent block design (header / headline / fields / caveat).
- Public API + worked example for scope-grant.
- Cryptographic-binding-unchanged guarantee.
- Audit-binding mapping to AuditEnvelope.intent_text +
intent_commitment.
- When-to-provide-an-intent table per call site.
- Tests reference.
* `wiki/audit-envelope-add-op-kind.md`: cross-link added — every new
master-mutation op_kind PR also wires `assert_webauthn_*_with_intent`.
* `docs/spec/architecture.md` §10.1: cross-link added pointing at the
new wiki page; explains the page is where intent rendering happens
and binds to the audit row.
## Tests
`cargo test -p agentkeys-cli --lib k11_webauthn`: 9 tests pass (5 new):
* html_escape_neutralizes_script_injection — load-bearing safety check.
* html_escape_handles_quote_chars.
* html_escape_passes_safe_text_through.
* k11_intent_context_empty_is_default.
* k11_intent_context_with_text_is_not_empty.
Full workspace `cargo test --workspace` clean.
End-to-end visual verification (manual): open the confirmation page
during `harness/v2-stage1-demo.sh --webauthn` — intent block renders
above the challenge hex.
## Symptom (the user's report)
\`bash harness/v2-stage2-demo.sh --webauthn\` step 6 failed with:
fail cast send failed: Error: Failed to estimate gas: server returned
an error response: error code -32603: VM Exception while processing
transaction: revert, data: \"0xa98bbce05f0fa99105175d11f8a6f7e5f60…\"
## Diagnosis
Selector \`0xa98bbce0\` decodes to
\`SidecarRegistry.DeviceAlreadyRegistered(bytes32)\`. The 32-byte arg
\`0x5f0fa991…\` is the companion's device_key_hash — the device was
ALREADY registered on chain (from a prior \`--webauthn\` run that ran
through). The script blindly re-submitted the registerAdditionalMaster
tx instead of pre-checking + skipping. Idempotency hole.
## Fix
\`harness/scripts/heima-device-add.sh\` Step 1 now pre-reads
\`SidecarRegistry.getDevice(deviceKeyHash)\` and short-circuits when
\`registeredAt > 0\` (the canonical pre-check shape from CLAUDE.md
\"Idempotent remote-setup rule\" — \"Chain tx → cast call <view-fn>
returning canonical state → skip already-registered\").
Three paths:
* \`registeredAt = 0\` (not on chain yet) → log \"proceeding\" + continue
the existing flow (K11 ceremony + cast send).
* \`registeredAt > 0\` + \`revoked = false\` → log \`skip already-registered\`
with JSON output \`{\"ok\":true,\"skipped\":\"already-registered\",
\"device_key_hash\":\"…\",\"registered_at\":<ts>}\` and exit 0 — no
K11 ceremony, no tx, the harness step records green.
* \`registeredAt > 0\` + \`revoked = true\` → die with clear operator
message: \"re-registering a revoked device requires a new device
hash; generate a fresh companion device + re-enroll.\" (the contract
would revert anyway; failing loud + clear here saves the operator
one round-trip + one Touch ID tap.)
Sibling scripts (\`heima-register-first-master.sh\`,
\`heima-register-spare-master.sh\`, \`heima-agent-create.sh\`,
\`heima-device-register.sh\`) already had this check — verified via
\`grep -c\`. \`heima-device-add.sh\` was the only outlier.
## Why this is the CLAUDE.md \"runbook-fix-fold-back\" pattern
This is the second iteration of CLAUDE.md \"Idempotent remote-setup
rule\" enforcement. The rule listed \"Chain tx (register / scope /
audit append) → cast call <view-fn> returning canonical state\" as
the canonical pre-check shape. Every script that mutates chain state
needs that check; the one without it broke the harness on re-run.
The fix lives where the bug is (the device-add helper); no runbook
revision needed because \`v2-stage2-demo.sh\` already calls the helper
by name + would now skip cleanly on re-runs.
## Test
\`bash -n harness/scripts/heima-device-add.sh\` clean.
Live: operator re-runs \`bash harness/v2-stage2-demo.sh --webauthn\` —
step 6 should now log \`skip device 0x…5f0fa991… already registered\`
and advance to step 7 instead of reverting.
Independent diff review via \`codex review --base main\`. Six findings, all real; all six fixed in this commit with regression tests for the testable ones (5 tests added). Workspace cargo test clean (47 suites, 0 failures). ## P1 (blocking) findings ### P1-1: Canonical CBOR top-level map order was lexicographic-by-text, not RFC 8949 §4.2.3 \`crates/agentkeys-core/src/audit/cbor.rs\` — the encoder hard-coded the top-level map in alphabetical-by-text order, but canonical CBOR sorts by the encoded BYTES (length-prefix first, then bytes). For our 9 envelope- level keys this means shorter keys like \`result\` (6 chars) MUST sort before longer keys like \`actor_omni\` (10 chars). The bug would have silently desynchronized \`envelope_hash\` between the Rust encoder and any RFC-8949-correct Go or TypeScript encoder — exactly the cross-language determinism property the doc + the tests claim. The existing recursive \`canonicalize()\` helper already had the correct sort logic for \`op_body\` inner maps; the top-level map was simply bypassing it. **Fix:** route the top-level map through the same \`canonicalize()\` helper. Single source of truth for byte ordering — top-level + nested can never drift again. **Regression test:** \`top_level_map_keys_emitted_in_canonical_cbor_order\` decodes the output bytes and asserts the key order is the exact canonical sequence: \`result, op_body, op_kind, ts_unix, version, actor_omni, intent_text, operator_omni, intent_commitment\`. ### P1-2 + P1-3: setup-heima.sh called non-existent flags on helper scripts \`scripts/setup-heima.sh\` step 4 called \`heima-bring-up.sh --only-step gen-key\` and step 5 called \`heima-fund-account.sh --target deployer\`. Neither flag exists. \`heima-bring-up.sh\` has no \`--only-step\` parser so extra args were silently ignored and the FULL bring-up ran from step 1 (funding + deploying contracts when the operator only wanted key generation). \`heima-fund-account.sh\` rejects unknown flags so step 5 would hard-fail with \"--to is required\". **Fix:** delegate the entire \"make-chain-ready\" flow (key gen → fund → deploy → persist addresses) to a SINGLE call to \`heima-bring-up.sh\` in step 4 — that script is the canonical idempotent owner of the flow and pre-checks every mutation itself. Step 5 now derives the deployer address from the persisted key (\`cast wallet address\`) and calls \`heima-fund-account.sh --to <addr>\` with the flag the helper actually accepts. Steps 6 + 7 become explicit no-ops with comments pointing at step 4. \`bash -n scripts/setup-heima.sh\` clean. ## P2 (quality) findings ### P2-4: U256::shl returned ZERO at 64-bit boundaries \`crates/agentkeys-core/src/clear_signing/eip712.rs\` — \`U256::ONE.shl(64)\` produced \`0\` because the prior off-by-one impl copied \`self.limbs[3 - src]\` where \`src = i + limb_shift\`. When \`bit_shift == 0\` (i.e. \`bits\` is a multiple of 64), \`hi\` reduced to a plain limb copy from the wrong slot — for \`Self::ONE.shl(64)\` this copied \`self.limbs[2]\` (zero) into \`out[3]\` instead of \`self.limbs[3]\` (the value 1) into \`out[2]\`. Practical effect: every \`uint64: N\`, \`uint128: N\`, \`uint192: N\` (and the matching int sizes) in a typed-data field hit the range check \`big >= U256::ONE.shl(bits)\` with the right side spuriously zero, so the EIP-712 signer rejected valid values like \`uint64: 1\` as out-of-range — making the new typed-data sign path unusable for common fixed-width integer fields outside the existing \`uint8\`/\`uint256\` test coverage. **Fix:** re-implement \`shl\` to iterate INPUT limbs LSB-first; each non-zero limb's bits land in its primary output slot (shifted up by \`bit_shift\`) plus a secondary slot when \`bit_shift > 0\`. No off-by-one possible. **Regression tests:** - \`u256_shl_at_64_bit_boundary_does_not_drop_to_zero\`: asserts \`U256::ONE.shl(64) == 2^64\`, same for 128 + 192. - \`uint64_accepts_value_one\`: end-to-end at the encoder layer. - \`uint128_accepts_mid_range_value\`: confirms 2^127 round-trips. ### P2-5: int256 range check was skipped entirely \`encode_int\` guarded the range check behind \`if bits < 256\` so for \`int256\` fields no check ran. Values >= 2^255 (which should be rejected — they wrap into negative two's-complement under signed-256) were accepted silently. An attacker could craft a typed-data payload whose declared int256 value lies outside the signed range and get a signature anyway. **Fix:** drop the \`if bits < 256\` guard. The boundary \`pos_max = U256::ONE.shl(bits - 1)\` fits in U256 for every supported N from 8 to 256 (for N=256, pos_max = 2^255 — exactly representable). **Regression tests:** - \`int256_rejects_value_at_or_above_2_pow_255\`: 2^255 → rejected. - \`int256_accepts_max_positive\`: 2^255 - 1 → accepted. - \`int256_accepts_min_negative\`: -2^255 → accepted. ### P2-6: clap-derived flag name was --seven-thirty-file, docs said --7730-file \`crates/agentkeys-cli/src/main.rs\` — clap derives the long-flag name from the Rust field ident. \`seven_thirty_file\` becomes \`--seven-thirty-file\`. But the command's \`long_about\` text + every example advertised \`--7730-file\`. Users following the doc would hit \"unrecognized argument: --7730-file\". **Fix:** explicit \`#[arg(long = \"7730-file\", ...)]\` override. \`agentkeys signer preview-7730 --help\` now shows the \`--7730-file <SEVEN_THIRTY_FILE>\` flag matching the docs. ## Test summary - \`cargo test -p agentkeys-core --lib audit\`: 22 tests pass. - \`cargo test -p agentkeys-core --lib clear_signing\`: 37 tests pass. - \`cargo test --workspace\`: 47 test suites, 0 failures. - \`bash -n scripts/setup-heima.sh\`: clean. - \`target/debug/agentkeys signer preview-7730 --help\`: shows \`--7730-file\`.
## Answer to the user's question > in local webauthn signing process with touchID, I see challenge is a > encoded raw data, is there a readable original text? YES — the library API for it shipped in PR #95 (\`assert_webauthn_with_intent\`, \`assert_webauthn_for_chain_with_intent\`, the \`K11IntentContext\` type, the HTML intent block above the raw challenge dump on the confirmation page). But the CLI subcommand \`agentkeys k11 assert --webauthn\` and the harness helper scripts still used the LEGACY non-intent entry points — so when the user ran the harness with \`--webauthn\`, the confirmation page rendered only the 32-byte challenge hex. The plumbing was incomplete at the seam between the harness scripts and the library. This commit completes the plumbing end-to-end. ## What changed ### CLI: \`agentkeys k11 assert --webauthn\` accepts intent flags \`crates/agentkeys-cli/src/main.rs\` — \`K11Action::Assert\` gains two new flags: - \`--intent-text <STRING>\` — the headline rendered prominently on the WebAuthn confirmation page. Example: \`--intent-text \"Grant agent demo-agent access to openrouter\"\`. - \`--intent-field <Label=Value>\` (repeatable) — per-field detail rows below the headline. Example: \`--intent-field \"Service=openrouter\" --intent-field \"K3 epoch=1\"\`. Both flags are ignored in stub mode (\`--webauthn\` not passed). The dispatch builds a \`K11IntentContext\` and calls the corresponding \`*_with_intent\` library entry point. \`Label=Value\` parsing splits on the FIRST \`=\` (so values may contain \`=\` themselves); empty labels + rows without \`=\` are rejected with a clear operator-facing error. ### Harness scripts: 5 call sites now pass op-specific intents | Script | Op | Intent text | |---|---|---| | \`harness/scripts/heima-device-add.sh\` | \`registerAdditionalMasterDevice\` | \"Register companion device as 2nd master\" + new device hash, role bitfield, companion RP ID, chain ID, nonce | | \`harness/scripts/heima-recovery.sh\` | \`revokeMasterDevice\` (M-of-N) | \"Revoke master device via M-of-N recovery quorum\" + target hash, threshold, asserting role, chain ID | | \`scripts/heima-device-revoke.sh\` | \`revokeDevice\` (master) | \"⚠ REVOKE MASTER device — this disables the operator's master entirely\" + master hash, wallet, recovery note | | \`scripts/heima-scope-set.sh\` | \`setScopeWithWebauthn\` | \"Grant agent '<label>' access to: <services>\" + agent omni, services list, read-only flag, max-per-call, max-per-period, max-total, period, chain ID, scope nonce | | \`scripts/heima-scope-revoke.sh\` | \`revokeScope\` | \"Revoke all scope grants for agent '<label>'\" + agent omni, effect note, chain ID, scope nonce | Each intent is hand-tailored to the op's actual semantics — the \`device-revoke\` master path gets a ⚠-prefixed warning because the operator is one Touch ID tap away from disabling their own master entirely; the others get straightforward descriptive text. ## What the operator sees now Before: \`\`\` 🔑 PRIMARY MASTER K11 assertion Operator 0xb3224706… RP ID localhost Challenge 0xdead…beef ← 32 bytes — only what they saw \`\`\` After (scope-set example): \`\`\` 🔑 PRIMARY MASTER K11 assertion YOU ARE ABOUT TO AUTHORIZE: Grant agent 'demo-agent' access to: openrouter,brave-search Agent label demo-agent Agent omni 0xb3224706… Services openrouter,brave-search Read-only false Max amount per call 1000000000000000000 (0 = unlimited) Max amount per period 10000000000000000000 over 86400s (0 = unlimited) Max total amount 0 (0 = unlimited) Chain ID 212013 Scope nonce 5 Review the above BEFORE pressing Sign. The Touch ID prompt itself cannot show this text — your eyes are the last line of defense between the daemon's claim and the signature. Operator 0xb3224706… RP ID localhost Challenge (raw) 0xdead…beef ← 32-byte commitment — what WebAuthn actually signs [ Sign as PRIMARY MASTER ] \`\`\` The intent rendering is display-only (cryptographic binding is still \`challenge = sha256(message)\`, unchanged). It exists because WebAuthn's OS-level Touch ID prompt is fixed by the platform — no application can inject custom text. The localhost confirmation page is the only surface where AgentKeys can render what's being authorized. ## Tests - \`cargo build -p agentkeys-cli\` clean. - \`cargo test -p agentkeys-cli --lib k11_webauthn\` — 9 tests pass (including the html_escape regression tests proving malicious daemon- supplied intent strings cannot inject \`<script>\` into the page). - \`bash -n\` clean on all 5 updated scripts. End-to-end visual verification (manual): re-run \`harness/v2-stage2-demo.sh --webauthn\` — the Touch ID confirmation page for each master mutation now shows the headline + per-field rows above the challenge hex.
## Symptom (operator-reported)
\`bash harness/v2-stage1-demo.sh\` step 8 (Smoke-test S3 envelope) fails:
read failed: internal error:
assume_role_with_web_identity(arn:aws:iam::…:role/agentkeys-vault-role):
dispatch failure
\"dispatch failure\" alone is unactionable — could be DNS, TCP, TLS, proxy,
or 'no connector available' (a config bug). The operator can't tell which
without re-running the SDK with debug logs.
## Root cause
\`aws_sdk_sts::Error\`'s \`Display\` impl renders ONLY the top-level
\`SdkError\` variant. For \`DispatchFailure\` that's the literal string
\"dispatch failure\" with no causal info. The real reason lives in the
\`source()\` chain — which both AgentKeys call sites swallowed:
* [crates/agentkeys-provisioner/src/aws_creds.rs](crates/agentkeys-provisioner/src/aws_creds.rs) — operator-side STS for cred reads
* [crates/agentkeys-broker-server/src/sts.rs](crates/agentkeys-broker-server/src/sts.rs) — broker-side \`/v1/mint-aws-creds\`
Both did \`format!(\"…: {}\", e)\` which loses the chain.
## Fix
Walk \`std::error::Error::source()\` recursively at the catch site, flatten
into a one-line message:
msg = \"assume_role_with_web_identity(…): dispatch failure | caused by:
dns error: failed to lookup address information: nodename nor
servname provided, or not known\"
(...or whichever layer actually failed.) After this lands, the operator's
next retry surfaces the actual error: DNS, TCP, TLS, proxy, or
no-connector-configured. From there the fix is one-line (\"export
HTTPS_PROXY=…\" / \"check corporate VPN\" / \"update CA bundle\") or, if it
turns out to be no-connector, a separate in-repo fix (add hyper-rustls
feature).
## Why both call sites
Symmetry: the same diagnostic gap exists on broker-side (when the broker
mints creds via \`/v1/mint-aws-creds\`). Fixing only the operator side
would leave the broker emitting the same useless message later.
## Test plan
- \`cargo build -p agentkeys-provisioner -p agentkeys-broker-server --release\`
clean.
- Operator retries:
\`bash harness/v2-stage1-demo.sh --only-step 8\`
Expect: \"dispatch failure | caused by: <real reason>\" replacing the
bare \"dispatch failure\".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refresh of #82 against v2 architecture (#87 / #92). The original issue targeted v1 (mock-server-as-signer, daemon-side metadata, broker SQLite audit); plan was rewritten to v2 surfaces (signer typed RPC, worker audit rows with intent commitments, ERC-7730 catalog as a §22 pluggable surface).
Full v2-aligned plan:
docs/spec/plans/issue-82-erc7730-v2-aligned.md.What ships
Phase 1 — EIP-712 typed-data signing at the signer
POST /dev/sign-typed-dataon the mock-server signer. Accepts canonical EIP-712 v4 JSON (matches MetaMasketh_signTypedData_v4), parses + hashes internally (never trusts a caller-supplied prehash), returns 65-byte signature + every intermediate digest (primary_type_hash,domain_separator,digest).DevKeyService::sign_eip712+Eip712SignResultenvelope.SignerError::InvalidTypedData(400) propagated throughSignerClientError.SignerClient::sign_eip712trait method +HttpSignerClientimpl.Phase 2 —
clear_signingmodule inagentkeys-corecrates/agentkeys-core/src/clear_signing/:eip712.rsparser.rsformat.rs{name}intent interpolatorbinding.rscatalog.rs$AGENTKEYS_7730_DIRmod.rs::build_previewintent_text+intent_commitment=keccak256(intent_text || 0x7c || digest)Phase 3 — CLI preview surfaces
Two new subcommands under
agentkeys signer:sign-typed-data --signer-url ... --omni-account ... --typed-data-file ./permit.json [--preview-7730]— calls/dev/sign-typed-data. With--preview-7730, renders operator intent + per-field review before signing.preview-7730 --typed-data-file ./permit.json [--7730-file ./erc20-permit-usdc.json]— render WITHOUT signing. Dry-run for new 7730 files.Both pick up
$AGENTKEYS_7730_DIRfor operator-custom 7730 files. Both support--jsonfor machine output.Phase 4 — audit-row intent-commitment schema (arch.md only)
arch.md §15.3extended with two optional audit-row fields (signed_intent_text,signed_intent_hash). Schema is backwards-compatible — pre-#82 rows have the fields absent; worker reads/writes land in a follow-up PR.Docs
docs/spec/signer-protocol.md— full/dev/sign-typed-datawire contract.docs/spec/architecture.md§14.2 + §15.3 + §22 — typed-data RPC in the signer surface, audit-row intent-commitment fields, clear-signing metadata as a pluggable surface (bundled → registry → on-chain progression).docs/spec/plans/issue-82-erc7730-v2-aligned.md— full plan refresh, including Phase 5 (K11-binding-on-high-value-signs, out of scope here — needs ScopeContract extension).Test plan
cargo test --workspace— 600+ tests, 0 failures, 1 ignored.agentkeys-core::clear_signing(EIP-712 spec reference vector, cyclic type detection, integer range checks, array length validation, U256 dec/hex roundtrip, two's-complement negation, parser, formatter, binding, catalog).dev_key_service.rs(recovers-to-derived-address, malformed-typed-data rejection).dev_key_service_routes.rs(200 / 400-unknown-primary / 400-out-of-range-uint / 503-signer-disabled / address-matches-derive / full-sig-recovery roundtrip).cargo clippy— clean on all new code; pre-existing warnings unchanged./agentkeys-harness) — to be run by the operator after merge against Heima mainnet to confirm no regression on v2 stage-1/2/3 demos.What did NOT land (follow-ups, tracked in the plan doc)
intent_commitmentfor typed-data signs. Daemon currently goes direct to signer viasigner_client. When broker mediation lands, the cap-token will carry the commitment.agentkeys-worker-auditdoesn't read the new schema fields yet (forward-compatible; unknown fields silently ignored). Schema is documented so the follow-up PR has a fixed target.CredentialAuditevent extension — needs a contract revision + redeploy; out of scope for a signer + worker change.github.com/ethereum/clear-signing-erc7730-registryintegration is v1 catalog source per arch.md §22; v0 bundled set ships in this PR.🤖 Generated with Claude Code