From a4d47efcb483f4a12387b584d30b201c5cd5b5ba Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 01:25:55 +0800 Subject: [PATCH 01/15] issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/agentkeys-cli/src/lib.rs | 164 ++++ crates/agentkeys-cli/src/main.rs | 51 +- crates/agentkeys-core/Cargo.toml | 7 +- .../src/clear_signing/binding.rs | 144 +++ .../src/clear_signing/catalog.rs | 145 +++ .../src/clear_signing/eip712.rs | 832 ++++++++++++++++++ .../fixtures/erc20-permit-usdc.json | 34 + .../src/clear_signing/format.rs | 332 +++++++ .../agentkeys-core/src/clear_signing/mod.rs | 214 +++++ .../src/clear_signing/parser.rs | 154 ++++ crates/agentkeys-core/src/lib.rs | 1 + crates/agentkeys-core/src/s3_backend.rs | 17 +- crates/agentkeys-core/src/signer_client.rs | 87 +- .../src/dev_key_service.rs | 157 +++- .../src/handlers/dev_keys.rs | 41 + crates/agentkeys-mock-server/src/lib.rs | 7 + .../tests/dev_key_service_routes.rs | 196 +++++ docs/spec/architecture.md | 20 + .../spec/plans/issue-82-erc7730-v2-aligned.md | 204 +++++ docs/spec/signer-protocol.md | 94 +- 20 files changed, 2893 insertions(+), 8 deletions(-) create mode 100644 crates/agentkeys-core/src/clear_signing/binding.rs create mode 100644 crates/agentkeys-core/src/clear_signing/catalog.rs create mode 100644 crates/agentkeys-core/src/clear_signing/eip712.rs create mode 100644 crates/agentkeys-core/src/clear_signing/fixtures/erc20-permit-usdc.json create mode 100644 crates/agentkeys-core/src/clear_signing/format.rs create mode 100644 crates/agentkeys-core/src/clear_signing/mod.rs create mode 100644 crates/agentkeys-core/src/clear_signing/parser.rs create mode 100644 docs/spec/plans/issue-82-erc7730-v2-aligned.md diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index fb5e9b1..d27cc48 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -1595,6 +1595,164 @@ pub async fn cmd_signer_sign( } } +/// `agentkeys signer sign-typed-data` — call `/dev/sign-typed-data` on the +/// configured signer (issue #82). Reads an EIP-712 v4 JSON file (the same +/// shape MetaMask's `eth_signTypedData_v4` takes), forwards it to the +/// signer, prints the signature + each digest the signer computed. +/// +/// With `--preview-7730`, the CLI also renders the operator-facing intent +/// text against the bundled ERC-7730 catalog (or the dir at +/// `$AGENTKEYS_7730_DIR`) and prints it before signing — closes the "agent +/// signed 0xdead…beef without me knowing what it was" gap that the original +/// issue #82 calls out. +pub async fn cmd_signer_sign_typed_data( + ctx: &CommandContext, + signer_url: &str, + omni_account: &str, + typed_data_file: &str, + preview_7730: bool, +) -> Result { + let session = ctx + .load_session() + .context("load session (run `agentkeys init` first)")?; + + let json = std::fs::read_to_string(typed_data_file) + .with_context(|| format!("read typed-data file {typed_data_file}"))?; + let typed_data: agentkeys_core::clear_signing::TypedData = + serde_json::from_str(&json).context("parse typed-data JSON")?; + + let mut preview_block: Option = None; + if preview_7730 { + let catalog = load_default_catalog().context("load ERC-7730 catalog")?; + match agentkeys_core::clear_signing::build_preview(&catalog, typed_data.clone()) { + Ok(p) => preview_block = Some(p), + Err(e) => eprintln!( + "agentkeys signer sign-typed-data: ERC-7730 preview not available ({e}); signing without operator intent text" + ), + } + } + + let client = HttpSignerClient::new(signer_url).with_session_jwt(session.token); + let signed = client + .sign_eip712(omni_account, &typed_data) + .await + .map_err(format_signer_error)?; + + if ctx.json_output { + let mut body = json!({ + "signature": signed.signature, + "address": signed.address, + "primary_type_hash": signed.primary_type_hash, + "domain_separator": signed.domain_separator, + "digest": signed.digest, + "key_version": signed.key_version, + }); + if let Some(p) = preview_block.as_ref() { + body["intent_text"] = json!(p.intent_text); + body["intent_commitment"] = json!(format!("0x{}", hex::encode(p.intent_commitment))); + } + Ok(serde_json::to_string_pretty(&body).unwrap()) + } else { + let mut out = String::new(); + if let Some(p) = preview_block.as_ref() { + out.push_str("Operator intent (ERC-7730):\n "); + out.push_str(&p.intent_text); + out.push_str("\n\nFields:\n"); + for (l, v) in &p.fields { + out.push_str(&format!(" - {l}: {v}\n")); + } + out.push_str(&format!( + "\nIntent commitment: 0x{}\n\n", + hex::encode(p.intent_commitment) + )); + } + out.push_str(&format!( + "signature={}\naddress={}\nprimary_type_hash={}\ndomain_separator={}\ndigest={}\nkey_version={}", + signed.signature, + signed.address, + signed.primary_type_hash, + signed.domain_separator, + signed.digest, + signed.key_version, + )); + Ok(out) + } +} + +/// `agentkeys signer preview-7730` — render the operator-facing preview for +/// a typed-data JSON file WITHOUT signing (issue #82). Useful for dry-runs +/// against new ERC-7730 files before plumbing them into automated agent +/// signing. +pub async fn cmd_signer_preview_7730( + ctx: &CommandContext, + typed_data_file: &str, + seven_thirty_file: Option<&str>, +) -> Result { + let json = std::fs::read_to_string(typed_data_file) + .with_context(|| format!("read typed-data file {typed_data_file}"))?; + let typed_data: agentkeys_core::clear_signing::TypedData = + serde_json::from_str(&json).context("parse typed-data JSON")?; + + let catalog = match seven_thirty_file { + Some(path) => { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read 7730 file {path}"))?; + let file = agentkeys_core::clear_signing::parser::parse(&raw) + .map_err(|e| anyhow!("parse 7730 file: {e}"))?; + let mut c = agentkeys_core::clear_signing::ClearSigningCatalog::empty(); + c.push(file); + c + } + None => load_default_catalog().context("load default ERC-7730 catalog")?, + }; + + let preview = agentkeys_core::clear_signing::build_preview(&catalog, typed_data) + .map_err(|e| anyhow!("build preview: {e}"))?; + + if ctx.json_output { + Ok(serde_json::to_string_pretty(&json!({ + "intent_text": preview.intent_text, + "intent_commitment": format!("0x{}", hex::encode(preview.intent_commitment)), + "domain_separator": format!("0x{}", hex::encode(preview.digests.domain_separator)), + "primary_type_hash": format!("0x{}", hex::encode(preview.digests.primary_type_hash)), + "digest": format!("0x{}", hex::encode(preview.digests.final_digest)), + "fields": preview.fields.iter().map(|(l, v)| json!({"label": l, "value": v})).collect::>(), + })) + .unwrap()) + } else { + let mut out = String::new(); + out.push_str("Operator intent (ERC-7730):\n "); + out.push_str(&preview.intent_text); + out.push_str("\n\nFields:\n"); + for (l, v) in &preview.fields { + out.push_str(&format!(" - {l}: {v}\n")); + } + out.push_str(&format!( + "\nDigests:\n domain_separator: 0x{}\n primary_type_hash: 0x{}\n digest: 0x{}\n intent_commitment: 0x{}", + hex::encode(preview.digests.domain_separator), + hex::encode(preview.digests.primary_type_hash), + hex::encode(preview.digests.final_digest), + hex::encode(preview.intent_commitment), + )); + Ok(out) + } +} + +/// Load the default ERC-7730 catalog: bundled + (if `$AGENTKEYS_7730_DIR` +/// is set) every `*.json` file in that directory. Operators ship their own +/// curated 7730 files via the env var without needing to recompile. +fn load_default_catalog() -> Result { + let mut catalog = agentkeys_core::clear_signing::ClearSigningCatalog::bundled(); + if let Ok(dir) = std::env::var("AGENTKEYS_7730_DIR") { + if !dir.is_empty() { + catalog + .extend_from_dir(&dir) + .map_err(|e| anyhow!("load 7730 files from $AGENTKEYS_7730_DIR={dir}: {e}"))?; + } + } + Ok(catalog) +} + /// `agentkeys whoami` — read-only summary of the current session and the /// signer-derived wallet address (if a signer URL is supplied and the /// session carries an `omni_account` claim). @@ -1689,6 +1847,12 @@ fn format_signer_error(e: SignerClientError) -> anyhow::Error { SignerClientError::InvalidMessageHex(m) => { anyhow!("Error: INVALID_MESSAGE_HEX\n {}", m) } + SignerClientError::InvalidTypedData(m) => { + anyhow!( + "Error: INVALID_TYPED_DATA\n {}\n\n Fix: check the EIP-712 JSON — `types` must include `EIP712Domain`, every type referenced in `primaryType` must be declared, and field values must fit their declared type (uint8 ≤ 255, int8 ∈ [-128, 127], etc.).", + m + ) + } SignerClientError::Internal(m) => anyhow!("Error: SIGNER_INTERNAL\n {}", m), SignerClientError::Transport(m) => anyhow!( "Error: SIGNER_UNREACHABLE\n {}\n\n Fix: confirm --signer-url is reachable.", diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index f5fd883..89ab995 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -1,7 +1,8 @@ use agentkeys_cli::{ cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_link, cmd_provision, cmd_read, cmd_recover, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive, - cmd_signer_sign, cmd_store, cmd_teardown, cmd_usage, cmd_whoami, CommandContext, + cmd_signer_preview_7730, cmd_signer_sign, cmd_signer_sign_typed_data, cmd_store, cmd_teardown, + cmd_usage, cmd_whoami, CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode, }; @@ -383,6 +384,36 @@ enum SignerAction { #[arg(long, help = "Message to sign (sent as UTF-8 bytes)")] message: String, }, + + #[command( + name = "sign-typed-data", + about = "EIP-712 typed-data sign (issue #82)", + long_about = "Calls /dev/sign-typed-data on the configured signer. The file at --typed-data-file is an EIP-712 v4 JSON object (matches MetaMask `eth_signTypedData_v4`).\n\nThe signer parses the typed-data internally and computes the digest — callers MUST NOT pass a pre-hashed value.\n\nWith --preview-7730, the CLI also renders the operator-facing intent text against the bundled ERC-7730 catalog (override the dir via $AGENTKEYS_7730_DIR) and prints it before signing.\n\nExamples:\n agentkeys signer sign-typed-data --signer-url http://localhost:8090 --omni-account <64hex> --typed-data-file ./permit.json\n agentkeys signer sign-typed-data ... --preview-7730" + )] + SignTypedData { + #[arg(long, env = "AGENTKEYS_SIGNER_URL", help = "URL of the signer service")] + signer_url: String, + #[arg(long, help = "OmniAccount (64-hex-char SHA256 digest)")] + omni_account: String, + #[arg(long, help = "Path to a JSON file containing the EIP-712 v4 typed-data")] + typed_data_file: String, + /// Render the operator-facing intent text + per-field preview against + /// the bundled ERC-7730 catalog (override via $AGENTKEYS_7730_DIR). + #[arg(long)] + preview_7730: bool, + }, + + #[command( + name = "preview-7730", + about = "Render the ERC-7730 preview for a typed-data file WITHOUT signing (issue #82)", + long_about = "Useful for dry-runs against new ERC-7730 files before plumbing them into automated agent signing. Loads the bundled catalog (and $AGENTKEYS_7730_DIR if set) by default; --7730-file pins a single file.\n\nExamples:\n agentkeys signer preview-7730 --typed-data-file ./permit.json\n agentkeys signer preview-7730 --typed-data-file ./permit.json --7730-file ./erc20-permit-usdc.json" + )] + Preview7730 { + #[arg(long, help = "Path to a JSON file containing the EIP-712 v4 typed-data")] + typed_data_file: String, + #[arg(long, help = "Optional: pin to a single ERC-7730 file instead of the bundled catalog")] + seven_thirty_file: Option, + }, } #[derive(Subcommand)] @@ -668,6 +699,24 @@ async fn main() { SignerAction::Sign { signer_url, omni_account, message } => { cmd_signer_sign(&ctx, signer_url, omni_account, message).await } + SignerAction::SignTypedData { + signer_url, + omni_account, + typed_data_file, + preview_7730, + } => { + cmd_signer_sign_typed_data( + &ctx, + signer_url, + omni_account, + typed_data_file, + *preview_7730, + ) + .await + } + SignerAction::Preview7730 { typed_data_file, seven_thirty_file } => { + cmd_signer_preview_7730(&ctx, typed_data_file, seven_thirty_file.as_deref()).await + } }, Commands::Chain { action } => cmd_chain(&ctx, action).await, Commands::K11 { action } => cmd_k11(action).await, diff --git a/crates/agentkeys-core/Cargo.toml b/crates/agentkeys-core/Cargo.toml index 64ea660..ffdc339 100644 --- a/crates/agentkeys-core/Cargo.toml +++ b/crates/agentkeys-core/Cargo.toml @@ -28,13 +28,16 @@ aws-sdk-s3 = "1" aws-credential-types = "1" aes-gcm = "0.10" rand = "0.8" +# Issue #82 — ERC-7730 clear-signing + EIP-712 typed-data hashing live in +# `clear_signing/`. k256 is needed for the optional in-process signing path +# (tests, CLI preview); sha3 for keccak256 in the EIP-712 encoder. +k256 = { version = "0.13", features = ["ecdsa", "sha2"] } +sha3 = "0.10" [dev-dependencies] tempfile = "3" agentkeys-mock-server = { path = "../agentkeys-mock-server" } axum = { version = "0.7", features = ["json"] } -k256 = { version = "0.13", features = ["ecdsa", "sha2"] } -sha3 = "0.10" rusqlite = { version = "0.31", features = ["bundled"] } rand_core = { version = "0.6", features = ["std"] } getrandom = "0.2" diff --git a/crates/agentkeys-core/src/clear_signing/binding.rs b/crates/agentkeys-core/src/clear_signing/binding.rs new file mode 100644 index 0000000..7c0b793 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/binding.rs @@ -0,0 +1,144 @@ +//! Domain → ERC-7730 file binding (issue #82). +//! +//! Given an EIP-712 typed-data domain, locate the ERC-7730 file in the +//! catalog that describes how to render the message. v0 binding rule: +//! exact match on `{name, version, chainId, verifyingContract}` — at least +//! one of these MUST match, all set fields MUST match. Unset fields in the +//! 7730 file are wildcards. + +use super::parser::{Erc7730Eip712Domain, Erc7730File}; +use super::eip712::TypedData; + +/// Look up the ERC-7730 file whose `context.eip712.domain` matches the +/// typed-data `domain`. Returns `None` if no file in the catalog matches. +pub fn match_file<'a>( + files: impl IntoIterator, + typed_data: &TypedData, +) -> Option<&'a Erc7730File> { + let td_domain = parse_typed_data_domain(&typed_data.domain)?; + for file in files { + if let Some(ctx) = &file.context.eip712 { + if domain_matches(&ctx.domain, &td_domain) { + return Some(file); + } + } + } + None +} + +pub(crate) fn parse_typed_data_domain( + domain: &serde_json::Value, +) -> Option { + let obj = domain.as_object()?; + Some(Erc7730Eip712Domain { + name: obj.get("name").and_then(|v| v.as_str()).map(str::to_string), + version: obj.get("version").and_then(|v| v.as_str()).map(str::to_string), + chain_id: obj + .get("chainId") + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))), + verifying_contract: obj + .get("verifyingContract") + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase()), + }) +} + +fn domain_matches(file: &Erc7730Eip712Domain, td: &Erc7730Eip712Domain) -> bool { + if let Some(f) = &file.name { + if td.name.as_ref() != Some(f) { + return false; + } + } + if let Some(f) = &file.version { + if td.version.as_ref() != Some(f) { + return false; + } + } + if let Some(f) = file.chain_id { + if td.chain_id != Some(f) { + return false; + } + } + if let Some(f) = &file.verifying_contract { + let f_lower = f.to_lowercase(); + if td.verifying_contract.as_ref() != Some(&f_lower) { + return false; + } + } + // At least one field MUST have been set, otherwise this is a wildcard + // file that matches everything — refuse to bind. + file.name.is_some() + || file.version.is_some() + || file.chain_id.is_some() + || file.verifying_contract.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clear_signing::parser::parse; + use serde_json::json; + use std::collections::BTreeMap; + + fn usdc_permit_file() -> Erc7730File { + let json = r#"{ + "context": { "eip712": { "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + } } }, + "metadata": {}, + "display": { "formats": { "Permit": { "intent": "x" } } } + }"#; + parse(json).unwrap() + } + + fn permit_td(verifying: &str) -> TypedData { + TypedData { + primary_type: "Permit".into(), + types: BTreeMap::new(), + domain: json!({ + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": verifying, + }), + message: json!({}), + } + } + + #[test] + fn exact_match_succeeds() { + let files = vec![usdc_permit_file()]; + let td = permit_td("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + assert!(match_file(&files, &td).is_some()); + } + + #[test] + fn match_is_case_insensitive_on_address() { + let files = vec![usdc_permit_file()]; + let td = permit_td("0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48"); + assert!(match_file(&files, &td).is_some()); + } + + #[test] + fn mismatched_chain_id_fails() { + let files = vec![usdc_permit_file()]; + let mut td = permit_td("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + td.domain.as_object_mut().unwrap().insert("chainId".into(), json!(137)); + assert!(match_file(&files, &td).is_none()); + } + + #[test] + fn empty_file_domain_is_wildcard_refused() { + let json = r#"{ + "context": { "eip712": { "domain": {} } }, + "metadata": {}, + "display": { "formats": {} } + }"#; + let files = vec![parse(json).unwrap()]; + let td = permit_td("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + assert!(match_file(&files, &td).is_none()); + } +} diff --git a/crates/agentkeys-core/src/clear_signing/catalog.rs b/crates/agentkeys-core/src/clear_signing/catalog.rs new file mode 100644 index 0000000..804df11 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/catalog.rs @@ -0,0 +1,145 @@ +//! ERC-7730 file catalog (issue #82). +//! +//! Holds a collection of ERC-7730 files keyed by their EIP-712 domain. The +//! catalog is the source of truth for "given this typed-data domain, how do +//! I render the message?". +//! +//! v0 sources: +//! - **Bundled**: files compiled into the binary under +//! `crates/agentkeys-core/src/clear_signing/fixtures/`. The minimum +//! shippable set ships in this PR (USDC permit). Add more as operators +//! need them; each is a single JSON file in the fixtures dir. +//! - **Filesystem**: load all `*.json` from a directory pointed at by +//! `$AGENTKEYS_7730_DIR` (per arch.md §22 pluggable surfaces). Lets +//! operators ship operator-custom 7730 files without recompiling. +//! +//! v1 (separate issue): fetch from the upstream +//! `ethereum/clear-signing-erc7730-registry` GitHub repo at daemon startup, +//! cached locally. + +use std::path::Path; + +use super::parser::{parse, Erc7730Error, Erc7730File}; + +/// One bundled USDC permit ERC-7730 file. New bundled files are added here +/// alongside their JSON; the JSON is the source of truth, this array is +/// just the compile-time include. +const BUNDLED_FILES: &[(&str, &str)] = &[( + "erc20-permit-usdc.json", + include_str!("fixtures/erc20-permit-usdc.json"), +)]; + +/// Catalog of ERC-7730 files. Cheap to clone (each file's `Erc7730File` is +/// already heap-allocated; the catalog is `Vec`). +#[derive(Debug, Clone, Default)] +pub struct ClearSigningCatalog { + files: Vec, +} + +impl ClearSigningCatalog { + /// Empty catalog — preview will fail to bind any typed data. + pub fn empty() -> Self { + Self { files: Vec::new() } + } + + /// Bundled set — the canonical v0 default. + pub fn bundled() -> Self { + let mut catalog = Self::empty(); + for (name, json) in BUNDLED_FILES { + match parse(json) { + Ok(file) => catalog.files.push(file), + Err(e) => { + eprintln!("agentkeys clear_signing: bundled file {name} failed to parse: {e}"); + } + } + } + catalog + } + + /// Bundled + every `*.json` file under `dir`. Errors loading individual + /// files surface as `Err`; the caller decides whether to ignore. + pub fn bundled_plus_dir(dir: impl AsRef) -> Result { + let mut catalog = Self::bundled(); + catalog.extend_from_dir(dir)?; + Ok(catalog) + } + + /// Add one parsed ERC-7730 file to the catalog. + pub fn push(&mut self, file: Erc7730File) { + self.files.push(file); + } + + /// Load all `*.json` under `dir` and append them. + pub fn extend_from_dir(&mut self, dir: impl AsRef) -> Result<(), Erc7730Error> { + let dir = dir.as_ref(); + let read_dir = std::fs::read_dir(dir).map_err(|e| { + Erc7730Error::Malformed(format!("cannot read 7730 dir {}: {e}", dir.display())) + })?; + for entry in read_dir { + let entry = entry + .map_err(|e| Erc7730Error::Malformed(format!("dir entry error: {e}")))?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let content = std::fs::read_to_string(&path).map_err(|e| { + Erc7730Error::Malformed(format!("read {}: {e}", path.display())) + })?; + self.files.push(parse(&content)?); + } + Ok(()) + } + + /// Iterate the catalog's files — used by binding for domain lookup. + pub fn iter(&self) -> impl Iterator { + self.files.iter() + } + + pub fn len(&self) -> usize { + self.files.len() + } + + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_catalog_loads_usdc_permit() { + let catalog = ClearSigningCatalog::bundled(); + assert!(!catalog.is_empty(), "bundled catalog must contain ≥ 1 file"); + let has_usdc = catalog.iter().any(|f| { + f.context + .eip712 + .as_ref() + .and_then(|e| e.domain.name.as_deref()) + .map(|n| n == "USD Coin") + .unwrap_or(false) + }); + assert!(has_usdc, "bundled catalog must include USDC permit"); + } + + #[test] + fn extend_from_dir_loads_json_files() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("custom.json"); + std::fs::write( + &path, + r#"{ + "context": { "eip712": { "domain": { + "name": "Custom", "version": "1", "chainId": 1 + } } }, + "metadata": {}, + "display": { "formats": {} } + }"#, + ) + .unwrap(); + let mut catalog = ClearSigningCatalog::empty(); + catalog.extend_from_dir(tmp.path()).unwrap(); + assert_eq!(catalog.len(), 1); + } +} diff --git a/crates/agentkeys-core/src/clear_signing/eip712.rs b/crates/agentkeys-core/src/clear_signing/eip712.rs new file mode 100644 index 0000000..509804e --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/eip712.rs @@ -0,0 +1,832 @@ +//! EIP-712 typed-data hashing (issue #82). +//! +//! Implements the v4 EIP-712 encoding rules: +//! +//! - `digest = keccak256(0x1901 || domain_separator || hashStruct(primary_type, message))` +//! - `domain_separator = hashStruct("EIP712Domain", domain)` +//! - `hashStruct(type, value) = keccak256(typeHash(type) || encodeData(type, value))` +//! - `typeHash(type) = keccak256(encodeType(type))` +//! - `encodeType` = `"()" || dependencies sorted alphabetically by type name` +//! +//! See for the canonical spec. +//! +//! ## Supported type-string subset (v0) +//! +//! - `string`, `bytes`, `bool`, `address` +//! - All `uint{8,16,...,256}` (8-bit increments) +//! - All `int{8,16,...,256}` (8-bit increments) +//! - All `bytes{1,2,...,32}` (fixed-byte) +//! - Dynamic arrays `T[]` and fixed arrays `T[N]` of any of the above (including structs) +//! - Nested struct types defined in `types` +//! +//! Anything outside this subset raises `Eip712Error::UnsupportedType`. The +//! signer MUST refuse to sign a typed-data value with an unsupported type +//! rather than silently produce a hash the operator did not understand. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Eip712Error { + #[error("invalid_typed_data: missing field {0}")] + MissingField(&'static str), + + #[error("invalid_typed_data: types must contain EIP712Domain")] + MissingDomainType, + + #[error("invalid_typed_data: primaryType '{0}' not declared in types")] + UnknownPrimaryType(String), + + #[error("invalid_typed_data: type '{0}' referenced but not declared in types")] + UnknownType(String), + + #[error("invalid_typed_data: unsupported type-string '{0}' (issue #82 v0 subset)")] + UnsupportedType(String), + + #[error("invalid_typed_data: field '{field}' expects {expected}, got {got}")] + FieldTypeMismatch { + field: String, + expected: String, + got: String, + }, + + #[error("invalid_typed_data: integer '{0}' out of range for type {1}")] + IntegerOutOfRange(String, String), + + #[error("invalid_typed_data: invalid hex in field '{field}': {reason}")] + InvalidHex { field: String, reason: String }, + + #[error("invalid_typed_data: array '{field}' length {got} does not match fixed size {expected}")] + ArrayLengthMismatch { + field: String, + expected: usize, + got: usize, + }, + + #[error("invalid_typed_data: cyclic type dependency through '{0}'")] + CyclicType(String), +} + +/// Field declaration inside a type definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TypeField { + pub name: String, + #[serde(rename = "type")] + pub ty: String, +} + +/// Full EIP-712 v4 typed-data payload. Matches the canonical JSON shape +/// (`MetaMask eth_signTypedData_v4`, `viem.signTypedData`, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypedData { + pub domain: serde_json::Value, + pub types: BTreeMap>, + #[serde(rename = "primaryType")] + pub primary_type: String, + pub message: serde_json::Value, +} + +/// Computed digests returned alongside the signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Eip712Digests { + pub domain_separator: [u8; 32], + pub primary_type_hash: [u8; 32], + pub message_hash: [u8; 32], + pub final_digest: [u8; 32], +} + +/// Compute every digest needed to sign + audit a typed-data value. +pub fn compute_digests(td: &TypedData) -> Result { + if !td.types.contains_key("EIP712Domain") { + return Err(Eip712Error::MissingDomainType); + } + if !td.types.contains_key(&td.primary_type) { + return Err(Eip712Error::UnknownPrimaryType(td.primary_type.clone())); + } + + let domain_separator = hash_struct(&td.types, "EIP712Domain", &td.domain)?; + let primary_type_hash = type_hash(&td.types, &td.primary_type)?; + let message_hash = hash_struct(&td.types, &td.primary_type, &td.message)?; + + let mut hasher = Keccak256::new(); + hasher.update([0x19, 0x01]); + hasher.update(domain_separator); + hasher.update(message_hash); + let final_digest: [u8; 32] = hasher.finalize().into(); + + Ok(Eip712Digests { + domain_separator, + primary_type_hash, + message_hash, + final_digest, + }) +} + +/// `typeHash(type)` = `keccak256(encodeType(type))`. +pub fn type_hash( + types: &BTreeMap>, + type_name: &str, +) -> Result<[u8; 32], Eip712Error> { + let encoded = encode_type(types, type_name)?; + Ok(keccak(encoded.as_bytes())) +} + +/// `encodeType("Mail")` → +/// `"Mail(Person from,Person to,string contents)Person(string name,address wallet)"`. +/// +/// Dependencies are listed in alphabetical order by struct name. The primary +/// type itself comes first regardless of alphabetical order. +pub fn encode_type( + types: &BTreeMap>, + primary: &str, +) -> Result { + let mut deps = BTreeSet::new(); + collect_dependencies(types, primary, &mut deps, &mut BTreeSet::new())?; + deps.remove(primary); + + let mut out = String::new(); + out.push_str(&encode_one_type(types, primary)?); + for dep in &deps { + out.push_str(&encode_one_type(types, dep)?); + } + Ok(out) +} + +fn encode_one_type( + types: &BTreeMap>, + name: &str, +) -> Result { + let fields = types + .get(name) + .ok_or_else(|| Eip712Error::UnknownType(name.to_string()))?; + let mut out = String::from(name); + out.push('('); + let body = fields + .iter() + .map(|f| format!("{} {}", f.ty, f.name)) + .collect::>() + .join(","); + out.push_str(&body); + out.push(')'); + Ok(out) +} + +fn collect_dependencies( + types: &BTreeMap>, + name: &str, + out: &mut BTreeSet, + visiting: &mut BTreeSet, +) -> Result<(), Eip712Error> { + if visiting.contains(name) { + return Err(Eip712Error::CyclicType(name.to_string())); + } + if out.contains(name) { + return Ok(()); + } + visiting.insert(name.to_string()); + let fields = types + .get(name) + .ok_or_else(|| Eip712Error::UnknownType(name.to_string()))?; + for f in fields { + let base = strip_array_suffix(&f.ty); + if types.contains_key(base) { + collect_dependencies(types, base, out, visiting)?; + } + } + visiting.remove(name); + out.insert(name.to_string()); + Ok(()) +} + +/// Strip the outermost `[N]` or `[]` suffix from a type string. `"uint256[2][]"` +/// → `"uint256[2]"`, `"Person[]"` → `"Person"`, `"uint256"` → `"uint256"`. +fn strip_array_suffix(ty: &str) -> &str { + if let Some(stripped) = ty.strip_suffix(']') { + if let Some(bracket_open) = stripped.rfind('[') { + return &ty[..bracket_open]; + } + } + ty +} + +/// `hashStruct(type, value) = keccak256(typeHash(type) || encodeData(type, value))`. +pub fn hash_struct( + types: &BTreeMap>, + type_name: &str, + value: &serde_json::Value, +) -> Result<[u8; 32], Eip712Error> { + let th = type_hash(types, type_name)?; + let obj = value.as_object().ok_or_else(|| Eip712Error::FieldTypeMismatch { + field: type_name.to_string(), + expected: "object".to_string(), + got: value_kind(value), + })?; + let fields = types + .get(type_name) + .ok_or_else(|| Eip712Error::UnknownType(type_name.to_string()))?; + + let mut buf = Vec::with_capacity(32 * (1 + fields.len())); + buf.extend_from_slice(&th); + for field in fields { + // EIP-712 v4 + viem permit absent EIP712Domain fields: if a field is + // declared in the type but missing from the object, treat as the + // zero value (matches viem's behavior on optional domain fields). + let raw = obj.get(&field.name).unwrap_or(&serde_json::Value::Null); + let encoded = encode_data_for_field(types, &field.ty, raw, &field.name)?; + buf.extend_from_slice(&encoded); + } + Ok(keccak(&buf)) +} + +fn encode_data_for_field( + types: &BTreeMap>, + ty: &str, + value: &serde_json::Value, + field_name: &str, +) -> Result<[u8; 32], Eip712Error> { + // Arrays: keccak256(concat(encode_data_for_field(inner, x) for x in arr)). + if let Some(inner_ty) = parse_array_outer(ty) { + let arr = value.as_array().ok_or_else(|| Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: ty.to_string(), + got: value_kind(value), + })?; + if let ArrayKind::Fixed(n) = inner_ty.kind { + if arr.len() != n { + return Err(Eip712Error::ArrayLengthMismatch { + field: field_name.to_string(), + expected: n, + got: arr.len(), + }); + } + } + let mut concat = Vec::with_capacity(arr.len() * 32); + for (i, item) in arr.iter().enumerate() { + let sub_field = format!("{field_name}[{i}]"); + let h = encode_data_for_field(types, inner_ty.element_ty, item, &sub_field)?; + concat.extend_from_slice(&h); + } + return Ok(keccak(&concat)); + } + + // Struct: hashStruct. + if types.contains_key(ty) { + return hash_struct(types, ty, value); + } + + // Primitives. + match ty { + "bytes" => { + let bytes = parse_hex_field(value, field_name)?; + Ok(keccak(&bytes)) + } + "string" => { + let s = value.as_str().ok_or_else(|| Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: "string".to_string(), + got: value_kind(value), + })?; + Ok(keccak(s.as_bytes())) + } + "bool" => { + let b = value.as_bool().ok_or_else(|| Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: "bool".to_string(), + got: value_kind(value), + })?; + let mut buf = [0u8; 32]; + if b { + buf[31] = 1; + } + Ok(buf) + } + "address" => { + let bytes = parse_hex_field(value, field_name)?; + if bytes.len() != 20 { + return Err(Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: "address (20 bytes)".to_string(), + got: format!("{} bytes", bytes.len()), + }); + } + let mut buf = [0u8; 32]; + buf[12..].copy_from_slice(&bytes); + Ok(buf) + } + _ if ty.starts_with("uint") => { + let bits = parse_int_bits(&ty[4..]) + .ok_or_else(|| Eip712Error::UnsupportedType(ty.to_string()))?; + encode_uint(value, field_name, ty, bits) + } + _ if ty.starts_with("int") => { + let bits = parse_int_bits(&ty[3..]) + .ok_or_else(|| Eip712Error::UnsupportedType(ty.to_string()))?; + encode_int(value, field_name, ty, bits) + } + _ if ty.starts_with("bytes") => { + let n = ty[5..] + .parse::() + .map_err(|_| Eip712Error::UnsupportedType(ty.to_string()))?; + if n == 0 || n > 32 { + return Err(Eip712Error::UnsupportedType(ty.to_string())); + } + let bytes = parse_hex_field(value, field_name)?; + if bytes.len() != n { + return Err(Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: format!("bytes{n}"), + got: format!("{} bytes", bytes.len()), + }); + } + let mut buf = [0u8; 32]; + buf[..n].copy_from_slice(&bytes); + Ok(buf) + } + _ => Err(Eip712Error::UnsupportedType(ty.to_string())), + } +} + +fn parse_int_bits(suffix: &str) -> Option { + if suffix.is_empty() { + return Some(256); + } + let n: u32 = suffix.parse().ok()?; + if n == 0 || n > 256 || n % 8 != 0 { + return None; + } + Some(n) +} + +enum ArrayKind { + Dynamic, + Fixed(usize), +} + +struct ArrayParse<'a> { + element_ty: &'a str, + kind: ArrayKind, +} + +/// If `ty` ends in `[...]`, return the inner type and the kind. Returns +/// `None` for non-arrays (so the caller can fall through to primitive / +/// struct handling). +fn parse_array_outer(ty: &str) -> Option> { + let stripped = ty.strip_suffix(']')?; + let bracket_open = stripped.rfind('[')?; + let inside = &ty[bracket_open + 1..ty.len() - 1]; + let kind = if inside.is_empty() { + ArrayKind::Dynamic + } else { + ArrayKind::Fixed(inside.parse().ok()?) + }; + Some(ArrayParse { + element_ty: &ty[..bracket_open], + kind, + }) +} + +fn encode_uint( + value: &serde_json::Value, + field_name: &str, + ty: &str, + bits: u32, +) -> Result<[u8; 32], Eip712Error> { + let s = number_or_string(value, field_name, ty)?; + let big = parse_uint_string(&s).ok_or_else(|| { + Eip712Error::IntegerOutOfRange(s.clone(), ty.to_string()) + })?; + if bits < 256 { + let max = U256::ONE.shl(bits as usize); + if big >= max { + return Err(Eip712Error::IntegerOutOfRange(s, ty.to_string())); + } + } + Ok(big.to_be_bytes()) +} + +fn encode_int( + value: &serde_json::Value, + field_name: &str, + ty: &str, + bits: u32, +) -> Result<[u8; 32], Eip712Error> { + let s = number_or_string(value, field_name, ty)?; + let (neg, magnitude) = match s.strip_prefix('-') { + Some(rest) => (true, rest.to_string()), + None => (false, s.clone()), + }; + let mag = parse_uint_string(&magnitude).ok_or_else(|| { + Eip712Error::IntegerOutOfRange(s.clone(), ty.to_string()) + })?; + // Range check: for intN, magnitude must fit in (N-1) bits when positive, + // and ≤ 2^(N-1) when negative. + if bits < 256 { + let pos_max = U256::ONE.shl((bits - 1) as usize); + if neg { + if mag > pos_max { + return Err(Eip712Error::IntegerOutOfRange(s, ty.to_string())); + } + } else if mag >= pos_max { + return Err(Eip712Error::IntegerOutOfRange(s, ty.to_string())); + } + } + let encoded = if neg { mag.neg_twos_complement() } else { mag }; + Ok(encoded.to_be_bytes()) +} + +fn number_or_string( + value: &serde_json::Value, + field_name: &str, + ty: &str, +) -> Result { + if let Some(s) = value.as_str() { + return Ok(s.to_string()); + } + if let Some(n) = value.as_u64() { + return Ok(n.to_string()); + } + if let Some(n) = value.as_i64() { + return Ok(n.to_string()); + } + Err(Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: ty.to_string(), + got: value_kind(value), + }) +} + +fn parse_uint_string(s: &str) -> Option { + let s = s.trim(); + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + return U256::from_hex(hex); + } + U256::from_dec(s) +} + +fn parse_hex_field(value: &serde_json::Value, field_name: &str) -> Result, Eip712Error> { + let s = value.as_str().ok_or_else(|| Eip712Error::FieldTypeMismatch { + field: field_name.to_string(), + expected: "0x-prefixed hex string".to_string(), + got: value_kind(value), + })?; + let stripped = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + hex::decode(stripped).map_err(|e| Eip712Error::InvalidHex { + field: field_name.to_string(), + reason: e.to_string(), + }) +} + +fn value_kind(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } + .to_string() +} + +fn keccak(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +// ============================================================================ +// U256 — minimal big-integer needed for EIP-712 encoding. +// +// We carry exactly 256 bits as four big-endian-ordered `u64` limbs. The +// supported ops are: parse-from-decimal, parse-from-hex, compare, shift-left +// by a fixed bit count, and two's-complement negation. That's the entire +// surface EIP-712 encoding needs. Pulling in `primitive-types` / `ethnum` +// would bloat the dep tree for no functional gain. +// ============================================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct U256 { + limbs: [u64; 4], // limbs[0] = most-significant +} + +impl U256 { + const ZERO: Self = Self { limbs: [0; 4] }; + const ONE: Self = Self { limbs: [0, 0, 0, 1] }; + + fn from_dec(s: &str) -> Option { + if s.is_empty() { + return None; + } + let mut out = Self::ZERO; + for c in s.chars() { + let d = c.to_digit(10)?; + out = out.mul_small(10)?; + out = out.add_small(d as u64)?; + } + Some(out) + } + + fn from_hex(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || s.len() > 64 { + return None; + } + let mut padded = String::with_capacity(64); + for _ in 0..(64 - s.len()) { + padded.push('0'); + } + padded.push_str(s); + let bytes = hex::decode(&padded).ok()?; + let mut limbs = [0u64; 4]; + for (i, chunk) in bytes.chunks(8).enumerate() { + let mut buf = [0u8; 8]; + buf.copy_from_slice(chunk); + limbs[i] = u64::from_be_bytes(buf); + } + Some(Self { limbs }) + } + + fn mul_small(self, factor: u64) -> Option { + let mut out = [0u64; 4]; + let mut carry: u128 = 0; + for i in (0..4).rev() { + let v = self.limbs[i] as u128 * factor as u128 + carry; + out[i] = v as u64; + carry = v >> 64; + } + if carry != 0 { + return None; + } + Some(Self { limbs: out }) + } + + fn add_small(self, addend: u64) -> Option { + let mut out = self.limbs; + let mut carry = addend as u128; + for i in (0..4).rev() { + let v = out[i] as u128 + carry; + out[i] = v as u64; + carry = v >> 64; + if carry == 0 { + break; + } + } + if carry != 0 { + return None; + } + Some(Self { limbs: out }) + } + + /// Left-shift by `bits`. Caller MUST ensure `bits <= 256`. Bits shifted + /// out of the top limb are dropped silently — callers only use this with + /// `Self::ONE` to compute `2^bits`, so overflow is impossible in practice. + fn shl(self, bits: usize) -> Self { + if bits == 0 { + return self; + } + if bits >= 256 { + return Self::ZERO; + } + let limb_shift = bits / 64; + let bit_shift = bits % 64; + let mut out = [0u64; 4]; + for i in 0..4 { + let src = i + limb_shift; + if src >= 4 { + break; + } + let hi = self.limbs[3 - src] << bit_shift; + let lo = if bit_shift == 0 || src + 1 >= 4 { + 0 + } else { + self.limbs[3 - (src + 1)] >> (64 - bit_shift) + }; + out[3 - i] = hi | lo; + } + Self { limbs: out } + } + + /// Two's-complement negation as a full-256-bit value: `(~self).wrapping_add(1)`. + fn neg_twos_complement(self) -> Self { + let mut out = [0u64; 4]; + for i in 0..4 { + out[i] = !self.limbs[i]; + } + // wrapping_add 1 + let mut carry = 1u128; + for i in (0..4).rev() { + let v = out[i] as u128 + carry; + out[i] = v as u64; + carry = v >> 64; + if carry == 0 { + break; + } + } + Self { limbs: out } + } + + fn to_be_bytes(self) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..4 { + out[i * 8..(i + 1) * 8].copy_from_slice(&self.limbs[i].to_be_bytes()); + } + out + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn types_mail() -> BTreeMap> { + let mut t = BTreeMap::new(); + t.insert( + "EIP712Domain".to_string(), + vec![ + TypeField { name: "name".into(), ty: "string".into() }, + TypeField { name: "version".into(), ty: "string".into() }, + TypeField { name: "chainId".into(), ty: "uint256".into() }, + TypeField { + name: "verifyingContract".into(), + ty: "address".into(), + }, + ], + ); + t.insert( + "Person".to_string(), + vec![ + TypeField { name: "name".into(), ty: "string".into() }, + TypeField { name: "wallet".into(), ty: "address".into() }, + ], + ); + t.insert( + "Mail".to_string(), + vec![ + TypeField { name: "from".into(), ty: "Person".into() }, + TypeField { name: "to".into(), ty: "Person".into() }, + TypeField { name: "contents".into(), ty: "string".into() }, + ], + ); + t + } + + /// Reference vector from § + /// "Specification of the eth_signTypedData_v4 JSON RPC". + #[test] + fn eip712_spec_example_matches_known_digest() { + let types = types_mail(); + let td = TypedData { + types, + primary_type: "Mail".into(), + domain: json!({ + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }), + message: json!({ + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + "contents": "Hello, Bob!", + }), + }; + let d = compute_digests(&td).unwrap(); + // Known reference: from the EIP-712 spec text and viem/ethers cross-verified. + assert_eq!( + hex::encode(d.final_digest), + "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2", + ); + assert_eq!( + hex::encode(d.domain_separator), + "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f", + ); + assert_eq!( + hex::encode(d.message_hash), + "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e", + ); + } + + #[test] + fn encode_type_orders_deps_alphabetically_with_primary_first() { + let types = types_mail(); + let encoded = encode_type(&types, "Mail").unwrap(); + assert_eq!( + encoded, + "Mail(Person from,Person to,string contents)Person(string name,address wallet)" + ); + } + + #[test] + fn cyclic_type_raises_error() { + let mut t = BTreeMap::new(); + t.insert( + "EIP712Domain".to_string(), + vec![TypeField { name: "x".into(), ty: "uint256".into() }], + ); + t.insert( + "A".to_string(), + vec![TypeField { name: "b".into(), ty: "B".into() }], + ); + t.insert( + "B".to_string(), + vec![TypeField { name: "a".into(), ty: "A".into() }], + ); + assert!(matches!(encode_type(&t, "A"), Err(Eip712Error::CyclicType(_)))); + } + + #[test] + fn uint256_accepts_decimal_and_hex_strings() { + let v = json!("1000000000000000000"); + let r = encode_data_for_field(&BTreeMap::new(), "uint256", &v, "amount").unwrap(); + assert_eq!(hex::encode(r), "0000000000000000000000000000000000000000000000000de0b6b3a7640000"); + + let v = json!("0xde0b6b3a7640000"); + let r2 = encode_data_for_field(&BTreeMap::new(), "uint256", &v, "amount").unwrap(); + assert_eq!(r, r2); + } + + #[test] + fn uint8_rejects_over_255() { + let v = json!(256); + let err = encode_data_for_field(&BTreeMap::new(), "uint8", &v, "x").unwrap_err(); + assert!(matches!(err, Eip712Error::IntegerOutOfRange(_, _))); + } + + #[test] + fn int8_negative_encodes_as_twos_complement() { + let v = json!("-1"); + let r = encode_data_for_field(&BTreeMap::new(), "int8", &v, "x").unwrap(); + // -1 sign-extended to 256 bits is 0xff...ff. + assert_eq!(hex::encode(r), "f".repeat(64)); + } + + #[test] + fn bool_encodes_as_zero_padded_one() { + let v = json!(true); + let r = encode_data_for_field(&BTreeMap::new(), "bool", &v, "x").unwrap(); + assert_eq!(hex::encode(r), format!("{}{}", "0".repeat(62), "01")); + } + + #[test] + fn dynamic_array_encodes_keccak_of_concat() { + let v = json!(["1", "2", "3"]); + let r = encode_data_for_field(&BTreeMap::new(), "uint256[]", &v, "arr").unwrap(); + // keccak256( uint256(1) || uint256(2) || uint256(3) ) + let mut buf = [0u8; 96]; + buf[31] = 1; + buf[63] = 2; + buf[95] = 3; + let expected = keccak(&buf); + assert_eq!(r, expected); + } + + #[test] + fn fixed_array_length_mismatch_errors() { + let v = json!([1, 2]); + let err = encode_data_for_field(&BTreeMap::new(), "uint256[3]", &v, "arr").unwrap_err(); + assert!(matches!(err, Eip712Error::ArrayLengthMismatch { .. })); + } + + #[test] + fn unsupported_type_string_errors() { + let v = json!("0xabcd"); + let err = encode_data_for_field(&BTreeMap::new(), "uintfoo", &v, "x").unwrap_err(); + assert!(matches!(err, Eip712Error::UnsupportedType(_))); + } + + #[test] + fn strip_array_suffix_handles_nested() { + assert_eq!(strip_array_suffix("uint256[]"), "uint256"); + assert_eq!(strip_array_suffix("uint256[3]"), "uint256"); + assert_eq!(strip_array_suffix("uint256[2][]"), "uint256[2]"); + assert_eq!(strip_array_suffix("Person"), "Person"); + } + + #[test] + fn u256_dec_then_hex_roundtrip() { + let a = U256::from_dec("18446744073709551616").unwrap(); // 2^64 + let b = U256::from_hex("10000000000000000").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn u256_neg_one_is_all_f() { + let one = U256::ONE; + let neg = one.neg_twos_complement(); + assert_eq!(hex::encode(neg.to_be_bytes()), "f".repeat(64)); + } +} diff --git a/crates/agentkeys-core/src/clear_signing/fixtures/erc20-permit-usdc.json b/crates/agentkeys-core/src/clear_signing/fixtures/erc20-permit-usdc.json new file mode 100644 index 0000000..68e1a61 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/fixtures/erc20-permit-usdc.json @@ -0,0 +1,34 @@ +{ + "context": { + "eip712": { + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + } + } + }, + "metadata": { + "owner": "Circle", + "info": { + "legalName": "Circle Internet Financial, Inc.", + "url": "https://www.circle.com", + "lastUpdate": "2026-05-21" + } + }, + "display": { + "formats": { + "Permit": { + "intent": "Approve {value} to spender {spender}", + "fields": [ + { "path": "owner", "label": "Owner", "format": "address", "params": { "truncate": true } }, + { "path": "spender", "label": "Spender", "format": "address", "params": { "truncate": true } }, + { "path": "value", "label": "Amount", "format": "tokenAmount", "params": { "decimals": 6, "ticker": "USDC" } }, + { "path": "nonce", "label": "Nonce", "format": "integer" }, + { "path": "deadline", "label": "Deadline", "format": "date" } + ] + } + } + } +} diff --git a/crates/agentkeys-core/src/clear_signing/format.rs b/crates/agentkeys-core/src/clear_signing/format.rs new file mode 100644 index 0000000..c8a0a77 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/format.rs @@ -0,0 +1,332 @@ +//! Per-field formatters + intent interpolator (issue #82). +//! +//! Maps ERC-7730 `display.formats[…].fields[].format` strings to operator- +//! readable text. Implements the v0 subset: +//! +//! - `tokenAmount`: `1000000` with `{decimals: 6, ticker: "USDC"}` → `"1.00 USDC"` +//! - `address`: `0xabc...123` → `"0xabc…123"` (truncated for display) or full hex +//! - `integer`: raw integer rendered with thousands separators +//! - `date`: UNIX seconds → ISO-8601 UTC +//! - `bool`: `true`/`false` → `"true"`/`"false"` +//! - `raw` / unknown: hex-encoded bytes / stringified value +//! +//! Intent interpolation: `"Approve {value} to {spender}"` → +//! `"Approve 1.00 USDC to 0xabc…123"` by looking up `{name}` against the +//! field path map. + +use std::collections::BTreeMap; + +use super::parser::{Erc7730Field, Erc7730Format}; + +/// Map of field path → rendered value, built from the message + ERC-7730 +/// formats. Indexed by the path AND by the leaf name (the trailing segment), +/// so an intent string `{value}` resolves whether the path is `value` or +/// `permit.value`. +pub struct RenderedFields { + by_path: BTreeMap, + by_leaf: BTreeMap, +} + +impl RenderedFields { + pub fn render( + message: &serde_json::Value, + format: &Erc7730Format, + ) -> Self { + let mut by_path = BTreeMap::new(); + let mut by_leaf = BTreeMap::new(); + for field in &format.fields { + let raw = lookup_path(message, &field.path); + let rendered = render_field(field, raw); + by_path.insert(field.path.clone(), rendered.clone()); + if let Some(leaf) = field.path.rsplit('.').next() { + by_leaf.insert(leaf.to_string(), rendered); + } + } + Self { by_path, by_leaf } + } + + pub fn lookup(&self, key: &str) -> Option<&str> { + self.by_path + .get(key) + .or_else(|| self.by_leaf.get(key)) + .map(String::as_str) + } + + /// Iterate (label, rendered) pairs in the order they appear in + /// `format.fields`. The label falls back to the path when not set. + pub fn iter_pairs<'a>( + &'a self, + format: &'a Erc7730Format, + ) -> impl Iterator { + format.fields.iter().map(|f| { + let label = f.label.as_deref().unwrap_or(&f.path); + let rendered = self + .by_path + .get(&f.path) + .map(String::as_str) + .unwrap_or("?"); + (label, rendered) + }) + } +} + +/// Interpolate `"Approve {value} to {spender}"` against a rendered field map. +/// Unknown `{name}` references are left in place so the operator can see +/// when a 7730 file references a field the typed data doesn't carry. +pub fn interpolate_intent(template: &str, fields: &RenderedFields) -> String { + let mut out = String::with_capacity(template.len() + 64); + let mut rest = template; + while let Some(start) = rest.find('{') { + out.push_str(&rest[..start]); + rest = &rest[start..]; + if let Some(end) = rest.find('}') { + let name = &rest[1..end]; + match fields.lookup(name) { + Some(rendered) => out.push_str(rendered), + None => { + out.push('{'); + out.push_str(name); + out.push('}'); + } + } + rest = &rest[end + 1..]; + } else { + out.push_str(rest); + break; + } + } + out.push_str(rest); + out +} + +fn render_field(field: &Erc7730Field, raw: Option<&serde_json::Value>) -> String { + let raw = match raw { + Some(v) => v, + None => return "?".to_string(), + }; + match field.format.as_str() { + "tokenAmount" => render_token_amount(raw, &field.params), + "address" => render_address(raw, &field.params), + "integer" => render_integer(raw), + "date" => render_date(raw), + "bool" => render_bool(raw), + "raw" | _ => render_raw(raw), + } +} + +fn render_token_amount(raw: &serde_json::Value, params: &serde_json::Value) -> String { + let decimals = params + .get("decimals") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as usize; + let ticker = params + .get("ticker") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + + let raw_str = match raw { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + _ => return render_raw(raw), + }; + let n_str = raw_str.trim_start_matches('-'); + let neg = raw_str.starts_with('-'); + + let formatted = if decimals == 0 { + n_str.to_string() + } else if n_str.len() <= decimals { + let padded = format!("{:0>width$}", n_str, width = decimals + 1); + let split_at = padded.len() - decimals; + let (int_part, frac_part) = padded.split_at(split_at); + let frac_trimmed = frac_part.trim_end_matches('0'); + if frac_trimmed.is_empty() { + int_part.to_string() + } else { + format!("{int_part}.{frac_trimmed}") + } + } else { + let split_at = n_str.len() - decimals; + let (int_part, frac_part) = n_str.split_at(split_at); + let frac_trimmed = frac_part.trim_end_matches('0'); + if frac_trimmed.is_empty() { + int_part.to_string() + } else { + format!("{int_part}.{frac_trimmed}") + } + }; + + let with_sign = if neg { format!("-{formatted}") } else { formatted }; + if ticker.is_empty() { + with_sign + } else { + format!("{with_sign} {ticker}") + } +} + +fn render_address(raw: &serde_json::Value, params: &serde_json::Value) -> String { + let s = match raw.as_str() { + Some(s) => s.to_lowercase(), + None => return render_raw(raw), + }; + let truncate = params + .get("truncate") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if !truncate || s.len() < 12 { + return s; + } + format!("{}…{}", &s[..6], &s[s.len() - 4..]) +} + +fn render_integer(raw: &serde_json::Value) -> String { + match raw { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + _ => render_raw(raw), + } +} + +fn render_date(raw: &serde_json::Value) -> String { + let secs = match raw { + serde_json::Value::String(s) => s.parse::().ok(), + serde_json::Value::Number(n) => n.as_i64(), + _ => None, + }; + match secs { + Some(s) => format_unix_seconds_utc(s), + None => render_raw(raw), + } +} + +fn render_bool(raw: &serde_json::Value) -> String { + match raw { + serde_json::Value::Bool(b) => b.to_string(), + _ => render_raw(raw), + } +} + +fn render_raw(raw: &serde_json::Value) -> String { + match raw { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Format `secs` (Unix epoch seconds) as `YYYY-MM-DDTHH:MM:SSZ` without +/// pulling in a date crate. Algorithm: Howard Hinnant's civil-from-days +/// (see ). +fn format_unix_seconds_utc(secs: i64) -> String { + let days = secs.div_euclid(86_400); + let sod = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + let hh = sod / 3600; + let mm = (sod % 3600) / 60; + let ss = sod % 60; + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = (yoe as i64) + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (y + if m <= 2 { 1 } else { 0 }, m, d) +} + +fn lookup_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + let mut cur = value; + for segment in path.split('.') { + if let Ok(idx) = segment.parse::() { + cur = cur.as_array().and_then(|a| a.get(idx))?; + } else { + cur = cur.get(segment)?; + } + } + Some(cur) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn token_amount_renders_with_decimals_and_ticker() { + let r = render_token_amount(&json!("1000000"), &json!({"decimals": 6, "ticker": "USDC"})); + assert_eq!(r, "1 USDC"); + + let r = render_token_amount( + &json!("1234500000"), + &json!({"decimals": 6, "ticker": "USDC"}), + ); + assert_eq!(r, "1234.5 USDC"); + + let r = render_token_amount(&json!("500000"), &json!({"decimals": 6, "ticker": "USDC"})); + assert_eq!(r, "0.5 USDC"); + + let r = render_token_amount(&json!("0"), &json!({"decimals": 6, "ticker": "USDC"})); + assert_eq!(r, "0 USDC"); + } + + #[test] + fn address_truncates_by_default() { + let r = render_address( + &json!("0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"), + &json!({}), + ); + assert_eq!(r, "0xcccc…cccc"); + } + + #[test] + fn address_can_be_full() { + let r = render_address( + &json!("0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"), + &json!({"truncate": false}), + ); + assert_eq!(r, format!("0x{}", "c".repeat(40))); + } + + #[test] + fn interpolate_replaces_known_fields_leaves_unknown() { + let format = Erc7730Format { + intent: Some("Approve {value} to {spender}".into()), + fields: vec![ + Erc7730Field { + path: "value".into(), + label: None, + format: "tokenAmount".into(), + params: json!({"decimals": 6, "ticker": "USDC"}), + }, + Erc7730Field { + path: "spender".into(), + label: None, + format: "address".into(), + params: json!({"truncate": true}), + }, + ], + }; + let msg = json!({"value": "1000000", "spender": "0xaaaabbbbccccddddeeeeffff0000111122223333"}); + let rendered = RenderedFields::render(&msg, &format); + let s = interpolate_intent("Approve {value} to {spender} maybe {unknown}", &rendered); + assert_eq!(s, "Approve 1 USDC to 0xaaaa…3333 maybe {unknown}"); + } + + #[test] + fn date_renders_iso8601_utc() { + let r = render_date(&json!(1_700_000_000)); + // 2023-11-14T22:13:20 UTC. + assert_eq!(r, "2023-11-14T22:13:20Z"); + } + + #[test] + fn lookup_path_walks_nested() { + let v = json!({"permit": {"value": "42"}}); + assert_eq!(lookup_path(&v, "permit.value"), Some(&json!("42"))); + assert_eq!(lookup_path(&v, "permit.missing"), None); + } +} diff --git a/crates/agentkeys-core/src/clear_signing/mod.rs b/crates/agentkeys-core/src/clear_signing/mod.rs new file mode 100644 index 0000000..9ec7601 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/mod.rs @@ -0,0 +1,214 @@ +//! Clear-signing (ERC-7730 + EIP-712) — issue #82. +//! +//! Two responsibilities: +//! +//! 1. **EIP-712 typed-data hashing** ([`eip712`]). Implements the v4 encoding +//! rules so the signer can hash + sign a typed-data value, and so the +//! daemon / CLI can re-derive the same digest without contacting the +//! signer. +//! +//! 2. **ERC-7730 metadata** ([`parser`], [`format`], [`binding`], [`catalog`]). +//! Loads operator-readable display rules ("Approve USDC 1000 to +//! Uniswap router") for typed-data messages, so the operator can review +//! *what* an agent is about to authorize before approving. +//! +//! ## Public entry points +//! +//! - [`ClearSigningCatalog::bundled`] — load the compile-time-bundled v0 set. +//! - [`build_preview`] — given a catalog + typed data, compute the digest, +//! resolve the matching 7730 file, render the intent text, compute the +//! audit-row commitment hash. +//! +//! ## The intent-commitment property +//! +//! `signed_intent_hash = keccak256(intent_text || "|" || digest)` — the audit +//! row carries this hash, so later auditors verifying a sign event can +//! re-render the intent from the same 7730 file and check the commitment +//! matches. This closes the "agent-A signed `0xdead…beef`" failure mode +//! that arch.md §15.3 calls out. See [`docs/spec/architecture.md`]. + +pub mod binding; +pub mod catalog; +pub mod eip712; +pub mod format; +pub mod parser; + +use sha3::{Digest, Keccak256}; +use thiserror::Error; + +pub use catalog::ClearSigningCatalog; +pub use eip712::{compute_digests, Eip712Digests, Eip712Error, TypedData, TypeField}; +pub use format::{interpolate_intent, RenderedFields}; +pub use parser::{Erc7730Error, Erc7730File}; + +#[derive(Debug, Error)] +pub enum ClearSigningError { + #[error("eip712: {0}")] + Eip712(#[from] Eip712Error), + + #[error("7730: {0}")] + Erc7730(#[from] Erc7730Error), + + #[error("no_7730_file_for_domain: typed-data domain does not match any 7730 file in catalog")] + NoMatch, + + #[error("no_format_for_primary_type: matched 7730 file does not define format for primary type '{0}'")] + NoFormatForPrimaryType(String), + + #[error("no_intent: matched 7730 format does not define an intent string")] + NoIntent, +} + +/// What [`build_preview`] returns: the rendered intent text, the matched +/// 7730 file, the EIP-712 digests, and the intent-commitment hash that the +/// audit row should carry. +#[derive(Debug, Clone)] +pub struct ClearSigningPreview { + pub typed_data: TypedData, + pub digests: Eip712Digests, + /// Operator-readable text. Example: + /// `"Approve 1000.5 USDC to spender 0xabcd…1234"`. + pub intent_text: String, + /// `keccak256(intent_text || "|" || digest)` — the cryptographic + /// commitment that the audit row stores alongside the signature, so a + /// later auditor can verify the rendered intent the operator saw. + pub intent_commitment: [u8; 32], + /// Per-field rendered (label, value) pairs in the order the 7730 file + /// declares them. Used by the CLI to print a field-by-field review. + pub fields: Vec<(String, String)>, +} + +/// Build a preview for `typed_data` against `catalog`. The preview is the +/// rendered intent plus the digests the signer would produce; it does NOT +/// itself produce a signature. +pub fn build_preview( + catalog: &ClearSigningCatalog, + typed_data: TypedData, +) -> Result { + let digests = compute_digests(&typed_data)?; + let file = binding::match_file(catalog.iter(), &typed_data) + .ok_or(ClearSigningError::NoMatch)?; + let format = file + .display + .formats + .get(&typed_data.primary_type) + .ok_or_else(|| ClearSigningError::NoFormatForPrimaryType(typed_data.primary_type.clone()))?; + let intent_template = format + .intent + .as_deref() + .ok_or(ClearSigningError::NoIntent)?; + + let rendered = RenderedFields::render(&typed_data.message, format); + let intent_text = interpolate_intent(intent_template, &rendered); + let intent_commitment = commit_intent(&intent_text, &digests.final_digest); + let fields = rendered + .iter_pairs(format) + .map(|(l, v)| (l.to_string(), v.to_string())) + .collect(); + + Ok(ClearSigningPreview { + typed_data, + digests, + intent_text, + intent_commitment, + fields, + }) +} + +/// `keccak256(intent_text.as_bytes() || 0x7c || final_digest)`. The +/// separator byte (`0x7c` = ASCII `|`) is a domain-separation token so an +/// adversary cannot construct an `intent_text` whose last byte fakes the +/// digest boundary. +pub fn commit_intent(intent_text: &str, final_digest: &[u8; 32]) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(intent_text.as_bytes()); + hasher.update([0x7c]); + hasher.update(final_digest); + hasher.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::BTreeMap; + + fn usdc_permit_typed_data() -> TypedData { + let mut types: BTreeMap> = BTreeMap::new(); + types.insert( + "EIP712Domain".into(), + vec![ + TypeField { name: "name".into(), ty: "string".into() }, + TypeField { name: "version".into(), ty: "string".into() }, + TypeField { name: "chainId".into(), ty: "uint256".into() }, + TypeField { + name: "verifyingContract".into(), + ty: "address".into(), + }, + ], + ); + types.insert( + "Permit".into(), + vec![ + TypeField { name: "owner".into(), ty: "address".into() }, + TypeField { name: "spender".into(), ty: "address".into() }, + TypeField { name: "value".into(), ty: "uint256".into() }, + TypeField { name: "nonce".into(), ty: "uint256".into() }, + TypeField { name: "deadline".into(), ty: "uint256".into() }, + ], + ); + TypedData { + types, + primary_type: "Permit".into(), + domain: json!({ + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }), + message: json!({ + "owner": "0x1111111111111111111111111111111111111111", + "spender": "0xaaaabbbbccccddddeeeeffff0000111122223333", + "value": "1500000", + "nonce": "0", + "deadline": "1900000000", + }), + } + } + + #[test] + fn build_preview_against_bundled_renders_usdc_intent() { + let catalog = ClearSigningCatalog::bundled(); + let td = usdc_permit_typed_data(); + let p = build_preview(&catalog, td).unwrap(); + assert_eq!(p.intent_text, "Approve 1.5 USDC to spender 0xaaaa…3333"); + // intent_commitment is deterministic for the same intent + digest: + let again = commit_intent(&p.intent_text, &p.digests.final_digest); + assert_eq!(p.intent_commitment, again); + // Fields list carries the per-field rendering for CLI review: + assert!(p + .fields + .iter() + .any(|(l, v)| l == "Amount" && v == "1.5 USDC")); + } + + #[test] + fn build_preview_fails_when_no_7730_matches() { + let catalog = ClearSigningCatalog::empty(); + let td = usdc_permit_typed_data(); + let err = build_preview(&catalog, td).unwrap_err(); + assert!(matches!(err, ClearSigningError::NoMatch)); + } + + #[test] + fn commit_intent_is_collision_resistant_across_separator() { + // "foo|bar" hashed differently from intent="foo|" + digest=[b'b','a','r',...] + // because we use a non-printable separator + 32-byte digest with explicit length. + let digest = [0u8; 32]; + let a = commit_intent("foo", &digest); + let mut b_digest = [0u8; 32]; + b_digest[..3].copy_from_slice(b"bar"); + let b = commit_intent("foo|", &b_digest); + assert_ne!(a, b); + } +} diff --git a/crates/agentkeys-core/src/clear_signing/parser.rs b/crates/agentkeys-core/src/clear_signing/parser.rs new file mode 100644 index 0000000..d683038 --- /dev/null +++ b/crates/agentkeys-core/src/clear_signing/parser.rs @@ -0,0 +1,154 @@ +//! ERC-7730 v2 metadata file parser (issue #82). +//! +//! Parses the JSON shape documented at +//! into typed Rust structs. Only the subset needed for v0 clear-signing is +//! retained — operator-facing intent strings, EIP-712 domain binding, and +//! per-field display formats. Calldata-recursion, enum-resolved-from-chain, +//! and contract-deployment lookup beyond exact-match are out of scope. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Erc7730Error { + #[error("malformed_7730_file: {0}")] + Malformed(String), + + #[error("unsupported_7730_format: {0}")] + Unsupported(String), +} + +/// Top-level ERC-7730 file. Other fields the spec defines (`metadata.owner`, +/// `metadata.info.legalName`, etc.) are accepted but not currently surfaced +/// to the operator — operators looking at the rendered preview see the +/// rendered intent string, not the metadata block. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730File { + pub context: Erc7730Context, + #[serde(default)] + pub metadata: serde_json::Value, + pub display: Erc7730Display, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730Context { + /// EIP-712 binding — domain.{name, version, chainId, verifyingContract} + /// is the lookup key for typed-data sign requests. + #[serde(rename = "eip712", default)] + pub eip712: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730Eip712Context { + pub domain: Erc7730Eip712Domain, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Erc7730Eip712Domain { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub version: Option, + #[serde(default, rename = "chainId")] + pub chain_id: Option, + #[serde(default, rename = "verifyingContract")] + pub verifying_contract: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730Display { + /// Keyed by the primary type (EIP-712) or function selector (calldata). + /// v0 only honors the EIP-712 primary-type form. + pub formats: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730Format { + /// Intent string with `{field}` interpolation. Example: + /// `"Approve {value} {token} to {spender}"`. + #[serde(default)] + pub intent: Option, + /// Per-field display rules. Path is JSONPath-lite (`message.value`, + /// `message.permit.token`). + #[serde(default)] + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Erc7730Field { + pub path: String, + #[serde(default)] + pub label: Option, + /// One of: `"tokenAmount"`, `"address"`, `"raw"`, `"date"`, `"integer"`, + /// `"enum"`, `"bool"`. Unknown formats fall back to raw. + pub format: String, + #[serde(default)] + pub params: serde_json::Value, +} + +pub fn parse(json: &str) -> Result { + serde_json::from_str::(json) + .map_err(|e| Erc7730Error::Malformed(format!("invalid JSON: {e}"))) +} + +pub fn parse_value(value: serde_json::Value) -> Result { + serde_json::from_value::(value) + .map_err(|e| Erc7730Error::Malformed(format!("schema mismatch: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const USDC_PERMIT_7730: &str = r#"{ + "context": { + "eip712": { + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + } + } + }, + "metadata": { "owner": "Circle" }, + "display": { + "formats": { + "Permit": { + "intent": "Approve USDC {value} to {spender}", + "fields": [ + { "path": "owner", "label": "Owner", "format": "address" }, + { "path": "spender", "label": "Spender", "format": "address" }, + { "path": "value", "label": "Amount", "format": "tokenAmount", "params": { "decimals": 6, "ticker": "USDC" } }, + { "path": "nonce", "label": "Nonce", "format": "integer" }, + { "path": "deadline", "label": "Deadline", "format": "date" } + ] + } + } + } + }"#; + + #[test] + fn parses_usdc_permit_fixture() { + let file = parse(USDC_PERMIT_7730).unwrap(); + let eip712 = file.context.eip712.unwrap(); + assert_eq!(eip712.domain.name.as_deref(), Some("USD Coin")); + assert_eq!(eip712.domain.chain_id, Some(1)); + let permit = file.display.formats.get("Permit").unwrap(); + assert_eq!( + permit.intent.as_deref(), + Some("Approve USDC {value} to {spender}") + ); + assert_eq!(permit.fields.len(), 5); + let value_field = permit.fields.iter().find(|f| f.path == "value").unwrap(); + assert_eq!(value_field.format, "tokenAmount"); + assert_eq!(value_field.params["decimals"], serde_json::json!(6)); + } + + #[test] + fn rejects_malformed_json() { + assert!(matches!(parse("{not json"), Err(Erc7730Error::Malformed(_)))); + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index 181e067..dea2c9d 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod actor_omni; pub mod auth_request; pub mod backend; pub mod chain_profile; +pub mod clear_signing; pub mod init_flow; pub mod mock_client; pub mod otp; diff --git a/crates/agentkeys-core/src/s3_backend.rs b/crates/agentkeys-core/src/s3_backend.rs index 9937270..03500cf 100644 --- a/crates/agentkeys-core/src/s3_backend.rs +++ b/crates/agentkeys-core/src/s3_backend.rs @@ -827,7 +827,10 @@ fn unsupported(op: &str) -> BackendError { #[cfg(test)] mod tests { use super::*; - use crate::signer_client::{DerivedAddress, SignedMessage, SignerClient, SignerClientError}; + use crate::clear_signing::TypedData; + use crate::signer_client::{ + DerivedAddress, SignedMessage, SignedTypedData, SignerClient, SignerClientError, + }; use async_trait::async_trait; use std::sync::Mutex; @@ -873,6 +876,18 @@ mod tests { key_version: 1, }) } + + async fn sign_eip712( + &self, + _omni: &str, + _td: &TypedData, + ) -> Result { + // S3CredentialBackend only needs the EIP-191 KEK-derivation + // path; this fake never sees a typed-data sign call. + Err(SignerClientError::Internal( + "FakeSigner does not implement sign_eip712".into(), + )) + } } fn fake_signer() -> Arc { diff --git a/crates/agentkeys-core/src/signer_client.rs b/crates/agentkeys-core/src/signer_client.rs index 7a111c4..69434e9 100644 --- a/crates/agentkeys-core/src/signer_client.rs +++ b/crates/agentkeys-core/src/signer_client.rs @@ -15,6 +15,8 @@ use async_trait::async_trait; use thiserror::Error; +use crate::clear_signing::TypedData; + /// Wire-protocol error codes from `signer-protocol.md`. Daemon code matches /// on these (and the transport variants) to drive retry / surface logic. #[derive(Debug, Error)] @@ -27,6 +29,12 @@ pub enum SignerClientError { #[error("invalid_message_hex: {0}")] InvalidMessageHex(String), + /// 400 `invalid_typed_data` (issue #82) — `typed_data` payload was + /// rejected by the signer before any signing happened: malformed JSON, + /// unknown type, value out of range for declared type. + #[error("invalid_typed_data: {0}")] + InvalidTypedData(String), + /// 503 `signer_disabled` — operator must set /// `DEV_KEY_SERVICE_MASTER_SECRET` (dev) or attest the TEE (prod). #[error("signer_disabled: {0}")] @@ -75,7 +83,21 @@ pub struct SignedMessage { pub key_version: u8, } -/// The daemon's view of the signer. Two methods, both pure RPC. +/// Successful response from `/dev/sign-typed-data` (issue #82). Carries +/// the signature plus every digest the signer computed internally — so the +/// caller can cross-reference against the ERC-7730 metadata file pinned to +/// the same domain separator / primary type hash for audit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedTypedData { + pub signature: String, + pub address: String, + pub primary_type_hash: String, + pub domain_separator: String, + pub digest: String, + pub key_version: u8, +} + +/// The daemon's view of the signer. Three methods, all pure RPC. #[async_trait] pub trait SignerClient: Send + Sync { /// Resolve `omni_account` (64 lowercase hex chars) to its derived EVM @@ -93,6 +115,22 @@ pub trait SignerClient: Send + Sync { omni_account: &str, message_bytes: &[u8], ) -> Result; + + /// EIP-712-sign `typed_data` under the keypair derived from + /// `omni_account` (issue #82). The signer parses the typed-data JSON + /// itself and computes the digest internally — callers MUST NOT pass a + /// pre-hashed value. + /// + /// Returns the signature + every intermediate digest the signer + /// produced (`primary_type_hash`, `domain_separator`, final `digest`), + /// so the daemon can cross-reference against an ERC-7730 metadata file + /// and emit an audit row whose intent commitment binds to the same + /// digest the signer signed over. + async fn sign_eip712( + &self, + omni_account: &str, + typed_data: &TypedData, + ) -> Result; } /// HTTP implementation of `SignerClient` — talks to the dev_key_service @@ -221,6 +259,52 @@ impl SignerClient for HttpSignerClient { } Err(map_error(status, &body)) } + + async fn sign_eip712( + &self, + omni_account: &str, + typed_data: &TypedData, + ) -> Result { + let url = format!("{}/dev/sign-typed-data", self.base_url); + let mut req = self.http.post(&url).json(&serde_json::json!({ + "omni_account": omni_account, + "typed_data": typed_data, + })); + if let Some(jwt) = &self.session_jwt { + req = req.header("Authorization", format!("Bearer {jwt}")); + } + let resp = req + .send() + .await + .map_err(|e| SignerClientError::Transport(format!("POST {url}: {e}")))?; + let status = resp.status().as_u16(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| SignerClientError::Transport(format!("parse JSON: {e}")))?; + + if status == 200 { + let pick = |k: &'static str| -> Result { + body[k] + .as_str() + .map(str::to_string) + .ok_or_else(|| SignerClientError::Unexpected { + status, + error: None, + message: Some(format!("missing '{k}'")), + }) + }; + return Ok(SignedTypedData { + signature: pick("signature")?, + address: pick("address")?, + primary_type_hash: pick("primary_type_hash")?, + domain_separator: pick("domain_separator")?, + digest: pick("digest")?, + key_version: body["key_version"].as_u64().unwrap_or(0) as u8, + }); + } + Err(map_error(status, &body)) + } } /// Translate a non-2xx response body into a typed `SignerClientError`, @@ -231,6 +315,7 @@ fn map_error(status: u16, body: &serde_json::Value) -> SignerClientError { match (status, code) { (400, "invalid_omni_account") => SignerClientError::InvalidOmniAccount(message), (400, "invalid_message_hex") => SignerClientError::InvalidMessageHex(message), + (400, "invalid_typed_data") => SignerClientError::InvalidTypedData(message), (401, "unauthorized") => SignerClientError::Unauthorized(message), (503, "signer_disabled") => SignerClientError::SignerDisabled(message), (500, "internal") => SignerClientError::Internal(message), diff --git a/crates/agentkeys-mock-server/src/dev_key_service.rs b/crates/agentkeys-mock-server/src/dev_key_service.rs index b81b139..0537777 100644 --- a/crates/agentkeys-mock-server/src/dev_key_service.rs +++ b/crates/agentkeys-mock-server/src/dev_key_service.rs @@ -64,6 +64,12 @@ pub enum SignerError { #[error("invalid_message_hex: {0}")] InvalidMessageHex(String), + /// Issue #82 — typed-data signing rejected the EIP-712 payload before + /// any signing happened (malformed JSON, unknown type, value out of + /// range for declared type). + #[error("invalid_typed_data: {0}")] + InvalidTypedData(String), + #[error("internal: {0}")] Internal(String), } @@ -75,6 +81,7 @@ impl SignerError { match self { SignerError::InvalidOmniAccount(_) => "invalid_omni_account", SignerError::InvalidMessageHex(_) => "invalid_message_hex", + SignerError::InvalidTypedData(_) => "invalid_typed_data", SignerError::Internal(_) => "internal", } } @@ -82,7 +89,9 @@ impl SignerError { /// HTTP status the handler should return. pub fn http_status(&self) -> u16 { match self { - SignerError::InvalidOmniAccount(_) | SignerError::InvalidMessageHex(_) => 400, + SignerError::InvalidOmniAccount(_) + | SignerError::InvalidMessageHex(_) + | SignerError::InvalidTypedData(_) => 400, SignerError::Internal(_) => 500, } } @@ -212,6 +221,57 @@ impl DevKeyService { let signature_hex = format!("0x{}", hex::encode(&sig_bytes)); Ok((signature_hex, address)) } + + /// **DEV ONLY.** EIP-712 typed-data sign (issue #82). Returns the + /// signature, the recovered address, and the digests the signer + /// computed internally so the caller can cross-reference against an + /// ERC-7730 metadata file for audit. + /// + /// The signer parses `typed_data` itself and computes the digest from + /// `keccak256("\x19\x01" || domain_separator || hashStruct(primaryType, + /// message))`. It never accepts a caller-supplied prehash — that is + /// what makes the signer's signature a meaningful claim about *what + /// was signed*, not just *that something was signed*. + pub fn sign_eip712( + &self, + omni_account: &str, + typed_data: agentkeys_core::clear_signing::TypedData, + ) -> Result { + let omni_bytes = parse_omni_account(omni_account)?; + let sk = self.derive_signing_key(&omni_bytes)?; + let address = address_for_signing_key(&sk); + + let digests = agentkeys_core::clear_signing::compute_digests(&typed_data) + .map_err(|e| SignerError::InvalidTypedData(e.to_string()))?; + + let (sig, recovery_id) = sk + .sign_prehash_recoverable(&digests.final_digest) + .map_err(|e| SignerError::Internal(format!("signing failed: {e}")))?; + + let mut sig_bytes = sig.to_bytes().to_vec(); + sig_bytes.push(recovery_id.to_byte()); + debug_assert_eq!(sig_bytes.len(), 65, "EIP-712 signature must be 65 bytes"); + + Ok(Eip712SignResult { + signature: format!("0x{}", hex::encode(&sig_bytes)), + address, + primary_type_hash: format!("0x{}", hex::encode(digests.primary_type_hash)), + domain_separator: format!("0x{}", hex::encode(digests.domain_separator)), + digest: format!("0x{}", hex::encode(digests.final_digest)), + }) + } +} + +/// Result of `sign_eip712`. Each digest is emitted alongside the signature +/// so an audit trail can cross-reference against the ERC-7730 metadata +/// file pinned to the same domain separator + primary type hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Eip712SignResult { + pub signature: String, + pub address: String, + pub primary_type_hash: String, + pub domain_separator: String, + pub digest: String, } /// Parse an `omni_account` from the wire format (64 lowercase hex chars, @@ -405,6 +465,101 @@ mod tests { SignerError::InvalidMessageHex("x".into()).code(), "invalid_message_hex" ); + assert_eq!( + SignerError::InvalidTypedData("x".into()).code(), + "invalid_typed_data" + ); assert_eq!(SignerError::Internal("x".into()).code(), "internal"); } + + /// Issue #82 — typed-data sign produces a signature that recovers to + /// the same address `derive_address` returns, AND emits the EIP-712 + /// digests in the result envelope. + #[test] + fn sign_eip712_recovers_to_derived_address() { + use agentkeys_core::clear_signing::{TypeField, TypedData}; + use std::collections::BTreeMap; + + let s = fixed_signer(); + let omni = fixed_omni(); + let derived = s.derive_address(&omni).unwrap(); + + let mut types: BTreeMap> = BTreeMap::new(); + types.insert( + "EIP712Domain".into(), + vec![ + TypeField { name: "name".into(), ty: "string".into() }, + TypeField { name: "version".into(), ty: "string".into() }, + TypeField { name: "chainId".into(), ty: "uint256".into() }, + TypeField { name: "verifyingContract".into(), ty: "address".into() }, + ], + ); + types.insert( + "Permit".into(), + vec![ + TypeField { name: "owner".into(), ty: "address".into() }, + TypeField { name: "spender".into(), ty: "address".into() }, + TypeField { name: "value".into(), ty: "uint256".into() }, + TypeField { name: "nonce".into(), ty: "uint256".into() }, + TypeField { name: "deadline".into(), ty: "uint256".into() }, + ], + ); + let td = TypedData { + types, + primary_type: "Permit".into(), + domain: serde_json::json!({ + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }), + message: serde_json::json!({ + "owner": "0x1111111111111111111111111111111111111111", + "spender": "0xaaaabbbbccccddddeeeeffff0000111122223333", + "value": "1500000", + "nonce": "0", + "deadline": "1900000000", + }), + }; + + let result = s.sign_eip712(&omni, td).unwrap(); + assert_eq!(result.address, derived); + assert!(result.signature.starts_with("0x")); + assert_eq!(result.signature.len(), 2 + 130); + assert!(result.digest.starts_with("0x")); + assert_eq!(result.digest.len(), 2 + 64); + + // Cross-check signature recovers to derived addr via the spec digest. + let raw = hex::decode(result.signature.trim_start_matches("0x")).unwrap(); + let recovery_id = RecoveryId::try_from(raw[64]).unwrap(); + let signature = Signature::from_slice(&raw[..64]).unwrap(); + let digest_bytes = hex::decode(result.digest.trim_start_matches("0x")).unwrap(); + let mut digest = [0u8; 32]; + digest.copy_from_slice(&digest_bytes); + let vk = VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id).unwrap(); + let encoded_point = vk.to_encoded_point(false); + let pubkey_bytes = encoded_point.as_bytes(); + let mut h = Keccak256::new(); + h.update(&pubkey_bytes[1..]); + let pubkey_hash = h.finalize(); + let recovered = format!("0x{}", hex::encode(&pubkey_hash[12..])); + assert_eq!(recovered, derived); + } + + #[test] + fn sign_eip712_rejects_malformed_typed_data() { + use agentkeys_core::clear_signing::TypedData; + use std::collections::BTreeMap; + + let s = fixed_signer(); + // Missing EIP712Domain in types → invalid_typed_data. + let td = TypedData { + types: BTreeMap::new(), + primary_type: "Permit".into(), + domain: serde_json::json!({}), + message: serde_json::json!({}), + }; + let err = s.sign_eip712(&fixed_omni(), td).unwrap_err(); + assert!(matches!(err, SignerError::InvalidTypedData(_))); + } } diff --git a/crates/agentkeys-mock-server/src/handlers/dev_keys.rs b/crates/agentkeys-mock-server/src/handlers/dev_keys.rs index 383be44..31fbc57 100644 --- a/crates/agentkeys-mock-server/src/handlers/dev_keys.rs +++ b/crates/agentkeys-mock-server/src/handlers/dev_keys.rs @@ -30,6 +30,14 @@ pub struct SignMessageRequest { pub message_hex: String, } +/// Issue #82 — typed-data sign request. `typed_data` carries the canonical +/// EIP-712 v4 JSON shape (matches MetaMask `eth_signTypedData_v4`). +#[derive(Deserialize)] +pub struct SignTypedDataRequest { + pub omni_account: String, + pub typed_data: agentkeys_core::clear_signing::TypedData, +} + /// Minimal JWT claims we care about for verification. #[derive(Debug, Serialize, Deserialize)] struct SessionClaims { @@ -168,6 +176,39 @@ pub async fn sign_message( } } +/// Issue #82 — typed-data sign handler. Mirrors `sign_message` for the JWT +/// auth + signer-disabled paths; on success returns the signature + every +/// digest the signer computed internally (so the caller can cross-reference +/// against an ERC-7730 metadata file for audit). +pub async fn sign_typed_data( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + if let Err(e) = verify_session_jwt(&state, &headers, &body.omni_account) { + return e.into_response(); + } + let Some(signer) = state.dev_signer.as_ref() else { + return signer_disabled().into_response(); + }; + + match signer.sign_eip712(&body.omni_account, body.typed_data) { + Ok(result) => ( + StatusCode::OK, + Json(json!({ + "signature": result.signature, + "address": result.address, + "primary_type_hash": result.primary_type_hash, + "domain_separator": result.domain_separator, + "digest": result.digest, + "key_version": KEY_VERSION, + })), + ) + .into_response(), + Err(e) => signer_error(e).into_response(), + } +} + fn signer_disabled() -> (StatusCode, Json) { ( StatusCode::SERVICE_UNAVAILABLE, diff --git a/crates/agentkeys-mock-server/src/lib.rs b/crates/agentkeys-mock-server/src/lib.rs index e0b91a6..c4878b9 100644 --- a/crates/agentkeys-mock-server/src/lib.rs +++ b/crates/agentkeys-mock-server/src/lib.rs @@ -22,6 +22,10 @@ pub fn create_signer_router(state: SharedState) -> Router { Router::new() .route("/dev/derive-address", post(handlers::dev_keys::derive_address)) .route("/dev/sign-message", post(handlers::dev_keys::sign_message)) + // Issue #82 — EIP-712 typed-data signing. Same JWT auth path as + // `/dev/sign-message`; signer parses typed_data itself + emits + // digests alongside the signature. + .route("/dev/sign-typed-data", post(handlers::dev_keys::sign_typed_data)) .route("/healthz", get(|| async { "ok" })) .with_state(state) } @@ -68,6 +72,9 @@ pub fn create_router(state: SharedState) -> Router { // Issue #74 step 2 replaces this with a TEE worker; wire shape stays. .route("/dev/derive-address", post(handlers::dev_keys::derive_address)) .route("/dev/sign-message", post(handlers::dev_keys::sign_message)) + // Issue #82 — EIP-712 typed-data sign endpoint. Documented in + // `signer-protocol.md`. TEE-worker swap-in preserves the same path. + .route("/dev/sign-typed-data", post(handlers::dev_keys::sign_typed_data)) // `/healthz` (Kubernetes convention) — what the broker's Tier-2 // reachability probe hits. Single endpoint, single name across the // codebase. Pre-Stage-7 `/health` alias was dropped; any caller that diff --git a/crates/agentkeys-mock-server/tests/dev_key_service_routes.rs b/crates/agentkeys-mock-server/tests/dev_key_service_routes.rs index 2cd8afc..589c94a 100644 --- a/crates/agentkeys-mock-server/tests/dev_key_service_routes.rs +++ b/crates/agentkeys-mock-server/tests/dev_key_service_routes.rs @@ -466,3 +466,199 @@ async fn signer_only_session_endpoint_absent() { // signer-only router has no /session route → 404 assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +// ── /dev/sign-typed-data tests (issue #82) ──────────────────────────────── + +fn usdc_permit_typed_data(value: &str) -> Value { + json!({ + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }, + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Permit": [ + { "name": "owner", "type": "address" }, + { "name": "spender", "type": "address" }, + { "name": "value", "type": "uint256" }, + { "name": "nonce", "type": "uint256" }, + { "name": "deadline", "type": "uint256" } + ] + }, + "primaryType": "Permit", + "message": { + "owner": "0x1111111111111111111111111111111111111111", + "spender": "0xaaaabbbbccccddddeeeeffff0000111122223333", + "value": value, + "nonce": "0", + "deadline": "1900000000" + } + }) +} + +#[tokio::test] +async fn sign_typed_data_returns_signature_address_digests() { + let master = [0x44u8; 32]; + let omni = fixed_omni(); + + let (status, body) = post_json( + router_with_signer(master), + "/dev/sign-typed-data", + json!({ + "omni_account": omni, + "typed_data": usdc_permit_typed_data("1500000"), + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let sig = body["signature"].as_str().unwrap(); + assert!(sig.starts_with("0x")); + assert_eq!(sig.len(), 2 + 130, "signature must be 65 bytes hex"); + + let address = body["address"].as_str().unwrap(); + assert!(address.starts_with("0x")); + assert_eq!(address.len(), 42); + + for k in ["primary_type_hash", "domain_separator", "digest"] { + let h = body[k].as_str().unwrap_or_else(|| panic!("missing {k}")); + assert!(h.starts_with("0x")); + assert_eq!(h.len(), 2 + 64, "{k} must be 32 bytes hex"); + } + assert_eq!(body["key_version"], 1); +} + +#[tokio::test] +async fn sign_typed_data_address_matches_derive_response() { + let master = [0x44u8; 32]; + let omni = fixed_omni(); + + let (s1, derive) = post_json( + router_with_signer(master), + "/dev/derive-address", + json!({ "omni_account": omni }), + ) + .await; + let (s2, sign) = post_json( + router_with_signer(master), + "/dev/sign-typed-data", + json!({ + "omni_account": omni, + "typed_data": usdc_permit_typed_data("1500000"), + }), + ) + .await; + assert_eq!(s1, StatusCode::OK); + assert_eq!(s2, StatusCode::OK); + assert_eq!(derive["address"], sign["address"]); +} + +#[tokio::test] +async fn sign_typed_data_rejects_unknown_primary_type() { + let master = [0u8; 32]; + let mut td = usdc_permit_typed_data("1500000"); + td["primaryType"] = json!("NoSuchType"); + let (status, body) = post_json( + router_with_signer(master), + "/dev/sign-typed-data", + json!({ + "omni_account": fixed_omni(), + "typed_data": td, + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "invalid_typed_data"); +} + +#[tokio::test] +async fn sign_typed_data_rejects_out_of_range_uint() { + let master = [0u8; 32]; + let mut td = usdc_permit_typed_data("1500000"); + // Change `value` field to `uint8` so the actual value (1_500_000) overflows. + td["types"]["Permit"][2]["type"] = json!("uint8"); + let (status, body) = post_json( + router_with_signer(master), + "/dev/sign-typed-data", + json!({ + "omni_account": fixed_omni(), + "typed_data": td, + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "invalid_typed_data"); +} + +#[tokio::test] +async fn sign_typed_data_returns_503_when_signer_disabled() { + let app = router_without_signer(); + let (status, body) = post_json( + app, + "/dev/sign-typed-data", + json!({ + "omni_account": fixed_omni(), + "typed_data": usdc_permit_typed_data("1500000"), + }), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"], "signer_disabled"); +} + +#[tokio::test] +async fn sign_typed_data_recovers_to_derived_address() { + use sha3::{Digest, Keccak256}; + + let master = [0x55u8; 32]; + let omni = fixed_omni(); + + let (_, derive) = post_json( + router_with_signer(master), + "/dev/derive-address", + json!({ "omni_account": omni }), + ) + .await; + let derived = derive["address"].as_str().unwrap().to_string(); + + let (status, sign) = post_json( + router_with_signer(master), + "/dev/sign-typed-data", + json!({ + "omni_account": omni, + "typed_data": usdc_permit_typed_data("42"), + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Recover the signing public key from the signature + digest the signer + // emitted, and assert it derives to the same address. + let sig_bytes = + hex::decode(sign["signature"].as_str().unwrap().trim_start_matches("0x")).unwrap(); + let digest_bytes = + hex::decode(sign["digest"].as_str().unwrap().trim_start_matches("0x")).unwrap(); + + let recovery_id = k256::ecdsa::RecoveryId::try_from(sig_bytes[64]).unwrap(); + let signature = k256::ecdsa::Signature::from_slice(&sig_bytes[..64]).unwrap(); + let mut digest = [0u8; 32]; + digest.copy_from_slice(&digest_bytes); + let vk = + k256::ecdsa::VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id).unwrap(); + + let encoded_point = vk.to_encoded_point(false); + let pubkey_bytes = encoded_point.as_bytes(); + let mut h = Keccak256::new(); + h.update(&pubkey_bytes[1..]); + let pubkey_hash = h.finalize(); + let recovered = format!("0x{}", hex::encode(&pubkey_hash[12..])); + + assert_eq!(recovered, derived); +} diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index a1f5aed..507e196 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -804,11 +804,16 @@ Callers: broker + workers only. Daemons never talk to the signer directly — al /derive-cred-kek {operator_omni, k3_epoch} → KEK /sts-credentials {actor_omni, role_arn, ttl} → AWS STS creds /sign/siwe {actor_omni, siwe_message} → EIP-191 sig +/sign/typed-data {actor_omni, typed_data} → EIP-712 sig + digest + type_hash + domain_sep (issue #82) /sign/audit-row {actor_omni, audit_row} → audit-chain sig /verify/k10-sig {device_pubkey, payload, sig} → bool /verify/k11-assertion {cred_id, payload, assertion} → bool ``` +The mock-server backend exposes `/sign/typed-data` under the legacy +`/dev/sign-typed-data` path alongside `/dev/sign-message`. TEE-worker +swap-in MUST preserve both shapes; see [`signer-protocol.md`](signer-protocol.md). + ### 14.3 K3 rotation handling The signer is the only component that needs to hold historical K3 versions. Per K3 rotation (§16): @@ -867,6 +872,20 @@ V2 default: tier C. Tier A is the gas-subsidy escape hatch. Tier B is for operat The audit-service worker is stateless for tier C (every event independently signed); maintains a relay batcher for tiers A/B that drains to chain at configurable cadence (default 1 minute or 256 events, whichever first). +**Audit-row schema with intent commitment (issue #82).** Each audit row carries two optional fields when the underlying event was a typed-data sign (`/sign/typed-data` on the signer): + +| Field | Type | Source | Use | +|---|---|---|---| +| `signed_intent_text` | string | rendered ERC-7730 `interpolatedIntent` (e.g. `"Approve USDC 1000.00 to Uniswap v4 router"`) | Operator-readable record of *what was authorized*, not just *that something was signed* | +| `signed_intent_hash` | 32-byte hex | `keccak256(intent_text || "\|" || digest)` | Cryptographically commits the rendered intent to the EIP-712 digest the signer produced. Auditors verifying a sign event re-render the intent from the same ERC-7730 file and check the commitment matches. | + +Backward compatible: pre-#82 audit rows have these fields absent; tier C +chain events keep their current shape (the commitment is stored in +`signed_intent_hash` only — the rendered text is off-chain in the worker's +S3 row). A future contract revision will extend `CredentialAudit.append` +to take the commitment hash as a 33rd byte; until then, tier C chain +events index the audit-row by `signed_intent_hash` via S3 path. + ### 15.4 email-service - **IAM:** `ses:SendRawEmail` from operator's domain (e.g., `bots.litentry.org`); `s3:GetObject` + `s3:PutObject` on `bots//{inbound,sent}/*` @@ -1364,6 +1383,7 @@ The architecture is intentionally pluggable on six axes. Each axis has a default | **Chain layer** | Litentry/Heima parachain (built-in profile `heima`, chain ID 212013) | Any EVM-compatible chain (Base, Ethereum, Optimism, Arbitrum, Moonbeam, Astar, permissioned substrates like Aliyun BaaS / Hyperledger / Quorum) | **Named chain profiles** — `crates/agentkeys-core/src/chain_profile.rs` ships 7 built-ins (heima, heima-paseo, base, base-sepolia, ethereum, sepolia, anvil); operator-custom chains via `$AGENTKEYS_CHAIN_PROFILE_FILE` JSON. CLI `--chain `; daemon / broker / workers all read the same profile. See §22a below. | | **Worker runtime** | AWS Lambda + API Gateway | axum microservice (vendor-neutral); Cloudflare Worker (edge); Tencent SCF (China) | Worker shape per §15 is uniform across runtimes | | **Payment rail** | Per mode: P-1 service-pool / P-2 escrow / P-3 direct | Mode + upstream (Stripe, USDC, SOL, fiat) | Per-mode plugins layer on the §15.5 wire shape | +| **Clear-signing metadata** (issue #82) | Bundled ERC-7730 v2 set under `agentkeys-core::clear_signing::fixtures/` (USDC permit + curated DEX routers + permit2) | Registry fetch from `github.com/ethereum/clear-signing-erc7730-registry` at daemon startup; on-chain registry / IPFS-pinned + signature-verified | `ClearSigningCatalog` trait in [`crates/agentkeys-core/src/clear_signing/`](../../crates/agentkeys-core/src/clear_signing/); bundled → registry-cached → on-chain progression. Operator-custom files via `$AGENTKEYS_7730_DIR` env var | **Pluggability is the point.** No single backend is load-bearing for the architecture; the contracts (auth-plugin trait, signer-protocol, audit trait, worker shape, chain ABI) are. This is what lets: diff --git a/docs/spec/plans/issue-82-erc7730-v2-aligned.md b/docs/spec/plans/issue-82-erc7730-v2-aligned.md new file mode 100644 index 0000000..1da8e17 --- /dev/null +++ b/docs/spec/plans/issue-82-erc7730-v2-aligned.md @@ -0,0 +1,204 @@ +# Issue #82 — ERC-7730 clear-signing, v2-aligned plan + +**Status:** plan in progress (this PR ships phases 1-3 + phase-4 schema). +**Supersedes:** the original #82 body, which targeted v1 architecture (mock-server-as-signer, daemon-side metadata, broker SQLite audit). +**Owner:** AgentKeys signer + worker stack. + +--- + +## Why this rewrite + +The original #82 was filed before v2 architecture landed (PR #87 / #92). Three premises in the original issue are now out of date: + +1. **"Signer is `dev_key_service`, replaced post-#74-step-2 by TEE worker."** Reality: the signer is now a first-class component (arch.md §14, `signer.litentry.org`) with a typed RPC surface (`/derive-address`, `/derive-cred-kek`, `/sts-credentials`, `/sign/siwe`, `/sign/audit-row`, `/verify/k10-sig`, `/verify/k11-assertion`). `/dev/sign-message` is the legacy SIWE-only path; new sign primitives must land on the §14.2 surface. +2. **"Daemon-side metadata binding."** Reality: daemons never call the signer directly (arch.md §14.2 line 1). Binding belongs at the broker's cap-mint (so the cap-token's `op_type` carries the intent commitment) and at the signer (so it refuses to sign domains outside its bound 7730 set). The daemon's job is preview rendering. +3. **"Broker SQLite audit row schema extension."** Reality: audit is now a worker (`agentkeys-worker-audit`) with three tiers (§15.3). Intent fields belong on the worker's row schema and in `CredentialAudit.append` on chain. + +This plan re-targets all four phases against v2 surfaces. **It also adds K11-binding-on-high-value-signs**, a defense the original missed. + +--- + +## Phase 1 — EIP-712 typed-data signing + +**Wire shape** (extends [`signer-protocol.md`](../signer-protocol.md)): + +``` +POST /dev/sign-typed-data +{ + "omni_account": "<64 hex>", + "typed_data": { EIP-712 v4 JSON: domain, types, primaryType, message } +} +→ 200 +{ + "signature": "0x<130 hex>", + "address": "0x<40 hex>", + "primary_type_hash": "0x<64 hex>", // audit cross-ref + "domain_separator": "0x<64 hex>", // audit cross-ref + "digest": "0x<64 hex>", // final EIP-712 digest signed + "key_version": 1 +} +``` + +**Key property:** the signer parses the typed-data JSON itself and computes +`keccak256("\x19\x01" || domainSeparator || hashStruct(primaryType, message))` +internally — it never trusts a caller-supplied prehash. This is what makes the +signer's signature a meaningful claim about *what was signed*. + +**Crates touched:** + +| File | Change | +|---|---| +| [`crates/agentkeys-mock-server/src/dev_key_service.rs`](../../crates/agentkeys-mock-server/src/dev_key_service.rs) | Add `sign_eip712(omni, typed_data) → (sig, addr, type_hash, domain_sep, digest)` + EIP-712 v4 hashing | +| [`crates/agentkeys-mock-server/src/handlers/dev_keys.rs`](../../crates/agentkeys-mock-server/src/handlers/dev_keys.rs) | Add `sign_typed_data` handler with JWT auth path identical to `sign_message` | +| [`crates/agentkeys-mock-server/src/lib.rs`](../../crates/agentkeys-mock-server/src/lib.rs) | Wire route in both `create_signer_router()` and `create_router()` | +| [`crates/agentkeys-core/src/signer_client.rs`](../../crates/agentkeys-core/src/signer_client.rs) | Add `sign_eip712()` to `SignerClient` trait + `HttpSignerClient` | + +**Tests:** + +- Unit tests in `dev_key_service.rs`: domain-separator computation against known + fixtures (USDC permit, Permit2 single-permit, EIP-2612 generic). +- Route tests in `dev_key_service_routes.rs`: 200 / 400 / 401 / 503 paths. +- Conformance tests in `signer_conformance.rs`: TEE-stub vs HKDF-backed parity. + +## Phase 2 — ERC-7730 metadata parser + binding + +**New module:** `crates/agentkeys-core/src/clear_signing/`: + +``` +clear_signing/ +├── mod.rs # public API: ClearSigningCatalog, BoundSignRequest +├── parser.rs # ERC-7730 JSON parser (subset for v0) +├── format.rs # token-amount / address-name / enum / date formatters +├── binding.rs # domain.{name,version,chainId,verifyingContract} → 7730 file lookup +├── eip712.rs # EIP-712 typed-data encoding (shared with mock-server signer) +└── fixtures/ + └── erc20-permit.json # bundled USDC permit ERC-7730 file +``` + +**Binding strategy (per arch.md §22 pluggable surfaces):** + +| v | Source | When | +|---|---|---| +| v0 | Bundled set under `fixtures/` (USDC permit, Permit2, OpenSea Seaport) | This PR | +| v1 | Fetch from `github.com/ethereum/clear-signing-erc7730-registry` at daemon startup, cached locally | Follow-up issue | +| v2 | On-chain registry / IPFS-pinned + signature-verified | v3+ | + +**Public API:** + +```rust +pub struct ClearSigningCatalog { /* loaded ERC-7730 files keyed by domain */ } + +impl ClearSigningCatalog { + pub fn bundled() -> Self; + pub fn from_dir(path: &Path) -> Result; + pub fn lookup_for_eip712(&self, domain: &Eip712Domain) -> Option<&Erc7730File>; +} + +pub struct BoundSignRequest { + pub typed_data: serde_json::Value, + pub rendered_intent: String, // e.g. "Approve USDC 1000.00 to Uniswap router" + pub intent_commitment: [u8; 32], // keccak256(intent_text || "|" || digest) +} + +impl BoundSignRequest { + pub fn build( + catalog: &ClearSigningCatalog, + typed_data: serde_json::Value, + digest: [u8; 32], + ) -> Result; +} +``` + +## Phase 3 — Display rendering at operator review surface + +**CLI subcommand additions:** + +``` +# Preview without signing — show what the wallet would authorize +agentkeys signer preview-7730 \ + --typed-data-file ./permit.json \ + [--7730-file ./erc20-permit.json | --catalog bundled] + +# Sign with preview + confirmation prompt (interactive) +agentkeys signer sign-typed-data \ + --signer-url \ + --omni-account <64hex> \ + --typed-data-file ./permit.json \ + [--no-preview] +``` + +**Surface affected:** [`crates/agentkeys-cli/`](../../crates/agentkeys-cli/) — new +subcommands routed through `signer` group. + +**MCP tool (later — separate issue):** `agentkeys.preview_sign` returns the +rendered display for LLM agents to surface inline before requesting the +operator's K11 assertion. + +## Phase 4 — Intent-aware audit (schema this PR; wiring follow-up) + +**Arch.md §15.3 addition (this PR):** extend audit-row schema with: + +- `signed_intent_text` — the rendered `interpolatedIntent` string (e.g., + `"Approve USDC 1000.00 to Uniswap v4 router"`). +- `signed_intent_hash` — `keccak256(intent_text || "|" || digest)`. The + audit row cryptographically commits to the rendered intent the operator + saw. + +**Wiring (follow-up issue):** + +- `agentkeys-worker-audit::handlers::append` accepts the two fields in the + request body and stores them. +- `CredentialAudit.append(...)` on chain extends its event log to include + the commitment hash (text stays off-chain; chain holds only the + commitment). +- Broker cap-mint propagates the commitment through the cap-token's + `intent_commitment` field so workers can verify it before any sign call. + +**Why split:** the schema is backwards-compatible (workers ignore unknown +fields today); the chain-side audit event extension requires a contract +revision + redeploy, which is a separate change ladder. Schema-first +unblocks Phase 3 to start writing intent fields immediately; the chain +extension lands when the next contract revision ships. + +## Phase 5 — K11 binding on high-value signs (NEW vs original #82) + +Original #82 missed this entirely. Per arch.md §10.1 + §5a, K11 WebAuthn is +required for master mutations. Typed-data signs that meet operator-policy +thresholds (e.g., `tokenAmount > $POLICY_THRESHOLD` per `7730 display` +formatter output) should require a fresh K11 assertion in addition to K10. + +**Wiring (separate issue):** + +- Broker `handlers/cap.rs` adds an `intent_requires_k11` policy hook. +- ScopeContract on chain stores per-(operator, agent) signing policy + (max tokenAmount per service, allow-listed verifyingContract set). +- Daemon's localhost proxy triggers the K11 ceremony when the policy hook + fires. + +Tracked separately as a follow-up to this PR because the ScopeContract +extension is non-trivial. + +--- + +## What ships in THIS PR (scope lock) + +| Phase | Status | Notes | +|---|---|---| +| Plan refresh (this doc) | ✅ | Replaces stale #82 body | +| signer-protocol.md update | ✅ | `/dev/sign-typed-data` documented | +| arch.md §14.2 + §15.3 + §22 update | ✅ | New endpoint + intent commitment + clear-signing pluggable surface | +| Phase 1 — EIP-712 signing | ✅ | `dev_key_service.sign_eip712` + handler + signer_client method + tests | +| Phase 2 — clear_signing module | ✅ | Parser + formatter + binding + 1 bundled fixture (USDC permit) | +| Phase 3 — CLI preview + sign-typed | ✅ | Two new `agentkeys signer ...` subcommands | +| Phase 4 — audit intent schema | ✅ (docs only) | Schema in arch.md §15.3; broker/worker wiring deferred | +| Phase 5 — K11-on-high-value | ❌ (separate issue) | Needs ScopeContract extension | + +## What does NOT ship in this PR + +- **K11 binding on high-value signs (Phase 5).** Needs ScopeContract revision; tracked as follow-up. +- **Broker cap-mint policy gate.** Tracked as follow-up; the cap-mint endpoint will eventually gate sign requests against `intent_commitment` but the broker side stays unchanged in this PR (daemon → signer goes direct via `signer_client`). +- **Worker audit-row wiring.** Schema is documented; worker reads of new fields will land when the follow-up Phase 4 wiring PR ships. Today's worker silently ignores them (forward-compatible). +- **On-chain CredentialAudit event extension.** Needs contract revision + redeploy; tracked separately. +- **Registry fetch (v1).** Follow-up issue; v0 catalog is bundled-only. +- **EIP-4337 UserOp clear signing.** Out of scope per original #82. +- **FHE / encrypted-field support.** Out of scope per original #82. diff --git a/docs/spec/signer-protocol.md b/docs/spec/signer-protocol.md index b9abe0f..f539f79 100644 --- a/docs/spec/signer-protocol.md +++ b/docs/spec/signer-protocol.md @@ -10,12 +10,17 @@ implementation diverges, the daemon stops working. The signer is the trust boundary that owns the EVM keypair derived from a user's `omni_account`. The daemon never holds private key material; it asks -the signer for two things only: +the signer for three things only: 1. The 0x-address derived from a given `omni_account` (so the daemon knows what to `link` against the broker). 2. An EIP-191 ECDSA signature over an arbitrary message produced under that same derived key (so the daemon can complete the broker's SIWE round-trip). +3. An EIP-712 typed-data signature over a structured data object (so agents + can sign Permit / Permit2 / DEX orders / EIP-4337 UserOps / Heima extrinsic + envelopes under their per-actor K4 wallet, with the signer parsing the + typed-data JSON internally — never trusting a caller-supplied prehash). + This endpoint is added in issue #82. Issue #74 step 1 ships an HKDF-backed implementation in `agentkeys-mock-server` (`/dev/*` endpoints, gated by `DEV_KEY_SERVICE_MASTER_SECRET`). Issue #74 @@ -116,6 +121,91 @@ SIWE message UTF-8-encoded as hex; the signer MUST NOT interpret content. | 503 | `signer_disabled` | Same as `/dev/derive-address` | | 500 | `internal` | Unexpected — bug | +### `POST /dev/sign-typed-data` + +Added in issue #82. EIP-712 v4 typed-data signing. The signer parses the +typed-data JSON itself and computes the digest internally — it never trusts +a caller-supplied prehash. This is what makes the signer's signature a +meaningful claim about *what was signed*, not just *that something was +signed*. + +#### Request + +```json +{ + "omni_account": "<64 lowercase hex chars>", + "typed_data": { + "domain": { + "name": "", + "version": "", + "chainId": , + "verifyingContract": "0x<40 hex>, optional", + "salt": "0x<64 hex>, optional" + }, + "types": { + "EIP712Domain": [ { "name": "...", "type": "..." }, ... ], + "": [ { "name": "...", "type": "..." }, ... ], + "": [ ... ] + }, + "primaryType": "", + "message": { /* values for primaryType fields */ } + } +} +``` + +Type-string subset supported in v0: + +- `string`, `bytes`, `bool`, `address` (20 bytes) +- `uint8` / `uint16` / `uint24` / `uint32` / `uint40` / `uint48` / `uint56` / + `uint64` / `uint72` / ... / `uint256` (all uint sizes in 8-bit increments) +- `int8` ... `int256` (all int sizes in 8-bit increments) +- `bytes1` ... `bytes32` (all fixed-byte sizes) +- Static arrays `[N]` and dynamic arrays `[]` of any of the + above (including struct arrays) +- Nested struct types defined in `types` + +`EIP712Domain` MUST be present in `types`. The fields used from `domain` +are determined by `types.EIP712Domain` (operator may omit `chainId` if +their domain does not include it, etc.). + +#### Response — 200 OK + +```json +{ + "signature": "0x<130 lowercase hex chars>", + "address": "0x<40 lowercase hex chars>", + "primary_type_hash": "0x<64 lowercase hex chars>", + "domain_separator": "0x<64 lowercase hex chars>", + "digest": "0x<64 lowercase hex chars>", + "key_version": 1 +} +``` + +* `signature` is 65 bytes encoded as `0x` + 130 hex chars: `r(32) || s(32) || v(1)`. + `v` is normalized to `{0, 1}` (same canonicalization as `/dev/sign-message`). +* `address` MUST equal the address `/dev/derive-address` returned for the + same `omni_account`. +* `primary_type_hash` is `keccak256(encodeType(primaryType))` — useful for + audit-row cross-reference against an ERC-7730 metadata file pinned to the + same type hash. +* `domain_separator` is `keccak256(encodeData(EIP712Domain, domain))` — also + useful for audit cross-reference and for verifying that the signer parsed + the domain the way the caller expected. +* `digest` is the final EIP-712 digest the signature was produced over: + `keccak256("\x19\x01" || domain_separator || hashStruct(primaryType, message))`. +* `key_version` is the HKDF derivation domain (see "Versioned derivation" + below). + +#### Errors + +| HTTP | `error` value | Meaning | +|---|---|---| +| 400 | `invalid_omni_account` | `omni_account` missing, wrong length, non-hex | +| 400 | `invalid_typed_data` | `typed_data` malformed: missing `domain` / `types` / `primaryType` / `message`, unknown type in `types`, type field references a struct not defined in `types`, unsupported type-string subset, value out of range for declared type | +| 401 | `unauthorized` | Bearer JWT missing, expired, or `omni_account` mismatch (when JWT auth is enabled) | +| 503 | `signer_disabled` | Same as `/dev/derive-address` | +| 500 | `internal` | Unexpected — bug | + ## Error envelope All non-2xx responses share the shape: @@ -231,6 +321,6 @@ If you add a new signer backend, add it to that conformance suite. --- -**Last reviewed:** issue #74 step 1, 2026-05-08. +**Last reviewed:** issue #82 (ERC-7730 + EIP-712 endpoint), 2026-05-21. **Owner:** the signer-edge crate (currently `agentkeys-mock-server::dev_key_service`, post-step-2 `agentkeys-tee-worker`). From 98701112c36123290171aa0c246fa1eb9de45e98 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 08:44:10 +0800 Subject: [PATCH 02/15] =?UTF-8?q?issue=20#97:=20arch.md=20=C2=A715.3a=20?= =?UTF-8?q?=E2=80=94=20AuditEnvelope=20v1=20canonical=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` 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. --- docs/spec/architecture.md | 170 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 507e196..1c4060f 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -886,6 +886,176 @@ S3 row). A future contract revision will extend `CredentialAudit.append` to take the commitment hash as a 33rd byte; until then, tier C chain events index the audit-row by `signed_intent_hash` via S3 path. +### 15.3a Unified audit envelope — `AuditEnvelope v1` + +The schema documented above (`signed_intent_text` + `signed_intent_hash`) is +specific to **typed-data signs**. The rest of the audit surface today +carries only the narrow `(actor_omni, service_hash, op_type ∈ {0,1,2}, payload_hash)` +shape that [`CredentialAudit.sol`](../../crates/agentkeys-chain/src/CredentialAudit.sol) +takes — sufficient for credentials CRUD, useless for sign events, scope +mutations, device mutations, payments, memory ops, or email. An external +explorer (e.g. [`litentry/subscan-essentials`](https://github.com/litentry/subscan-essentials) +per §22a.6) wanting to render a uniform timeline across all audit-producing +surfaces has to know N different shapes today. + +`AuditEnvelope v1` is the canonical abstract format that every audit-producing +surface MUST emit going forward, and that the chain + explorer + indexer +consume. + +#### Wire shape (off-chain, served by `agentkeys-worker-audit`) + +``` +AuditEnvelope { + version: u8, // = 1 + ts_unix: u64, // server-side at queue time + actor_omni: [u8; 32], // who performed the op + operator_omni: [u8; 32], // whose data-class boundary it touched + op_kind: u8, // see canonical table below + op_body: CBOR_bytes, // op-kind-specific (opaque to chain + old indexers) + result: u8, // 0=Success, 1=Failure, 2=NotPermitted + intent_text: Option, // operator-readable (PR #95) + intent_commitment: Option<[u8; 32]>, // keccak256(intent_text || 0x7c || op_payload_digest) +} +``` + +Encoded canonically as deterministic CBOR (CTAP2 / RFC 8949 §4.2.1). The +worker computes `envelope_hash = keccak256(canonical_cbor(envelope))` and +exposes: + +- `POST /v1/audit/append` — accept envelope, queue, return `envelope_hash`. +- `GET /v1/audit/envelope/` — return the full envelope (used by the + explorer to fetch the body after seeing the on-chain hash). + +#### On-chain commitment + +`CredentialAudit.appendV2(operatorOmni, actorOmni, opKind, envelopeHash)` +lands alongside the v1 `append` shape (additive — no break). For tier A +(Merkle batched), `appendRootV2(operatorOmni, merkleRoot, opKindBitmap)` +carries an `opKindBitmap` (`bytes32`, each bit indexes one of 256 possible +op_kinds present in the batch) so explorers can filter without fetching +every leaf. + +Events: + +``` +event AuditAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed actorOmni, + uint8 indexed opKind, + bytes32 envelopeHash, + uint256 entryIndex +); + +event AuditRootAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed merkleRoot, + bytes32 opKindBitmap, + uint256 rootIndex, + uint64 entryCount +); +``` + +The `indexed opKind` topic lets the explorer query "show all this operator's +typed-data signs in chain history" with a single `eth_getLogs` filter, +without scanning every audit row. + +#### Canonical `op_kind` byte assignments + +PRs adding new op_kinds MUST append a row here; **numbers are never reused +and never reordered**. Grouped by 10s leaves room for related ops. + +| Kind | Byte | `op_body` schema | Worker that emits | +|---|---|---|---| +| `CredStore` | 0 | `{service: string, payload_hash: [u8;32]}` | credentials-service | +| `CredFetch` | 1 | `{service: string, cap_hash: [u8;32]}` | credentials-service | +| `CredTeardown` | 2 | `{actor_target: [u8;32]}` | credentials-service | +| `MemoryPut` | 10 | `{key: string, payload_hash: [u8;32]}` | memory-service | +| `MemoryGet` | 11 | `{key: string, cap_hash: [u8;32]}` | memory-service | +| `MemoryTeardown` | 12 | `{actor_target: [u8;32]}` | memory-service | +| `SignEip191` | 20 | `{message_digest: [u8;32], wallet: [u8;20]}` | signer (via daemon callback) | +| `SignEip712` | 21 | `{chain_id: u64, verifying_contract: [u8;20], primary_type: string, type_hash: [u8;32], domain_separator: [u8;32], digest: [u8;32]}` | signer (via daemon callback) | +| `PaymentEscrowRedeem` | 30 | `{escrow_addr: [u8;20], amount: U256, recipient: [u8;20], chain_id: u64}` | payment-service (P-2 mode) | +| `PaymentDirect` | 31 | `{rail: enum, ref: string, amount_minor: u64, currency: string}` | payment-service (P-1/P-3) | +| `ScopeGrant` | 40 | `{agent_omni: [u8;32], service: string, max_calls: u32, max_amount: U256}` | broker (via callback) | +| `ScopeRevoke` | 41 | `{agent_omni: [u8;32], service: string}` | broker (via callback) | +| `DeviceAdd` | 50 | `{device_key_hash: [u8;32], role_bits: u8, attestation_hash: [u8;32]}` | SidecarRegistry hook | +| `DeviceRevoke` | 51 | `{device_key_hash: [u8;32]}` | SidecarRegistry hook | +| `K10Rotate` | 52 | `{old_device_key_hash: [u8;32], new_device_key_hash: [u8;32]}` | SidecarRegistry hook | +| `EmailSend` | 60 | `{to_hash: [u8;32], subject_hash: [u8;32], message_id: string}` | email-service | +| `EmailReceive` | 61 | `{from_hash: [u8;32], message_id: string, payload_hash: [u8;32]}` | email-service | +| `K3EpochAdvance` | 70 | `{old_epoch: u64, new_epoch: u64, gov_tx: [u8;32]}` | K3EpochCounter hook | + +Byte ranges `8-9`, `13-19`, `22-29`, `32-39`, `42-49`, `53-59`, `62-69`, `71-79`, `80-255` are reserved for future extensions in the same family. + +#### Forward-compat / non-break design + +The trade-off when a new op_kind lands is **"uglier UI temporarily for old +explorers" — never "broken explorer / dropped event"**. Eight design +invariants make this work: + +1. **`op_kind` is a `u8`, not a sealed enum.** Indexers/explorers MUST treat + unknown values as `Unknown(byte)` with a generic fallback renderer. + Panicking, dropping, or 5xx-ing on an unknown op_kind is a bug, not + correct behavior. + +2. **Envelope-level fields are stable across all op_kinds.** CBOR-decoding + `(version, ts_unix, actor_omni, operator_omni, op_kind, intent_text, + intent_commitment, result)` works for **any** op_kind. Only `op_body` is + op-kind-specific. The explorer can ALWAYS render a meaningful row from + envelope-level fields, even if it can't decode the body. + +3. **`version` is gated on envelope-level breakage only.** Bump `version` + when the top-level fields change (adding a required field, removing + one). Adding a new op_kind does NOT bump version. Old indexers seeing + `version: 1` keep working; `version: 2` they skip with a "needs + upgrade" log line. + +4. **Explorer ships a generic fallback renderer.** Default UI for unknown + op_kind: shows the op_kind byte + actor + operator + timestamp + + `intent_text` (if present) + a "raw body" expander. New op_kinds never + break the timeline page — they just look generic until the explorer + ships a kind-specific renderer. + +5. **Worker passes through opaque `op_body` bytes.** Older workers that + don't recognize a new op_kind variant still know to forward the CBOR + blob untouched in `GET /v1/audit/envelope`. Indexers consuming the + JSON get `op_body` as base64-encoded opaque bytes (with `intent_text` + + `intent_commitment` still readable from envelope level). + +6. **Chain contract is op_kind-agnostic.** `appendV2` takes `opKind` as + `uint8` and `envelopeHash` as `bytes32`. No on-chain decode of + `op_body`. New op_kinds need ZERO contract redeploys. + +7. **Canonical op_kind table lives in arch.md.** PRs adding new op_kinds + MUST append a row to the table above. Numbers never reused and never + reordered. Reviewer can grep arch.md for the new byte to confirm it's + not a collision before merging. + +8. **Test contract per new op_kind.** Every PR adding an op_kind ships + THREE tests minimum: + - **Worker**: CBOR encode + decode roundtrip on canonical fixtures. + - **Explorer**: "old explorer + envelope with new op_kind → + graceful unknown render, no crash, no dropped event." + - **Doc**: arch.md table row appended; no number collision. + +#### Migration sequencing + +| Phase | Where | What lands | Backwards-compat property | +|---|---|---|---| +| A | `arch.md` (this section) | The schema + table + non-break invariants. **Lands in PR #95.** | None — doc only. | +| B | `agentkeys-worker-audit` + `agentkeys-core` | New `AuditEnvelope` struct; existing call sites migrated to emit it; `/v1/audit/envelope/` endpoint; old `AuditEvent` retained for one cycle. | Old indexers using `/v1/audit/append` v1 shape keep working; envelope-level fields readable from the new endpoint. | +| C | `crates/agentkeys-chain/src/CredentialAudit.sol` | `appendV2(operatorOmni, actorOmni, opKind, envelopeHash)` + `appendRootV2(... opKindBitmap)` + the two events. Contract redeploy on Heima Mainnet. **Old `append` and `appendRoot` retained on the same contract**, so existing indexers keep working until they migrate. | Old `AuditAppended` event still emitted by `append` callers; new indexers watch `AuditAppendedV2`. | +| D | [`litentry/subscan-essentials`](https://github.com/litentry/subscan-essentials) — tracked as [subscan-essentials#12](https://github.com/litentry/subscan-essentials/issues/12) | Decoder for `AuditAppendedV2` + `AuditRootAppendedV2` events; HTTP client to fetch `GET /v1/audit/envelope/` from the worker; per-op_kind renderer plug-in interface. | Old `AuditAppended` decoder retained. | +| E | [`litentry/subscan-essentials-ui-react`](https://github.com/litentry/subscan-essentials-ui-react) | Per-op_kind renderer components + the generic `Unknown(byte)` fallback. Routes `/agentkeys/audit/` use the V2 envelope feed. | Old route shapes preserved. | +| F | Sign / scope / device / payment / email / K3 worker call sites | Each emits its own op_kind via `AuditEnvelope`; the bytes are claimed via PRs that each touch the table in arch.md exactly once. | None — each row is additive. | + +Phases B / C / F are tracked at [agentKeys#97](https://github.com/litentry/agentKeys/issues/97). +Phases D / E are tracked at [subscan-essentials#12](https://github.com/litentry/subscan-essentials/issues/12). + +Phases B-E are **independent** once A lands — they can ship in parallel +across the three repos. Phase A is the lock-in moment; everything else +follows the canonical table. + ### 15.4 email-service - **IAM:** `ses:SendRawEmail` from operator's domain (e.g., `bots.litentry.org`); `s3:GetObject` + `s3:PutObject` on `bots//{inbound,sent}/*` From 7738166071aa0afcbcd15694ddd17bc4e302637d Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 08:57:35 +0800 Subject: [PATCH 03/15] issue #97 phase B: AuditEnvelope v1 struct + worker V2 endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>>` — 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/.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). --- Cargo.lock | 4 + crates/agentkeys-core/src/audit/bodies.rs | 248 +++++++++++ crates/agentkeys-core/src/audit/cbor.rs | 350 +++++++++++++++ crates/agentkeys-core/src/audit/mod.rs | 418 ++++++++++++++++++ crates/agentkeys-core/src/audit/op_kind.rs | 174 ++++++++ crates/agentkeys-core/src/lib.rs | 1 + crates/agentkeys-worker-audit/Cargo.toml | 5 + crates/agentkeys-worker-audit/src/handlers.rs | 203 ++++++++- crates/agentkeys-worker-audit/src/lib.rs | 20 + crates/agentkeys-worker-audit/src/main.rs | 4 + crates/agentkeys-worker-audit/src/state.rs | 33 +- .../tests/envelope_v2.rs | 170 +++++++ 12 files changed, 1627 insertions(+), 3 deletions(-) create mode 100644 crates/agentkeys-core/src/audit/bodies.rs create mode 100644 crates/agentkeys-core/src/audit/cbor.rs create mode 100644 crates/agentkeys-core/src/audit/mod.rs create mode 100644 crates/agentkeys-core/src/audit/op_kind.rs create mode 100644 crates/agentkeys-worker-audit/tests/envelope_v2.rs diff --git a/Cargo.lock b/Cargo.lock index fa19163..0eabf89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,16 +262,20 @@ dependencies = [ name = "agentkeys-worker-audit" version = "0.1.0" dependencies = [ + "agentkeys-core", "anyhow", "axum", + "ciborium", "clap", "hex", + "http-body-util", "reqwest", "serde", "serde_json", "sha3", "thiserror", "tokio", + "tower 0.4.13", "tracing", "tracing-subscriber", ] diff --git a/crates/agentkeys-core/src/audit/bodies.rs b/crates/agentkeys-core/src/audit/bodies.rs new file mode 100644 index 0000000..a7cb601 --- /dev/null +++ b/crates/agentkeys-core/src/audit/bodies.rs @@ -0,0 +1,248 @@ +//! Per-op_kind `op_body` schemas (arch.md §15.3a canonical table). +//! +//! These are the **typed** views of `op_body` that builds of the code +//! recognizing the op_kind can decode into. The envelope's actual +//! `op_body` field is a `ciborium::Value` — unknown op_kinds keep it as +//! opaque CBOR so old readers don't break (non-break invariant #4). +//! +//! Hex-byte fields use the `0x` string form in JSON for human +//! readability. CBOR encoding of these structs (via `ciborium`) preserves +//! the same JSON-shape — keys are text, values are text/integer per the +//! `serde` derives below. + +use serde::{Deserialize, Serialize}; + +// ── 0..9 — creds family ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CredStoreBody { + /// Service name (e.g., `"openrouter"`). Free-form string per arch.md + /// §17.5 — the worker uses this verbatim as the S3 object key suffix. + pub service: String, + /// `keccak256(envelope_ciphertext)` — proves the worker stored the + /// exact bytes the auditor can later verify. + pub payload_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CredFetchBody { + pub service: String, + /// `keccak256(cap_token_canonical_bytes)` — binds the audit row to + /// the cap-token that authorized the fetch. Auditors looking at "who + /// read service X at time T" can cross-reference against the broker's + /// cap-mint log. + pub cap_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CredTeardownBody { + /// 32-byte hex (`0x<64 hex>`). The actor whose credentials were torn + /// down — distinct from the actor performing the teardown (which is + /// envelope-level `actor_omni`). + pub actor_target: String, +} + +// ── 10..19 — memory family ───────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryPutBody { + pub key: String, + pub payload_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryGetBody { + pub key: String, + pub cap_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryTeardownBody { + pub actor_target: String, +} + +// ── 20..29 — signs family ────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SignEip191Body { + /// `keccak256("\x19Ethereum Signed Message:\n" || message)` — + /// the digest the signer signed over. Auditor verifies the signature + /// against this digest + the signer's known address. + pub message_digest: String, + /// 20-byte EVM address (`0x<40 hex>`) — the K4-derived wallet that + /// produced the signature. + pub wallet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SignEip712Body { + /// Chain ID from `typed_data.domain.chainId`. `0` if absent. + pub chain_id: u64, + /// 20-byte EVM address (`0x<40 hex>`). The contract this sign is + /// scoped to. `0x0000…0000` if not in domain. + pub verifying_contract: String, + /// `typed_data.primaryType` — the struct name (e.g. `"Permit"`). + pub primary_type: String, + /// `keccak256(encodeType(primary_type))` — useful for explorers to + /// match against an ERC-7730 metadata file pinned to the same type + /// hash. + pub type_hash: String, + /// `keccak256(encodeData(EIP712Domain, domain))` — the EIP-712 + /// domain separator. + pub domain_separator: String, + /// `keccak256("\x19\x01" || domain_separator || hashStruct(primary, + /// message))` — the final EIP-712 digest signed. + pub digest: String, +} + +// ── 30..39 — payments family ─────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaymentEscrowRedeemBody { + /// Escrow contract address (`0x<40 hex>`). + pub escrow_addr: String, + /// Amount in the chain's native units — string-encoded to support + /// U256 (JSON numbers max out at i53 safe). + pub amount: String, + /// Recipient address (`0x<40 hex>`). + pub recipient: String, + pub chain_id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaymentDirectBody { + /// Rail label (e.g. `"stripe"`, `"usdc"`, `"sol"`, `"fiat"`). + pub rail: String, + /// Provider-side reference (e.g. Stripe charge ID, USDC tx hash). + pub r#ref: String, + /// Amount in the smallest unit of the currency (cents for USD, + /// satoshi for BTC, etc.). + pub amount_minor: u64, + /// ISO-4217 (USD, EUR) or token symbol (USDC, BTC). + pub currency: String, +} + +// ── 40..49 — scope family ────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScopeGrantBody { + /// 32-byte hex — the agent whose scope was just granted. + pub agent_omni: String, + /// Service name the scope authorizes. + pub service: String, + /// Per-cap max-call cap configured on the grant. `0` = unlimited. + pub max_calls: u32, + /// Per-cap max-amount cap (string-encoded U256) for spend-bounded + /// scopes. `"0"` = unlimited. + pub max_amount: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScopeRevokeBody { + pub agent_omni: String, + pub service: String, +} + +// ── 50..59 — device family ───────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeviceAddBody { + /// `keccak256(K10_pubkey || 0x01)` — the on-chain device identifier + /// per arch.md §10.1. + pub device_key_hash: String, + /// Bitfield of CAP_MINT=1, RECOVERY=2, SCOPE_MGMT=4 (arch.md §10.1). + pub role_bits: u8, + /// `keccak256(WebAuthn attestation object)` — empty hash if the + /// add is the bootstrap (first master) where no prior K11 exists. + pub attestation_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeviceRevokeBody { + pub device_key_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct K10RotateBody { + pub old_device_key_hash: String, + pub new_device_key_hash: String, +} + +// ── 60..69 — email family ────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EmailSendBody { + /// `keccak256(to_address.as_bytes())` — hashed for privacy at the + /// audit-row layer. Original address available via the email-service + /// worker's S3 `sent/` log under the same `message_id`. + pub to_hash: String, + pub subject_hash: String, + /// SES `MessageId`. + pub message_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EmailReceiveBody { + pub from_hash: String, + pub message_id: String, + /// `keccak256(MIME-encoded message bytes)`. + pub payload_hash: String, +} + +// ── 70..79 — K3 family ───────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct K3EpochAdvanceBody { + pub old_epoch: u64, + pub new_epoch: u64, + /// `keccak256(governance multisig tx canonical bytes)` — the on-chain + /// proof of authorization to advance the epoch. + pub gov_tx: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every body struct deserializes from the JSON shape its `serde` + /// fields imply. Catches accidental field renames or type drift + /// against the arch.md canonical table. + #[test] + fn cred_store_body_deserializes() { + let json = serde_json::json!({ + "service": "openrouter", + "payload_hash": "0xabcd1234", + }); + let body: CredStoreBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.service, "openrouter"); + } + + #[test] + fn sign_eip712_body_carries_all_digests() { + let json = serde_json::json!({ + "chain_id": 1, + "verifying_contract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "primary_type": "Permit", + "type_hash": "0x".to_string() + &"de".repeat(32), + "domain_separator": "0x".to_string() + &"ad".repeat(32), + "digest": "0x".to_string() + &"be".repeat(32), + }); + let body: SignEip712Body = serde_json::from_value(json).unwrap(); + assert_eq!(body.chain_id, 1); + assert_eq!(body.primary_type, "Permit"); + } + + #[test] + fn payment_direct_body_uses_ref_as_field_name() { + // Sanity check: `ref` is a Rust reserved word, so the field is + // `r#ref` in code; JSON sees plain `"ref"` per the serde derive. + let json = serde_json::json!({ + "rail": "usdc", + "ref": "0xabc", + "amount_minor": 1_000_000, + "currency": "USDC", + }); + let body: PaymentDirectBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.r#ref, "0xabc"); + } +} diff --git a/crates/agentkeys-core/src/audit/cbor.rs b/crates/agentkeys-core/src/audit/cbor.rs new file mode 100644 index 0000000..f0709a3 --- /dev/null +++ b/crates/agentkeys-core/src/audit/cbor.rs @@ -0,0 +1,350 @@ +//! Canonical CBOR encoding of [`AuditEnvelope`] for chain commitment + +//! cross-encoder stability. +//! +//! ## Why canonical +//! +//! `envelope_hash = keccak256(canonical_cbor(envelope))` lands on chain. +//! Any non-determinism in the encoding (e.g. arbitrary map key order) +//! would mean the same logical envelope produces different bytes and +//! different hashes across encoders — auditors comparing the chain +//! commitment against a freshly re-encoded envelope would see false +//! mismatches. +//! +//! ## What this enforces +//! +//! Per RFC 8949 §4.2.1, deterministic encoding requires: +//! +//! 1. Integers in the shortest form their value allows. +//! 2. Floats in the shortest form (we don't use floats — envelope-level +//! is all u8/u64/strings/bytes). +//! 3. Strings/bytes use the indefinite-length form only when required +//! (we always use definite-length). +//! 4. Map keys sorted by their canonical CBOR encoding (length-then- +//! lexicographic, per §4.2.3). +//! +//! `ciborium` provides definite-length + shortest-form encoding by +//! default. The map-key ordering is the only point this module needs to +//! enforce explicitly — we build the envelope as an ordered `Vec<(key, +//! Value)>` and emit it as a CBOR map with keys already sorted. +//! +//! ## Wire format +//! +//! The envelope is a single CBOR map with these keys (sorted by canonical +//! CBOR ordering of the text keys): +//! +//! ```text +//! { +//! "actor_omni": h'...', # 32 raw bytes +//! "intent_commitment": h'...' | null, # 32 raw bytes or null +//! "intent_text": "..." | null, # UTF-8 string or null +//! "op_body": { ... }, # op-kind-specific CBOR +//! "op_kind": uint, # 0..255 +//! "operator_omni": h'...', # 32 raw bytes +//! "result": uint, # 0..255 (AuditResult) +//! "ts_unix": uint, # u64 +//! "version": uint # u8 +//! } +//! ``` +//! +//! Key ordering note: under RFC 8949 §4.2.3, sorting is by **lexicographic +//! comparison of the encoded bytes**, NOT the decoded text. For 9 short +//! ASCII text keys this happens to equal lexicographic-by-text, so the +//! ordering above is correct. If we ever add a longer key, re-derive the +//! order via the algorithm in §4.2.3. + +use ciborium::Value; + +use super::{AuditEnvelope, AuditError, AuditResult, ENVELOPE_VERSION}; + +pub fn encode_canonical(env: &AuditEnvelope) -> Result, AuditError> { + let map = Value::Map(vec![ + (Value::Text("actor_omni".into()), Value::Bytes(env.actor_omni.to_vec())), + ( + Value::Text("intent_commitment".into()), + match env.intent_commitment { + Some(c) => Value::Bytes(c.to_vec()), + None => Value::Null, + }, + ), + ( + Value::Text("intent_text".into()), + match &env.intent_text { + Some(t) => Value::Text(t.clone()), + None => Value::Null, + }, + ), + (Value::Text("op_body".into()), env.op_body.clone()), + (Value::Text("op_kind".into()), Value::Integer(env.op_kind.into())), + (Value::Text("operator_omni".into()), Value::Bytes(env.operator_omni.to_vec())), + (Value::Text("result".into()), Value::Integer((env.result as u8).into())), + (Value::Text("ts_unix".into()), Value::Integer(env.ts_unix.into())), + (Value::Text("version".into()), Value::Integer(env.version.into())), + ]); + + let mut out = Vec::with_capacity(256); + ciborium::into_writer(&map, &mut out) + .map_err(|e| AuditError::Cbor(format!("encode: {e}")))?; + Ok(out) +} + +pub fn decode_canonical(bytes: &[u8]) -> Result { + let value: Value = ciborium::from_reader(bytes) + .map_err(|e| AuditError::Cbor(format!("decode: {e}")))?; + + let map = match value { + Value::Map(m) => m, + other => return Err(AuditError::Invalid(format!("expected CBOR map, got {other:?}"))), + }; + + let mut actor_omni: Option<[u8; 32]> = None; + let mut operator_omni: Option<[u8; 32]> = None; + let mut op_kind: Option = None; + let mut op_body: Option = None; + let mut result: Option = None; + let mut ts_unix: Option = None; + let mut version: Option = None; + let mut intent_text: Option> = None; + let mut intent_commitment: Option> = None; + + for (k, v) in map { + let key = match k { + Value::Text(s) => s, + other => return Err(AuditError::Invalid(format!("map key must be text, got {other:?}"))), + }; + match key.as_str() { + "actor_omni" => actor_omni = Some(bytes_32(&v, "actor_omni")?), + "operator_omni" => operator_omni = Some(bytes_32(&v, "operator_omni")?), + "op_kind" => op_kind = Some(byte(&v, "op_kind")?), + "op_body" => op_body = Some(v), + "result" => { + let b = byte(&v, "result")?; + result = Some(match b { + 0 => AuditResult::Success, + 1 => AuditResult::Failure, + 2 => AuditResult::NotPermitted, + other => { + return Err(AuditError::Invalid(format!( + "unknown AuditResult byte: {other}" + ))) + } + }); + } + "ts_unix" => ts_unix = Some(uint64(&v, "ts_unix")?), + "version" => version = Some(byte(&v, "version")?), + "intent_text" => { + intent_text = Some(match v { + Value::Null => None, + Value::Text(s) => Some(s), + other => { + return Err(AuditError::Invalid(format!( + "intent_text must be text or null, got {other:?}" + ))) + } + }); + } + "intent_commitment" => { + intent_commitment = Some(match v { + Value::Null => None, + other => Some(bytes_32(&other, "intent_commitment")?), + }); + } + other => { + // Unknown envelope-level key — preserve forward-compat per + // invariant #2: ignore quietly. (A future ENVELOPE_VERSION + // bump would add new known keys; we already rejected + // version > ENVELOPE_VERSION earlier.) + let _ = other; + } + } + } + + let version = version.ok_or_else(|| AuditError::Invalid("missing version".into()))?; + if version != ENVELOPE_VERSION { + return Err(AuditError::Invalid(format!( + "unsupported envelope version: {version} (this code supports {ENVELOPE_VERSION})" + ))); + } + + Ok(AuditEnvelope { + version, + ts_unix: ts_unix.ok_or_else(|| AuditError::Invalid("missing ts_unix".into()))?, + actor_omni: actor_omni.ok_or_else(|| AuditError::Invalid("missing actor_omni".into()))?, + operator_omni: operator_omni + .ok_or_else(|| AuditError::Invalid("missing operator_omni".into()))?, + op_kind: op_kind.ok_or_else(|| AuditError::Invalid("missing op_kind".into()))?, + op_body: op_body.ok_or_else(|| AuditError::Invalid("missing op_body".into()))?, + result: result.ok_or_else(|| AuditError::Invalid("missing result".into()))?, + intent_text: intent_text.unwrap_or(None), + intent_commitment: intent_commitment.unwrap_or(None), + }) +} + +fn bytes_32(v: &Value, label: &str) -> Result<[u8; 32], AuditError> { + match v { + Value::Bytes(b) if b.len() == 32 => { + let mut out = [0u8; 32]; + out.copy_from_slice(b); + Ok(out) + } + Value::Bytes(b) => Err(AuditError::Invalid(format!( + "{label} must be 32 bytes, got {}", + b.len() + ))), + other => Err(AuditError::Invalid(format!( + "{label} must be CBOR bytes, got {other:?}" + ))), + } +} + +fn byte(v: &Value, label: &str) -> Result { + let n = uint64(v, label)?; + if n > u8::MAX as u64 { + return Err(AuditError::Invalid(format!( + "{label}: value {n} exceeds u8 range" + ))); + } + Ok(n as u8) +} + +fn uint64(v: &Value, label: &str) -> Result { + match v { + Value::Integer(i) => { + let as_i128: i128 = (*i).into(); + if as_i128 < 0 { + return Err(AuditError::Invalid(format!( + "{label}: negative integer {as_i128}" + ))); + } + if as_i128 > u64::MAX as i128 { + return Err(AuditError::Invalid(format!( + "{label}: value {as_i128} exceeds u64 range" + ))); + } + Ok(as_i128 as u64) + } + other => Err(AuditError::Invalid(format!( + "{label} must be integer, got {other:?}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::AuditOpKind; + + /// Two envelopes with identical content produce IDENTICAL bytes. + /// This is the cross-encoder-stability property — without it the + /// chain commitment would drift across encoder implementations. + #[test] + fn canonical_cbor_is_byte_stable() { + let env = AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 12345, + actor_omni: [0x11; 32], + operator_omni: [0x22; 32], + op_kind: AuditOpKind::SignEip712 as u8, + op_body: Value::Map(vec![ + (Value::Text("chain_id".into()), Value::Integer(1.into())), + ( + Value::Text("primary_type".into()), + Value::Text("Permit".into()), + ), + ]), + result: AuditResult::Success, + intent_text: Some("test".into()), + intent_commitment: Some([0xcc; 32]), + }; + + let a = encode_canonical(&env).unwrap(); + let b = encode_canonical(&env).unwrap(); + assert_eq!(a, b, "same input must produce identical CBOR"); + } + + /// Round-trip: encode then decode reconstructs the same envelope. + #[test] + fn decode_roundtrip() { + let env = AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 1_700_000_000, + actor_omni: [0xaa; 32], + operator_omni: [0xbb; 32], + op_kind: AuditOpKind::CredFetch as u8, + op_body: Value::Map(vec![ + ( + Value::Text("service".into()), + Value::Text("openrouter".into()), + ), + ( + Value::Text("cap_hash".into()), + Value::Text("0xdeadbeef".into()), + ), + ]), + result: AuditResult::Success, + intent_text: None, + intent_commitment: None, + }; + + let bytes = encode_canonical(&env).unwrap(); + let decoded = decode_canonical(&bytes).unwrap(); + assert_eq!(env, decoded); + } + + /// Decoder rejects an unknown envelope version (invariant #3 — old + /// readers refuse to interpret a v2 envelope rather than silently + /// misinterpret). + #[test] + fn decoder_rejects_future_version() { + let mut env = AuditEnvelope { + version: 99, // future version this code doesn't know + ts_unix: 1, + actor_omni: [0; 32], + operator_omni: [0; 32], + op_kind: 0, + op_body: Value::Null, + result: AuditResult::Success, + intent_text: None, + intent_commitment: None, + }; + env.version = 99; + let bytes = encode_canonical(&env).unwrap(); + let err = decode_canonical(&bytes).unwrap_err(); + assert!(format!("{err}").contains("99")); + } + + /// Decoder ignores unknown envelope-level keys (forward-compat for a + /// future version that adds a top-level field; a v1 decoder reading a + /// future envelope still gets the v1 fields back). This test crafts + /// a v1 envelope with an extra `future_key` and confirms the decoder + /// returns the v1 fields cleanly. + #[test] + fn decoder_ignores_unknown_envelope_keys() { + // Build a CBOR map manually with an extra key. + let env = AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 1, + actor_omni: [0xaa; 32], + operator_omni: [0xbb; 32], + op_kind: 0, + op_body: Value::Null, + result: AuditResult::Success, + intent_text: None, + intent_commitment: None, + }; + let mut bytes = encode_canonical(&env).unwrap(); + // Decode → re-encode with an extra key, then re-encode to bytes. + let mut map = match ciborium::from_reader::(bytes.as_slice()).unwrap() { + Value::Map(m) => m, + _ => panic!("expected map"), + }; + map.push(( + Value::Text("future_v2_key".into()), + Value::Integer(42.into()), + )); + bytes.clear(); + ciborium::into_writer(&Value::Map(map), &mut bytes).unwrap(); + + let decoded = decode_canonical(&bytes).unwrap(); + assert_eq!(decoded, env); + } +} diff --git a/crates/agentkeys-core/src/audit/mod.rs b/crates/agentkeys-core/src/audit/mod.rs new file mode 100644 index 0000000..7228fdb --- /dev/null +++ b/crates/agentkeys-core/src/audit/mod.rs @@ -0,0 +1,418 @@ +//! `AuditEnvelope v1` — unified audit message format (arch.md §15.3a, issue #97). +//! +//! Every audit-producing surface in AgentKeys (creds, memory, signer, +//! broker, payment-service, email-service, SidecarRegistry, K3EpochCounter) +//! emits a single canonical envelope shape so that: +//! +//! - The chain commits only `(opKind, envelopeHash)` — small, op-kind-agnostic, +//! no contract redeploy when a new op_kind lands. +//! - The off-chain worker (`agentkeys-worker-audit`) holds the full envelope, +//! addressed by hash. +//! - The explorer ([`litentry/subscan-essentials`](https://github.com/litentry/subscan-essentials/issues/12)) +//! reads the chain events, fetches envelopes by hash, and renders a uniform +//! timeline across all op_kinds. +//! +//! ## Non-break design +//! +//! Adding a new op_kind costs "uglier UI temporarily for old explorers" — +//! never "broken explorer / dropped event." Eight invariants enforced by +//! this module: +//! +//! 1. `op_kind` is a `u8`, NOT a sealed Rust enum. Decoders see an +//! `Unknown(byte)` variant for any byte not in the canonical table. +//! 2. Envelope-level fields are stable across all op_kinds. The +//! `AuditEnvelope` struct decodes `(version, ts_unix, actor_omni, +//! operator_omni, op_kind, intent_text, intent_commitment, result)` +//! for any op_kind — even one this code doesn't recognize. +//! 3. `version` is gated on envelope-level breakage only. Bumping +//! `version` is a coordinated migration; adding a new op_kind is not. +//! 4. The `op_body` is a `ciborium::Value`. Unknown body shapes are +//! preserved as opaque CBOR through encode/decode — caller decides +//! whether to attempt a typed decode. +//! 5. `canonical_cbor` is deterministic (RFC 8949 §4.2.1) so +//! `envelope_hash` is stable across encoders. +//! 6. The chain contract is op-kind-agnostic. +//! 7. The canonical op_kind table lives in arch.md §15.3a — this module's +//! constants must match. Reviewer greps both before merging a new +//! op_kind PR. +//! 8. Every new op_kind ships 3 tests: CBOR roundtrip + unknown-body +//! tolerance + arch.md row. +//! +//! See [`docs/spec/architecture.md`](../../../../docs/spec/architecture.md) +//! §15.3a for the canonical schema. + +pub mod bodies; +pub mod cbor; +pub mod op_kind; + +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; +use thiserror::Error; + +pub use bodies::{ + CredFetchBody, CredStoreBody, CredTeardownBody, DeviceAddBody, DeviceRevokeBody, + EmailReceiveBody, EmailSendBody, K10RotateBody, K3EpochAdvanceBody, MemoryGetBody, + MemoryPutBody, MemoryTeardownBody, PaymentDirectBody, PaymentEscrowRedeemBody, ScopeGrantBody, + ScopeRevokeBody, SignEip191Body, SignEip712Body, +}; +pub use op_kind::AuditOpKind; + +#[derive(Debug, Error)] +pub enum AuditError { + #[error("invalid_envelope: {0}")] + Invalid(String), + + #[error("cbor: {0}")] + Cbor(String), + + #[error("hex_decode: {0}")] + HexDecode(String), +} + +/// Envelope version. Bump ONLY when envelope-level fields change (adding, +/// removing, or changing the type of a top-level field). Adding a new +/// op_kind variant does NOT bump this — that's the whole point of the +/// open-enum design. +pub const ENVELOPE_VERSION: u8 = 1; + +/// Result of the audited operation. Open enum byte: future variants append +/// at the bottom; never reuse, never reorder. Per arch.md §15.3a. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuditResult { + Success = 0, + Failure = 1, + NotPermitted = 2, +} + +/// The canonical audit envelope. Every audit-producing surface emits one +/// of these. Encoding for chain commitment + worker storage is canonical +/// CBOR per RFC 8949 §4.2.1. +/// +/// ## Fields +/// +/// - `version`: `ENVELOPE_VERSION`. Decoders MUST refuse to process an +/// envelope with `version > known_max_version` and log "needs upgrade." +/// - `ts_unix`: server-side at queue time (the worker fills this if the +/// caller leaves it 0). +/// - `actor_omni`: who performed the operation. 32 raw bytes. +/// - `operator_omni`: whose data-class boundary the op touched. 32 bytes. +/// - `op_kind`: byte assignment per arch.md §15.3a canonical table. +/// - `op_body`: op-kind-specific. Opaque CBOR — readers that don't know +/// the op_kind keep it as a `ciborium::Value` and pass through. +/// - `result`: outcome of the operation. +/// - `intent_text`: optional operator-readable text. Set by PR #95 for +/// typed-data signs; arbitrary op_kinds may set this if there's a +/// meaningful human-readable intent. +/// - `intent_commitment`: optional `keccak256(intent_text || 0x7c || +/// op_payload_digest)`. Cryptographically binds the rendered intent +/// to the op payload. Auditors verifying the commitment re-render the +/// intent from the same source (e.g. an ERC-7730 file for sign ops) +/// and check the hash matches. +#[derive(Debug, Clone, PartialEq)] +pub struct AuditEnvelope { + pub version: u8, + pub ts_unix: u64, + pub actor_omni: [u8; 32], + pub operator_omni: [u8; 32], + pub op_kind: u8, + pub op_body: ciborium::Value, + pub result: AuditResult, + pub intent_text: Option, + pub intent_commitment: Option<[u8; 32]>, +} + +impl AuditEnvelope { + /// Encode the envelope as canonical CBOR (RFC 8949 §4.2.1). Suitable + /// for hashing — the resulting bytes are stable across encoder + /// implementations. + pub fn to_canonical_cbor(&self) -> Result, AuditError> { + cbor::encode_canonical(self) + } + + /// Decode an envelope from canonical CBOR. Unknown op_kinds keep + /// `op_body` as a `ciborium::Value` for the caller to inspect. + pub fn from_canonical_cbor(bytes: &[u8]) -> Result { + cbor::decode_canonical(bytes) + } + + /// `envelope_hash = keccak256(canonical_cbor(envelope))`. This is the + /// 32-byte commitment that lands on chain as the second arg to + /// `CredentialAudit.appendV2(...)`. + pub fn envelope_hash(&self) -> Result<[u8; 32], AuditError> { + let bytes = self.to_canonical_cbor()?; + let mut hasher = Keccak256::new(); + hasher.update(&bytes); + Ok(hasher.finalize().into()) + } + + /// Try to decode `op_body` as the typed shape associated with this + /// envelope's `op_kind`. Returns `None` if `op_kind` is unknown to + /// this build of the code — the caller renders a generic row in that + /// case (per non-break invariant #4). + pub fn typed_body(&self) -> Option { + TypedAuditBody::from_envelope(self) + } +} + +/// Helper: `keccak256(intent_text.as_bytes() || 0x7c || op_payload_digest)`. +/// The separator byte (`0x7c` = ASCII `|`) is a domain-separation token so +/// an adversary cannot construct an `intent_text` whose last byte fakes the +/// digest boundary. Mirrors [`clear_signing::commit_intent`]. +pub fn commit_intent(intent_text: &str, op_payload_digest: &[u8; 32]) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(intent_text.as_bytes()); + hasher.update([0x7c]); + hasher.update(op_payload_digest); + hasher.finalize().into() +} + +/// Typed view of `op_body` when this build of the code recognizes the +/// `op_kind`. Mirrors the canonical table in arch.md §15.3a. +#[derive(Debug, Clone, PartialEq)] +pub enum TypedAuditBody { + CredStore(CredStoreBody), + CredFetch(CredFetchBody), + CredTeardown(CredTeardownBody), + MemoryPut(MemoryPutBody), + MemoryGet(MemoryGetBody), + MemoryTeardown(MemoryTeardownBody), + SignEip191(SignEip191Body), + SignEip712(SignEip712Body), + PaymentEscrowRedeem(PaymentEscrowRedeemBody), + PaymentDirect(PaymentDirectBody), + ScopeGrant(ScopeGrantBody), + ScopeRevoke(ScopeRevokeBody), + DeviceAdd(DeviceAddBody), + DeviceRevoke(DeviceRevokeBody), + K10Rotate(K10RotateBody), + EmailSend(EmailSendBody), + EmailReceive(EmailReceiveBody), + K3EpochAdvance(K3EpochAdvanceBody), +} + +impl TypedAuditBody { + fn from_envelope(env: &AuditEnvelope) -> Option { + let kind = AuditOpKind::from_u8(env.op_kind)?; + // Round-trip through serde_json to leverage ciborium → Value → struct + // via the serde Deserialize impls on the body structs. Stable since + // both sides use the same field names. + let value = ciborium_to_json(&env.op_body).ok()?; + Some(match kind { + AuditOpKind::CredStore => { + Self::CredStore(serde_json::from_value(value).ok()?) + } + AuditOpKind::CredFetch => { + Self::CredFetch(serde_json::from_value(value).ok()?) + } + AuditOpKind::CredTeardown => { + Self::CredTeardown(serde_json::from_value(value).ok()?) + } + AuditOpKind::MemoryPut => { + Self::MemoryPut(serde_json::from_value(value).ok()?) + } + AuditOpKind::MemoryGet => { + Self::MemoryGet(serde_json::from_value(value).ok()?) + } + AuditOpKind::MemoryTeardown => { + Self::MemoryTeardown(serde_json::from_value(value).ok()?) + } + AuditOpKind::SignEip191 => { + Self::SignEip191(serde_json::from_value(value).ok()?) + } + AuditOpKind::SignEip712 => { + Self::SignEip712(serde_json::from_value(value).ok()?) + } + AuditOpKind::PaymentEscrowRedeem => { + Self::PaymentEscrowRedeem(serde_json::from_value(value).ok()?) + } + AuditOpKind::PaymentDirect => { + Self::PaymentDirect(serde_json::from_value(value).ok()?) + } + AuditOpKind::ScopeGrant => { + Self::ScopeGrant(serde_json::from_value(value).ok()?) + } + AuditOpKind::ScopeRevoke => { + Self::ScopeRevoke(serde_json::from_value(value).ok()?) + } + AuditOpKind::DeviceAdd => { + Self::DeviceAdd(serde_json::from_value(value).ok()?) + } + AuditOpKind::DeviceRevoke => { + Self::DeviceRevoke(serde_json::from_value(value).ok()?) + } + AuditOpKind::K10Rotate => { + Self::K10Rotate(serde_json::from_value(value).ok()?) + } + AuditOpKind::EmailSend => { + Self::EmailSend(serde_json::from_value(value).ok()?) + } + AuditOpKind::EmailReceive => { + Self::EmailReceive(serde_json::from_value(value).ok()?) + } + AuditOpKind::K3EpochAdvance => { + Self::K3EpochAdvance(serde_json::from_value(value).ok()?) + } + }) + } +} + +/// Convert a `ciborium::Value` to a `serde_json::Value` so we can use the +/// existing `serde_json::from_value` deserializers on the body structs. The +/// alternative — `ciborium::Value::deserialized()` — only works for types +/// that derive `Deserialize` AND don't depend on `human_readable=true`. The +/// JSON detour keeps things portable. +fn ciborium_to_json(v: &ciborium::Value) -> Result { + use ciborium::Value as CV; + Ok(match v { + CV::Null => serde_json::Value::Null, + CV::Bool(b) => serde_json::Value::Bool(*b), + CV::Integer(i) => { + // ciborium::value::Integer can hold up to 128 bits; constrain to i64/u64. + let as_i128: i128 = (*i).into(); + if as_i128 >= 0 && as_i128 <= u64::MAX as i128 { + serde_json::Value::Number((as_i128 as u64).into()) + } else if as_i128 >= i64::MIN as i128 && as_i128 <= i64::MAX as i128 { + serde_json::Value::Number((as_i128 as i64).into()) + } else { + return Err(AuditError::Invalid(format!("integer out of i64 range: {as_i128}"))); + } + } + CV::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + CV::Bytes(b) => serde_json::Value::String(format!("0x{}", hex::encode(b))), + CV::Text(s) => serde_json::Value::String(s.clone()), + CV::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for x in arr { + out.push(ciborium_to_json(x)?); + } + serde_json::Value::Array(out) + } + CV::Map(m) => { + let mut out = serde_json::Map::with_capacity(m.len()); + for (k, val) in m { + let key = match k { + CV::Text(s) => s.clone(), + other => format!("{other:?}"), + }; + out.insert(key, ciborium_to_json(val)?); + } + serde_json::Value::Object(out) + } + CV::Tag(_, inner) => ciborium_to_json(inner)?, + _ => return Err(AuditError::Invalid(format!("unsupported CBOR variant: {v:?}"))), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_envelope() -> AuditEnvelope { + use ciborium::Value; + AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 1_700_000_000, + actor_omni: [0xaa; 32], + operator_omni: [0xbb; 32], + op_kind: AuditOpKind::CredStore as u8, + op_body: Value::Map(vec![ + ( + Value::Text("service".into()), + Value::Text("openrouter".into()), + ), + ( + Value::Text("payload_hash".into()), + Value::Text(format!("0x{}", "ab".repeat(32))), + ), + ]), + result: AuditResult::Success, + intent_text: Some("Store credential for openrouter".to_string()), + intent_commitment: Some([0xcc; 32]), + } + } + + #[test] + fn cbor_roundtrip_preserves_envelope() { + let env = fixture_envelope(); + let cbor = env.to_canonical_cbor().unwrap(); + let decoded = AuditEnvelope::from_canonical_cbor(&cbor).unwrap(); + assert_eq!(env, decoded); + } + + #[test] + fn envelope_hash_is_deterministic() { + let env = fixture_envelope(); + let h1 = env.envelope_hash().unwrap(); + let h2 = env.envelope_hash().unwrap(); + assert_eq!(h1, h2); + } + + #[test] + fn envelope_hash_changes_with_any_field() { + let env = fixture_envelope(); + let baseline = env.envelope_hash().unwrap(); + let mut mutated = env.clone(); + mutated.ts_unix += 1; + assert_ne!(mutated.envelope_hash().unwrap(), baseline); + } + + #[test] + fn unknown_op_kind_still_decodes_envelope_level_fields() { + use ciborium::Value; + // Encode an envelope with an op_kind byte that's NOT in the canonical + // table (op_kind = 250). Decoding MUST succeed and preserve every + // envelope-level field. typed_body() returns None. + let mut env = fixture_envelope(); + env.op_kind = 250; + env.op_body = Value::Map(vec![( + Value::Text("future_field_only_v2_knows".into()), + Value::Text("value".into()), + )]); + + let cbor = env.to_canonical_cbor().unwrap(); + let decoded = AuditEnvelope::from_canonical_cbor(&cbor).unwrap(); + + assert_eq!(decoded.op_kind, 250); + assert_eq!(decoded.ts_unix, env.ts_unix); + assert_eq!(decoded.actor_omni, env.actor_omni); + assert_eq!(decoded.operator_omni, env.operator_omni); + assert_eq!(decoded.intent_text, env.intent_text); + assert_eq!(decoded.intent_commitment, env.intent_commitment); + // Critical: typed_body returns None — caller renders Unknown(byte) row. + assert!(decoded.typed_body().is_none()); + } + + #[test] + fn version_2_decoder_refuses_unknown_envelope_version() { + let mut env = fixture_envelope(); + env.version = 99; + let cbor = env.to_canonical_cbor().unwrap(); + // Decoder returns Invalid("unsupported envelope version: 99") + let err = AuditEnvelope::from_canonical_cbor(&cbor).unwrap_err(); + assert!(format!("{err}").contains("99")); + } + + #[test] + fn typed_body_decodes_cred_store() { + let env = fixture_envelope(); + match env.typed_body() { + Some(TypedAuditBody::CredStore(body)) => { + assert_eq!(body.service, "openrouter"); + } + other => panic!("unexpected typed body: {other:?}"), + } + } + + #[test] + fn commit_intent_matches_clear_signing_commitment() { + // Same scheme as clear_signing::commit_intent — same digest. + let intent = "Approve 1 USDC to 0xaaaa…3333"; + let digest = [0xde; 32]; + let a = commit_intent(intent, &digest); + let b = crate::clear_signing::commit_intent(intent, &digest); + assert_eq!(a, b); + } +} diff --git a/crates/agentkeys-core/src/audit/op_kind.rs b/crates/agentkeys-core/src/audit/op_kind.rs new file mode 100644 index 0000000..82e8a53 --- /dev/null +++ b/crates/agentkeys-core/src/audit/op_kind.rs @@ -0,0 +1,174 @@ +//! Canonical op_kind byte assignments (arch.md §15.3a, issue #97). +//! +//! **PRs adding new op_kinds MUST append a row to the canonical table in +//! arch.md §15.3a AND add a variant here.** Numbers are never reused and +//! never reordered — that's invariant #7 in the non-break design. +//! +//! Byte ranges with reserved slots: +//! +//! - 0-9 creds family (CredStore=0, CredFetch=1, CredTeardown=2; 3-9 reserved) +//! - 10-19 memory family (MemoryPut=10, MemoryGet=11, MemoryTeardown=12; 13-19 reserved) +//! - 20-29 signs family (SignEip191=20, SignEip712=21; 22-29 reserved) +//! - 30-39 payments family (PaymentEscrowRedeem=30, PaymentDirect=31; 32-39 reserved) +//! - 40-49 scope family (ScopeGrant=40, ScopeRevoke=41; 42-49 reserved) +//! - 50-59 device family (DeviceAdd=50, DeviceRevoke=51, K10Rotate=52; 53-59 reserved) +//! - 60-69 email family (EmailSend=60, EmailReceive=61; 62-69 reserved) +//! - 70-79 K3 family (K3EpochAdvance=70; 71-79 reserved) +//! - 80-255 reserved for future families + +/// Canonical op_kind enum. The byte value MUST match the row in arch.md +/// §15.3a. The enum is `repr(u8)` so `as u8` gives the canonical byte. +/// +/// Decoders MUST handle unknown bytes (anything outside this enum) by +/// keeping the envelope-level fields readable and surfacing +/// `Unknown(byte)` in the explorer UI (per non-break invariant #1). +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuditOpKind { + CredStore = 0, + CredFetch = 1, + CredTeardown = 2, + MemoryPut = 10, + MemoryGet = 11, + MemoryTeardown = 12, + SignEip191 = 20, + SignEip712 = 21, + PaymentEscrowRedeem = 30, + PaymentDirect = 31, + ScopeGrant = 40, + ScopeRevoke = 41, + DeviceAdd = 50, + DeviceRevoke = 51, + K10Rotate = 52, + EmailSend = 60, + EmailReceive = 61, + K3EpochAdvance = 70, +} + +impl AuditOpKind { + /// Decode a canonical byte to a known op_kind. Returns `None` for any + /// byte not in the canonical table (caller renders `Unknown(byte)`). + pub fn from_u8(byte: u8) -> Option { + Some(match byte { + 0 => Self::CredStore, + 1 => Self::CredFetch, + 2 => Self::CredTeardown, + 10 => Self::MemoryPut, + 11 => Self::MemoryGet, + 12 => Self::MemoryTeardown, + 20 => Self::SignEip191, + 21 => Self::SignEip712, + 30 => Self::PaymentEscrowRedeem, + 31 => Self::PaymentDirect, + 40 => Self::ScopeGrant, + 41 => Self::ScopeRevoke, + 50 => Self::DeviceAdd, + 51 => Self::DeviceRevoke, + 52 => Self::K10Rotate, + 60 => Self::EmailSend, + 61 => Self::EmailReceive, + 70 => Self::K3EpochAdvance, + _ => return None, + }) + } + + /// Human-readable label — what the explorer prints when it recognizes + /// the op_kind. Unknown op_kinds render `Unknown()` per + /// invariant #4. + pub fn label(self) -> &'static str { + match self { + Self::CredStore => "cred.store", + Self::CredFetch => "cred.fetch", + Self::CredTeardown => "cred.teardown", + Self::MemoryPut => "memory.put", + Self::MemoryGet => "memory.get", + Self::MemoryTeardown => "memory.teardown", + Self::SignEip191 => "sign.eip191", + Self::SignEip712 => "sign.eip712", + Self::PaymentEscrowRedeem => "payment.escrow_redeem", + Self::PaymentDirect => "payment.direct", + Self::ScopeGrant => "scope.grant", + Self::ScopeRevoke => "scope.revoke", + Self::DeviceAdd => "device.add", + Self::DeviceRevoke => "device.revoke", + Self::K10Rotate => "device.k10_rotate", + Self::EmailSend => "email.send", + Self::EmailReceive => "email.receive", + Self::K3EpochAdvance => "k3.epoch_advance", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant in the table can be encoded to its byte and decoded + /// back. Catches accidental byte-value collisions or missing + /// `from_u8` arms. + #[test] + fn every_op_kind_roundtrips_through_u8() { + let all = [ + AuditOpKind::CredStore, + AuditOpKind::CredFetch, + AuditOpKind::CredTeardown, + AuditOpKind::MemoryPut, + AuditOpKind::MemoryGet, + AuditOpKind::MemoryTeardown, + AuditOpKind::SignEip191, + AuditOpKind::SignEip712, + AuditOpKind::PaymentEscrowRedeem, + AuditOpKind::PaymentDirect, + AuditOpKind::ScopeGrant, + AuditOpKind::ScopeRevoke, + AuditOpKind::DeviceAdd, + AuditOpKind::DeviceRevoke, + AuditOpKind::K10Rotate, + AuditOpKind::EmailSend, + AuditOpKind::EmailReceive, + AuditOpKind::K3EpochAdvance, + ]; + for k in all { + let byte = k as u8; + assert_eq!(AuditOpKind::from_u8(byte), Some(k), "byte {byte} round-trip"); + } + } + + /// Bytes in the reserved gaps return None — proves the non-break + /// invariant #1 (open enum). 250 is the reserved-future canary. + #[test] + fn unknown_bytes_return_none() { + for byte in [3u8, 9, 13, 19, 22, 32, 42, 53, 62, 71, 80, 200, 250, 255] { + assert_eq!(AuditOpKind::from_u8(byte), None, "byte {byte} must be unknown"); + } + } + + /// No two enum variants share a byte. Compile-time guarantee in Rust, + /// but verify in case someone copy-pastes a number. + #[test] + fn all_byte_values_unique() { + use std::collections::HashSet; + let all = [ + AuditOpKind::CredStore as u8, + AuditOpKind::CredFetch as u8, + AuditOpKind::CredTeardown as u8, + AuditOpKind::MemoryPut as u8, + AuditOpKind::MemoryGet as u8, + AuditOpKind::MemoryTeardown as u8, + AuditOpKind::SignEip191 as u8, + AuditOpKind::SignEip712 as u8, + AuditOpKind::PaymentEscrowRedeem as u8, + AuditOpKind::PaymentDirect as u8, + AuditOpKind::ScopeGrant as u8, + AuditOpKind::ScopeRevoke as u8, + AuditOpKind::DeviceAdd as u8, + AuditOpKind::DeviceRevoke as u8, + AuditOpKind::K10Rotate as u8, + AuditOpKind::EmailSend as u8, + AuditOpKind::EmailReceive as u8, + AuditOpKind::K3EpochAdvance as u8, + ]; + let s: HashSet<_> = all.iter().copied().collect(); + assert_eq!(s.len(), all.len(), "duplicate byte assignment"); + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index dea2c9d..b9fedca 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod actor_omni; +pub mod audit; pub mod auth_request; pub mod backend; pub mod chain_profile; diff --git a/crates/agentkeys-worker-audit/Cargo.toml b/crates/agentkeys-worker-audit/Cargo.toml index 013ac66..ff576d1 100644 --- a/crates/agentkeys-worker-audit/Cargo.toml +++ b/crates/agentkeys-worker-audit/Cargo.toml @@ -13,6 +13,7 @@ name = "agentkeys_worker_audit" path = "src/lib.rs" [dependencies] +agentkeys-core = { workspace = true } axum = { version = "0.7", features = ["json"] } tokio = { workspace = true } serde = { workspace = true } @@ -24,7 +25,11 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } sha3 = "0.10" hex = "0.4" +ciborium = "0.2" clap = { version = "4", features = ["derive", "env"] } [dev-dependencies] tokio = { workspace = true, features = ["full", "test-util"] } +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" +sha3 = "0.10" diff --git a/crates/agentkeys-worker-audit/src/handlers.rs b/crates/agentkeys-worker-audit/src/handlers.rs index f6d1120..9b53ef5 100644 --- a/crates/agentkeys-worker-audit/src/handlers.rs +++ b/crates/agentkeys-worker-audit/src/handlers.rs @@ -1,17 +1,26 @@ //! HTTP surface for the audit-service worker. //! -//! Endpoints: +//! Endpoints (V1 — legacy 5-field shape, retained): //! POST /v1/audit/append — queue a single event //! POST /v1/audit/flush/:operator — flush one operator's queue → Merkle root //! POST /v1/audit/flush-all — flush every queue //! GET /v1/audit/queue-size/:operator — diagnostics +//! +//! Endpoints (V2 — canonical `AuditEnvelope`, issue #97 phase B): +//! POST /v1/audit/append/v2 — store an envelope + return its `envelope_hash` +//! GET /v1/audit/envelope/:hash — fetch the canonical CBOR for an envelope hash +//! +//! Per arch.md §15.3a, V1 + V2 coexist for one migration cycle. use axum::{ + body::Body, extract::{Path, State}, - http::StatusCode, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::state::{AuditEvent, FlushResult, SharedState}; @@ -82,3 +91,193 @@ pub async fn queue_size( queue_size: 0, // TODO: expose a read accessor on State })) } + +// ─── V2 endpoints — `AuditEnvelope` (arch.md §15.3a, issue #97) ────────── + +/// JSON shape accepted by `POST /v1/audit/append/v2`. The envelope is sent +/// as JSON (each `op_body` is a freeform JSON object); the worker +/// converts it to a `ciborium::Value` for canonical CBOR encoding. +#[derive(Deserialize)] +pub struct AppendV2Request { + /// Envelope-level version. Must equal + /// `agentkeys_core::audit::ENVELOPE_VERSION`. + pub version: u8, + /// Server-side fills this if 0; caller may pass an explicit timestamp. + #[serde(default)] + pub ts_unix: u64, + /// 0x-prefixed 64-hex (32 raw bytes). + pub actor_omni: String, + pub operator_omni: String, + pub op_kind: u8, + /// Op-kind-specific body. Opaque JSON — gets converted to CBOR. + pub op_body: serde_json::Value, + /// 0=Success, 1=Failure, 2=NotPermitted. + pub result: u8, + pub intent_text: Option, + /// 0x-prefixed 64-hex (32 raw bytes) or null. + pub intent_commitment: Option, +} + +#[derive(Serialize)] +pub struct AppendV2Response { + pub ok: bool, + /// 0x-prefixed 64-hex (32 raw bytes). Use this in the on-chain + /// `CredentialAudit.appendV2(operator_omni, actor_omni, op_kind, + /// envelope_hash)` call. + pub envelope_hash: String, +} + +pub async fn append_v2( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + use agentkeys_core::audit::{AuditEnvelope, AuditResult, ENVELOPE_VERSION}; + + if req.version != ENVELOPE_VERSION { + return Err(( + StatusCode::BAD_REQUEST, + format!( + "unsupported envelope version: {} (this worker supports {})", + req.version, ENVELOPE_VERSION + ), + )); + } + + let actor_omni = decode_hex_32(&req.actor_omni, "actor_omni")?; + let operator_omni = decode_hex_32(&req.operator_omni, "operator_omni")?; + let intent_commitment = match &req.intent_commitment { + Some(s) => Some(decode_hex_32(s, "intent_commitment")?), + None => None, + }; + let result = match req.result { + 0 => AuditResult::Success, + 1 => AuditResult::Failure, + 2 => AuditResult::NotPermitted, + other => { + return Err(( + StatusCode::BAD_REQUEST, + format!("unknown result byte: {other}"), + )) + } + }; + let ts_unix = if req.ts_unix == 0 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } else { + req.ts_unix + }; + + let envelope = AuditEnvelope { + version: req.version, + ts_unix, + actor_omni, + operator_omni, + op_kind: req.op_kind, + op_body: json_to_ciborium(req.op_body) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("op_body: {e}")))?, + result, + intent_text: req.intent_text, + intent_commitment, + }; + + let cbor = envelope + .to_canonical_cbor() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("encode: {e}")))?; + let envelope_hash = envelope + .envelope_hash() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("hash: {e}")))?; + let hash_hex = format!("0x{}", hex::encode(envelope_hash)); + + state.store_envelope(hash_hex.clone(), cbor).await; + + Ok(Json(AppendV2Response { + ok: true, + envelope_hash: hash_hex, + })) +} + +/// `GET /v1/audit/envelope/:hash` — return the canonical CBOR for the +/// envelope identified by `envelope_hash` (a 0x-prefixed 64-hex string). +/// Returns 404 if unknown. +/// +/// Response is `application/cbor` so explorers can verify the hash +/// matches by re-running `keccak256(body)`. +pub async fn get_envelope( + State(state): State, + Path(hash): Path, +) -> Response { + let key = hash.to_lowercase(); + match state.get_envelope(&key).await { + Some(cbor) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/cbor"), + ) + .body(Body::from(cbor)) + .unwrap(), + None => ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": "envelope_not_found", + "message": format!("no envelope at {hash}"), + })), + ) + .into_response(), + } +} + +fn decode_hex_32(s: &str, label: &str) -> Result<[u8; 32], (StatusCode, String)> { + let trimmed = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(trimmed).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("{label}: invalid hex: {e}"), + ) + })?; + if bytes.len() != 32 { + return Err(( + StatusCode::BAD_REQUEST, + format!("{label}: expected 32 bytes, got {}", bytes.len()), + )); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn json_to_ciborium(v: serde_json::Value) -> Result { + use ciborium::Value as CV; + Ok(match v { + serde_json::Value::Null => CV::Null, + serde_json::Value::Bool(b) => CV::Bool(b), + serde_json::Value::Number(n) => { + if let Some(u) = n.as_u64() { + CV::Integer(u.into()) + } else if let Some(i) = n.as_i64() { + CV::Integer(i.into()) + } else if let Some(f) = n.as_f64() { + CV::Float(f) + } else { + return Err(format!("unrepresentable number: {n}")); + } + } + serde_json::Value::String(s) => CV::Text(s), + serde_json::Value::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for x in arr { + out.push(json_to_ciborium(x)?); + } + CV::Array(out) + } + serde_json::Value::Object(o) => { + let mut entries = Vec::with_capacity(o.len()); + for (k, v) in o { + entries.push((CV::Text(k), json_to_ciborium(v)?)); + } + CV::Map(entries) + } + }) +} diff --git a/crates/agentkeys-worker-audit/src/lib.rs b/crates/agentkeys-worker-audit/src/lib.rs index 38e0a18..7148e24 100644 --- a/crates/agentkeys-worker-audit/src/lib.rs +++ b/crates/agentkeys-worker-audit/src/lib.rs @@ -11,3 +11,23 @@ pub mod handlers; pub mod merkle; pub mod state; + +use axum::{ + routing::{get, post}, + Router, +}; + +/// Build the worker's HTTP router. Exposed for tests that want to drive +/// the V2 endpoints through `tower::ServiceExt::oneshot` without binding +/// a real TCP socket. +pub fn create_router(state: state::SharedState) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/v1/audit/append", post(handlers::append)) + .route("/v1/audit/flush/:operator_omni", post(handlers::flush_one)) + .route("/v1/audit/flush-all", post(handlers::flush_all)) + .route("/v1/audit/queue-size/:operator_omni", get(handlers::queue_size)) + .route("/v1/audit/append/v2", post(handlers::append_v2)) + .route("/v1/audit/envelope/:hash", get(handlers::get_envelope)) + .with_state(state) +} diff --git a/crates/agentkeys-worker-audit/src/main.rs b/crates/agentkeys-worker-audit/src/main.rs index 36497c0..dd5c1a7 100644 --- a/crates/agentkeys-worker-audit/src/main.rs +++ b/crates/agentkeys-worker-audit/src/main.rs @@ -74,6 +74,10 @@ async fn main() -> anyhow::Result<()> { .route("/v1/audit/flush/:operator_omni", post(handlers::flush_one)) .route("/v1/audit/flush-all", post(handlers::flush_all)) .route("/v1/audit/queue-size/:operator_omni", get(handlers::queue_size)) + // V2 endpoints (arch.md §15.3a, issue #97 phase B). V1 stays so + // existing callers keep working during the migration cycle. + .route("/v1/audit/append/v2", post(handlers::append_v2)) + .route("/v1/audit/envelope/:hash", get(handlers::get_envelope)) .with_state(state); let listener = tokio::net::TcpListener::bind(&args.bind).await?; diff --git a/crates/agentkeys-worker-audit/src/state.rs b/crates/agentkeys-worker-audit/src/state.rs index 758c6bf..59a2a9b 100644 --- a/crates/agentkeys-worker-audit/src/state.rs +++ b/crates/agentkeys-worker-audit/src/state.rs @@ -38,11 +38,42 @@ pub struct State { queues: Mutex>>, /// Where to drop a leaves-jsonl file per flush. Defaults to /tmp. pub leaves_dir: String, + /// `envelope_hash` (lowercased 0x-hex) → canonical CBOR bytes. + /// Populated by `POST /v1/audit/append/v2`; read by `GET + /// /v1/audit/envelope/`. Per arch.md §15.3a issue #97 phase B. + /// + /// In-memory for v0 — the chain commitment is the durability + /// mechanism; if the worker restarts before a chain `appendV2` lands, + /// callers re-emit. Persistent storage (e.g., S3 + /// `s3:///audit/envelopes/.cbor`) is tracked as a + /// follow-up alongside the contract redeploy. + envelopes: Mutex>>, } impl State { pub fn new(leaves_dir: String) -> Self { - Self { queues: Mutex::new(HashMap::new()), leaves_dir } + Self { + queues: Mutex::new(HashMap::new()), + leaves_dir, + envelopes: Mutex::new(HashMap::new()), + } + } + + /// Store a canonical-CBOR-encoded `AuditEnvelope` keyed by its + /// `envelope_hash`. The hash format is lowercased 0x-hex (matches the + /// `GET` endpoint's path-arg shape). + pub async fn store_envelope(&self, envelope_hash_hex: String, cbor: Vec) { + let mut e = self.envelopes.lock().await; + e.insert(envelope_hash_hex, cbor); + } + + /// Retrieve a canonical-CBOR envelope by `envelope_hash` (lowercased + /// 0x-hex). Returns `None` if the hash is unknown to this worker (it + /// was committed on chain by another worker instance, or never + /// emitted, or the worker restarted). + pub async fn get_envelope(&self, envelope_hash_hex: &str) -> Option> { + let e = self.envelopes.lock().await; + e.get(envelope_hash_hex).cloned() } /// Append a single event. Returns the new queue length for this operator. diff --git a/crates/agentkeys-worker-audit/tests/envelope_v2.rs b/crates/agentkeys-worker-audit/tests/envelope_v2.rs new file mode 100644 index 0000000..9ecf6f1 --- /dev/null +++ b/crates/agentkeys-worker-audit/tests/envelope_v2.rs @@ -0,0 +1,170 @@ +//! Integration tests for the `AuditEnvelope v2` endpoints (issue #97 phase B). +//! +//! Exercises: +//! - `POST /v1/audit/append/v2` → 200 + envelope_hash +//! - `GET /v1/audit/envelope/` → 200 application/cbor with the canonical bytes +//! - `GET /v1/audit/envelope/` → 404 envelope_not_found +//! - End-to-end: hash returned by append matches `keccak256(canonical_cbor)` of +//! the round-tripped envelope. + +use std::sync::Arc; + +use agentkeys_worker_audit::{create_router, state::State}; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::json; +use sha3::{Digest, Keccak256}; +use tower::ServiceExt; + +fn router_with_state() -> axum::Router { + let tmp = std::env::temp_dir(); + let state: agentkeys_worker_audit::state::SharedState = + Arc::new(State::new(tmp.to_string_lossy().to_string())); + create_router(state) +} + +async fn post_json( + app: axum::Router, + path: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let parsed: serde_json::Value = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, parsed) +} + +fn valid_envelope_json() -> serde_json::Value { + json!({ + "version": 1, + "ts_unix": 1_700_000_000u64, + "actor_omni": "0x".to_string() + &"aa".repeat(32), + "operator_omni": "0x".to_string() + &"bb".repeat(32), + "op_kind": 21, // SignEip712 + "op_body": { + "chain_id": 1, + "verifying_contract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "primary_type": "Permit", + "type_hash": "0x".to_string() + &"de".repeat(32), + "domain_separator": "0x".to_string() + &"ad".repeat(32), + "digest": "0x".to_string() + &"be".repeat(32), + }, + "result": 0, + "intent_text": "Approve 1 USDC to 0xaaaa…3333", + "intent_commitment": "0x".to_string() + &"cc".repeat(32), + }) +} + +#[tokio::test] +async fn append_v2_then_get_returns_canonical_cbor() { + let app = router_with_state(); + let (status, append_resp) = post_json(app.clone(), "/v1/audit/append/v2", valid_envelope_json()).await; + assert_eq!(status, StatusCode::OK); + let hash = append_resp["envelope_hash"].as_str().unwrap().to_string(); + assert!(hash.starts_with("0x")); + assert_eq!(hash.len(), 2 + 64); + + // GET the envelope back. + let get_req = Request::builder() + .method(Method::GET) + .uri(format!("/v1/audit/envelope/{hash}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(get_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap().to_str().unwrap(), + "application/cbor" + ); + let cbor = resp.into_body().collect().await.unwrap().to_bytes(); + assert!(!cbor.is_empty()); + + // The returned CBOR's keccak256 MUST equal the envelope_hash returned by append. + let mut hasher = Keccak256::new(); + hasher.update(&cbor); + let recomputed = hasher.finalize(); + let recomputed_hex = format!("0x{}", hex::encode(recomputed)); + assert_eq!(recomputed_hex, hash); +} + +#[tokio::test] +async fn get_envelope_returns_404_for_unknown_hash() { + let app = router_with_state(); + let req = Request::builder() + .method(Method::GET) + .uri(format!("/v1/audit/envelope/0x{}", "ff".repeat(32))) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn append_v2_rejects_wrong_envelope_version() { + let mut body = valid_envelope_json(); + body["version"] = json!(99); + let (status, resp) = post_json(router_with_state(), "/v1/audit/append/v2", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // The body is a plain string in this error path (not JSON), so the + // parsed JSON is Null. Status check is the assertion. + let _ = resp; +} + +#[tokio::test] +async fn append_v2_rejects_short_actor_omni() { + let mut body = valid_envelope_json(); + body["actor_omni"] = json!("0xdeadbeef"); + let (status, _) = post_json(router_with_state(), "/v1/audit/append/v2", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn append_v2_accepts_unknown_op_kind() { + // Per non-break invariant #1, the worker must accept any op_kind byte + // — even one not yet in the canonical table — and store the envelope. + // Old workers that don't recognize new op_kinds just hold the opaque + // body for explorers that DO know to decode it. + let mut body = valid_envelope_json(); + body["op_kind"] = json!(250); + body["op_body"] = json!({ "future_field": "v2-only" }); + let (status, resp) = post_json(router_with_state(), "/v1/audit/append/v2", body).await; + assert_eq!(status, StatusCode::OK); + assert!(resp["envelope_hash"] + .as_str() + .unwrap() + .starts_with("0x")); +} + +#[tokio::test] +async fn envelope_hash_is_deterministic_across_appends() { + let body = valid_envelope_json(); + let (_, a) = post_json(router_with_state(), "/v1/audit/append/v2", body.clone()).await; + let (_, b) = post_json(router_with_state(), "/v1/audit/append/v2", body).await; + assert_eq!(a["envelope_hash"], b["envelope_hash"]); +} + +#[tokio::test] +async fn ts_unix_zero_gets_server_assigned() { + let mut body = valid_envelope_json(); + body["ts_unix"] = json!(0); + let (status, resp) = post_json(router_with_state(), "/v1/audit/append/v2", body).await; + assert_eq!(status, StatusCode::OK); + // The hash will differ from a fixed-ts envelope because ts_unix is part + // of the canonical CBOR. Just confirm we got a valid hash back. + assert!(resp["envelope_hash"] + .as_str() + .unwrap() + .starts_with("0x")); +} From 13e4ef46bf30c7c43828449786870cd240d5ba4e Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 09:02:05 +0800 Subject: [PATCH 04/15] =?UTF-8?q?issue=20#97=20phase=20B:=20AuditClient=20?= =?UTF-8?q?=E2=80=94=20convenience=20HTTP=20client=20for=20the=20V2=20endp?= =?UTF-8?q?oints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` 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>` (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). --- crates/agentkeys-core/src/audit/client.rs | 309 ++++++++++++++++++++++ crates/agentkeys-core/src/audit/mod.rs | 3 + 2 files changed, 312 insertions(+) create mode 100644 crates/agentkeys-core/src/audit/client.rs diff --git a/crates/agentkeys-core/src/audit/client.rs b/crates/agentkeys-core/src/audit/client.rs new file mode 100644 index 0000000..ca16308 --- /dev/null +++ b/crates/agentkeys-core/src/audit/client.rs @@ -0,0 +1,309 @@ +//! HTTP client for emitting `AuditEnvelope v1` to the audit-service worker +//! (`agentkeys-worker-audit`). Used by future emit sites in +//! credentials-service / memory-service / signer / broker / payment-service +//! / email-service / SidecarRegistry / K3EpochCounter. +//! +//! ## Why a client lives in core, not next to the worker +//! +//! Multiple emit sites in different crates need the same wire shape. Putting +//! the client in `agentkeys-core` makes the wire-level contract testable in +//! one place and shared by every emitter. +//! +//! ## Emit-and-forget semantics +//! +//! Audit emits are best-effort from the emitter's perspective — the chain +//! commitment is the durability mechanism, not the worker's in-memory map. +//! Emitters that need guaranteed delivery should either retry on transient +//! failure or fall back to direct on-chain `CredentialAudit.append`. + +use serde::Deserialize; + +use super::{AuditEnvelope, AuditError, AuditResult, ENVELOPE_VERSION}; + +/// Response from `POST /v1/audit/append/v2`. +#[derive(Debug, Clone, Deserialize)] +pub struct AppendV2Response { + pub ok: bool, + pub envelope_hash: String, +} + +/// Client for the audit-service worker's V2 surface. +pub struct AuditClient { + base_url: String, + http: reqwest::Client, +} + +impl AuditClient { + /// Construct with a worker base URL (no trailing slash). Defaults to + /// `$AGENTKEYS_AUDIT_WORKER_URL` then `https://audit.litentry.org` + /// — operators override per deployment. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + } + } + + pub fn from_env() -> Self { + let url = std::env::var("AGENTKEYS_AUDIT_WORKER_URL") + .unwrap_or_else(|_| "https://audit.litentry.org".to_string()); + Self::new(url) + } + + /// Emit a fully-constructed envelope. Returns the `envelope_hash` the + /// worker computed (which the caller can verify locally via + /// `envelope.envelope_hash()`). + pub async fn append(&self, envelope: &AuditEnvelope) -> Result { + let url = format!("{}/v1/audit/append/v2", self.base_url); + let body = envelope_to_json(envelope)?; + let resp = self + .http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| AuditError::Invalid(format!("POST {url}: {e}")))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(AuditError::Invalid(format!( + "audit worker returned {status}: {text}" + ))); + } + resp.json::() + .await + .map_err(|e| AuditError::Invalid(format!("parse append response: {e}"))) + } + + /// Fetch an envelope by its `envelope_hash` (0x-prefixed hex). Returns + /// `None` if the worker doesn't have it (404). + pub async fn get_envelope(&self, envelope_hash: &str) -> Result>, AuditError> { + let url = format!("{}/v1/audit/envelope/{}", self.base_url, envelope_hash); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| AuditError::Invalid(format!("GET {url}: {e}")))?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(AuditError::Invalid(format!( + "audit worker returned {status}: {text}" + ))); + } + let bytes = resp + .bytes() + .await + .map_err(|e| AuditError::Invalid(format!("read body: {e}")))?; + Ok(Some(bytes.to_vec())) + } +} + +/// Build the JSON shape `POST /v1/audit/append/v2` expects from an +/// `AuditEnvelope`. The wire shape mirrors the canonical CBOR but uses +/// 0x-hex strings for byte fields (matches the worker's `AppendV2Request` +/// deserializer). +fn envelope_to_json(env: &AuditEnvelope) -> Result { + let op_body_json = ciborium_value_to_json(&env.op_body)?; + let intent_commitment_hex = env + .intent_commitment + .map(|c| format!("0x{}", hex::encode(c))); + Ok(serde_json::json!({ + "version": env.version, + "ts_unix": env.ts_unix, + "actor_omni": format!("0x{}", hex::encode(env.actor_omni)), + "operator_omni": format!("0x{}", hex::encode(env.operator_omni)), + "op_kind": env.op_kind, + "op_body": op_body_json, + "result": env.result as u8, + "intent_text": env.intent_text, + "intent_commitment": intent_commitment_hex, + })) +} + +fn ciborium_value_to_json(v: &ciborium::Value) -> Result { + use ciborium::Value as CV; + Ok(match v { + CV::Null => serde_json::Value::Null, + CV::Bool(b) => serde_json::Value::Bool(*b), + CV::Integer(i) => { + let n: i128 = (*i).into(); + if n >= 0 && n <= u64::MAX as i128 { + serde_json::Value::Number((n as u64).into()) + } else if n >= i64::MIN as i128 && n <= i64::MAX as i128 { + serde_json::Value::Number((n as i64).into()) + } else { + return Err(AuditError::Invalid(format!( + "integer {n} out of i64 range" + ))); + } + } + CV::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + CV::Bytes(b) => serde_json::Value::String(format!("0x{}", hex::encode(b))), + CV::Text(s) => serde_json::Value::String(s.clone()), + CV::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for x in arr { + out.push(ciborium_value_to_json(x)?); + } + serde_json::Value::Array(out) + } + CV::Map(m) => { + let mut out = serde_json::Map::with_capacity(m.len()); + for (k, val) in m { + let key = match k { + CV::Text(s) => s.clone(), + other => format!("{other:?}"), + }; + out.insert(key, ciborium_value_to_json(val)?); + } + serde_json::Value::Object(out) + } + CV::Tag(_, inner) => ciborium_value_to_json(inner)?, + _ => { + return Err(AuditError::Invalid(format!( + "unsupported CBOR variant for JSON conversion: {v:?}" + ))) + } + }) +} + +/// Convenience builder for the most common emit pattern: known op_kind, +/// typed body that serializes via `serde_json`. +pub fn envelope_for( + actor_omni: [u8; 32], + operator_omni: [u8; 32], + op_kind: super::AuditOpKind, + op_body: impl serde::Serialize, + result: AuditResult, + intent_text: Option, + intent_commitment: Option<[u8; 32]>, +) -> Result { + let body_json = serde_json::to_value(op_body) + .map_err(|e| AuditError::Invalid(format!("serialize op_body: {e}")))?; + let body_cbor = json_to_ciborium(body_json)?; + Ok(AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 0, // worker fills if 0 + actor_omni, + operator_omni, + op_kind: op_kind as u8, + op_body: body_cbor, + result, + intent_text, + intent_commitment, + }) +} + +fn json_to_ciborium(v: serde_json::Value) -> Result { + use ciborium::Value as CV; + Ok(match v { + serde_json::Value::Null => CV::Null, + serde_json::Value::Bool(b) => CV::Bool(b), + serde_json::Value::Number(n) => { + if let Some(u) = n.as_u64() { + CV::Integer(u.into()) + } else if let Some(i) = n.as_i64() { + CV::Integer(i.into()) + } else if let Some(f) = n.as_f64() { + CV::Float(f) + } else { + return Err(AuditError::Invalid(format!("number not representable: {n}"))); + } + } + serde_json::Value::String(s) => CV::Text(s), + serde_json::Value::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for x in arr { + out.push(json_to_ciborium(x)?); + } + CV::Array(out) + } + serde_json::Value::Object(o) => { + let mut entries = Vec::with_capacity(o.len()); + for (k, v) in o { + entries.push((CV::Text(k), json_to_ciborium(v)?)); + } + CV::Map(entries) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::{AuditOpKind, SignEip712Body}; + + #[test] + fn envelope_for_builds_typed_body() { + let body = SignEip712Body { + chain_id: 1, + verifying_contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".into(), + primary_type: "Permit".into(), + type_hash: format!("0x{}", "de".repeat(32)), + domain_separator: format!("0x{}", "ad".repeat(32)), + digest: format!("0x{}", "be".repeat(32)), + }; + let env = envelope_for( + [0xaa; 32], + [0xbb; 32], + AuditOpKind::SignEip712, + body, + AuditResult::Success, + Some("Approve 1 USDC to 0xabc…123".into()), + Some([0xcc; 32]), + ) + .unwrap(); + assert_eq!(env.op_kind, AuditOpKind::SignEip712 as u8); + // Confirm the body round-trips back as SignEip712Body. + match env.typed_body().unwrap() { + crate::audit::TypedAuditBody::SignEip712(b) => { + assert_eq!(b.primary_type, "Permit"); + assert_eq!(b.chain_id, 1); + } + other => panic!("unexpected typed body: {other:?}"), + } + } + + #[test] + fn envelope_for_emits_canonical_cbor() { + // Same envelope produces same hash regardless of build path — + // builder must not introduce non-canonical fields. + let body = SignEip712Body { + chain_id: 1, + verifying_contract: "0xaaaa".into(), + primary_type: "Permit".into(), + type_hash: "0xdead".into(), + domain_separator: "0xbeef".into(), + digest: "0xcafe".into(), + }; + let a = envelope_for( + [0; 32], + [0; 32], + AuditOpKind::SignEip712, + body.clone(), + AuditResult::Success, + None, + None, + ) + .unwrap(); + let b = envelope_for( + [0; 32], + [0; 32], + AuditOpKind::SignEip712, + body, + AuditResult::Success, + None, + None, + ) + .unwrap(); + // ts_unix=0 on both, so envelope_hash matches. + assert_eq!(a.envelope_hash().unwrap(), b.envelope_hash().unwrap()); + } +} diff --git a/crates/agentkeys-core/src/audit/mod.rs b/crates/agentkeys-core/src/audit/mod.rs index 7228fdb..a1e7819 100644 --- a/crates/agentkeys-core/src/audit/mod.rs +++ b/crates/agentkeys-core/src/audit/mod.rs @@ -43,8 +43,11 @@ pub mod bodies; pub mod cbor; +pub mod client; pub mod op_kind; +pub use client::{envelope_for, AppendV2Response, AuditClient}; + use serde::{Deserialize, Serialize}; use sha3::{Digest, Keccak256}; use thiserror::Error; From 7397e28ec3afe7d0865b87064b3c1a72787051a7 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 09:10:57 +0800 Subject: [PATCH 05/15] issue #97 phase C: CredentialAudit.appendV2 + appendRootV2 (contract code only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../agentkeys-chain/src/CredentialAudit.sol | 69 +++++++++++++++ crates/agentkeys-chain/test/AgentKeysV1.t.sol | 87 +++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/crates/agentkeys-chain/src/CredentialAudit.sol b/crates/agentkeys-chain/src/CredentialAudit.sol index 738adc7..e23eee9 100644 --- a/crates/agentkeys-chain/src/CredentialAudit.sol +++ b/crates/agentkeys-chain/src/CredentialAudit.sol @@ -147,6 +147,75 @@ contract CredentialAudit { return roots[operatorOmni].length; } + // ─── V2 surface — `AuditEnvelope v1` (arch.md §15.3a, issue #97 phase C) ── + // + // V2 is event-only. The full envelope lives off-chain at the audit-service + // worker, addressed by `envelopeHash`. The chain commits only + // `(opKind, envelopeHash)` so the contract stays op-kind-agnostic — new + // op_kinds need ZERO contract redeploys (non-break invariant #6). + // + // V1 surface (`append` + `appendRoot` above) is retained so existing + // indexers + the live tier-A worker keep working through the migration. + + /// @notice Emitted by `appendV2`. The `opKind` topic is indexed so + /// explorers can filter "all this operator's typed-data signs" + /// via a single `eth_getLogs` call without scanning every row. + event AuditAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed actorOmni, + uint8 indexed opKind, + bytes32 envelopeHash + ); + + /// @notice Emitted by `appendRootV2`. `opKindBitmap` is `bytes32` where + /// each set bit corresponds to an op_kind byte present in the + /// batch (bit N = op_kind N). Explorers filter root batches by + /// op_kind without fetching every leaf. + event AuditRootAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed merkleRoot, + bytes32 opKindBitmap, + uint64 entryCount + ); + + /// @notice Append a single audit envelope commitment. `envelopeHash` is + /// `keccak256(canonical_cbor(AuditEnvelope))`; the worker + /// (`agentkeys-worker-audit`) holds the full envelope at + /// `GET /v1/audit/envelope/`. + /// + /// @dev Open to any caller, same as V1 `append` — chain ordering + + /// indexed topic filtering is the primary safety. Spam-resistance + /// is via gas cost. + function appendV2( + bytes32 operatorOmni, + bytes32 actorOmni, + uint8 opKind, + bytes32 envelopeHash + ) external { + emit AuditAppendedV2(operatorOmni, actorOmni, opKind, envelopeHash); + } + + /// @notice Commit one Merkle root summarising a tier-A batch of + /// envelopes. Gated to the operator's master wallet (same as + /// V1 `appendRoot`). + /// + /// @param opKindBitmap Each bit indexes one of 256 possible op_kinds + /// present in the batch. Bit N = op_kind N. + /// Lets explorers filter batches by op_kind + /// without fetching every leaf from the worker. + function appendRootV2( + bytes32 operatorOmni, + bytes32 merkleRoot, + bytes32 opKindBitmap, + uint64 batchEntryCount + ) external { + address master = registry.operatorMasterWallet(operatorOmni); + if (master == address(0) || msg.sender != master) { + revert NotOperatorMaster(msg.sender, master); + } + emit AuditRootAppendedV2(operatorOmni, merkleRoot, opKindBitmap, batchEntryCount); + } + function getRoot(bytes32 operatorOmni, uint256 rootIndex) external view diff --git a/crates/agentkeys-chain/test/AgentKeysV1.t.sol b/crates/agentkeys-chain/test/AgentKeysV1.t.sol index 2ef420b..65bb784 100644 --- a/crates/agentkeys-chain/test/AgentKeysV1.t.sol +++ b/crates/agentkeys-chain/test/AgentKeysV1.t.sol @@ -17,6 +17,22 @@ import {CredentialAudit} from "../src/CredentialAudit.sol"; /// produce the full (authData || clientDataJSON || r, s) chain bound /// to a contract-computed challenge. contract AgentKeysV1Test is Test { + // Local copies of CredentialAudit V2 events so `vm.expectEmit` can + // match by topic+data. The event signatures MUST match + // `CredentialAudit.sol` exactly — drift caught by `expectEmit`. + event AuditAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed actorOmni, + uint8 indexed opKind, + bytes32 envelopeHash + ); + event AuditRootAppendedV2( + bytes32 indexed operatorOmni, + bytes32 indexed merkleRoot, + bytes32 opKindBitmap, + uint64 entryCount + ); + P256Verifier p256; K11Verifier k11; SidecarRegistry registry; @@ -339,6 +355,77 @@ contract AgentKeysV1Test is Test { audit.appendRoot(operatorOmni, root, 1); } + // ─── V2 envelope path (arch.md §15.3a, issue #97 phase C) ───────────── + + function test_CredentialAudit_AppendV2_EmitsEvent() public { + bytes32 envelopeHash = keccak256("test-envelope"); + uint8 opKind = 21; // SignEip712 + + // The event topics MUST carry operator, actor, and opKind so + // explorers can filter `eth_getLogs` by any of the three. + vm.expectEmit(true, true, true, true); + emit AuditAppendedV2(operatorOmni, actorOmniAgentA, opKind, envelopeHash); + audit.appendV2(operatorOmni, actorOmniAgentA, opKind, envelopeHash); + } + + function test_CredentialAudit_AppendV2_AcceptsAnyOpKind() public { + // Per non-break invariant #1, the contract is op-kind-agnostic — + // any byte 0..255 must be accepted. Adding a new op_kind needs + // ZERO contract redeploys. + bytes32 envelopeHash = keccak256("future"); + vm.expectEmit(true, true, true, true); + emit AuditAppendedV2(operatorOmni, actorOmniAgentA, 250, envelopeHash); + audit.appendV2(operatorOmni, actorOmniAgentA, 250, envelopeHash); + } + + function test_CredentialAudit_AppendV2_OpenToAnyCaller() public { + // V2 `appendV2` is gated only by chain ordering + gas (same as + // V1 `append`). Attacker can append, but the operator can prove + // forgery via the indexer's view of canonical envelope hashes. + bytes32 envelopeHash = keccak256("attacker-claim"); + vm.prank(attacker); + audit.appendV2(operatorOmni, actorOmniAgentA, 0, envelopeHash); + // No revert — the attacker emit is just noise the indexer filters. + } + + function test_CredentialAudit_AppendRootV2_EmitsEvent() public { + _registerFirstMaster(); + bytes32 root = keccak256("v2-root"); + // bit 0 (CredStore) + bit 21 (SignEip712) + bit 40 (ScopeGrant) + bytes32 bitmap = bytes32(uint256((1 << 0) | (1 << 21) | (uint256(1) << 40))); + + vm.expectEmit(true, true, true, true); + emit AuditRootAppendedV2(operatorOmni, root, bitmap, 3); + vm.prank(master); + audit.appendRootV2(operatorOmni, root, bitmap, 3); + } + + function test_CredentialAudit_AppendRootV2_RejectsNonMaster() public { + _registerFirstMaster(); + bytes32 root = keccak256("dummy"); + bytes32 bitmap = bytes32(uint256(1)); + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(CredentialAudit.NotOperatorMaster.selector, attacker, master) + ); + audit.appendRootV2(operatorOmni, root, bitmap, 1); + } + + function test_CredentialAudit_V1_And_V2_Coexist() public { + // Both surfaces stay live during the migration cycle. The V1 emit + // path is observed today by the existing tier-A worker; V2 is + // what new emitters use. Confirm neither breaks the other. + bytes32 svc = keccak256("openrouter"); + bytes32 payload = keccak256("blob-1"); + audit.append(operatorOmni, actorOmniAgentA, svc, audit.OP_STORE(), payload); + assertEq(audit.entryCount(operatorOmni), 1); + + bytes32 envHash = keccak256("v2-envelope"); + audit.appendV2(operatorOmni, actorOmniAgentA, 0, envHash); + // V1 storage is untouched by V2 emits. + assertEq(audit.entryCount(operatorOmni), 1); + } + function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { // Internal-node prefix per codex M2. return a < b From 0df87c0277fd93c51ae23c41ec257c81fc1e37d3 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 09:18:50 +0800 Subject: [PATCH 06/15] issue #97: recursive op_body canonicalization + arch.md event sig fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/agentkeys-core/src/audit/cbor.rs | 113 +++++++++++++++++++++++- docs/spec/architecture.md | 11 ++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/crates/agentkeys-core/src/audit/cbor.rs b/crates/agentkeys-core/src/audit/cbor.rs index f0709a3..67a578e 100644 --- a/crates/agentkeys-core/src/audit/cbor.rs +++ b/crates/agentkeys-core/src/audit/cbor.rs @@ -57,6 +57,15 @@ use ciborium::Value; use super::{AuditEnvelope, AuditError, AuditResult, ENVELOPE_VERSION}; pub fn encode_canonical(env: &AuditEnvelope) -> Result, AuditError> { + // `op_body` is canonicalized recursively: every nested map's keys are + // sorted by canonical CBOR key ordering before encoding. The Rust + // ecosystem (serde_json::Value::Object = BTreeMap) happens to produce + // sorted maps already, but a Go or TypeScript explorer-side encoder + // building op_body with unsorted keys would otherwise produce + // different bytes + different envelope_hash. This recursive + // canonicalization is what makes the hash truly cross-language. + let op_body_canonical = canonicalize(env.op_body.clone()); + let map = Value::Map(vec![ (Value::Text("actor_omni".into()), Value::Bytes(env.actor_omni.to_vec())), ( @@ -73,7 +82,7 @@ pub fn encode_canonical(env: &AuditEnvelope) -> Result, AuditError> { None => Value::Null, }, ), - (Value::Text("op_body".into()), env.op_body.clone()), + (Value::Text("op_body".into()), op_body_canonical), (Value::Text("op_kind".into()), Value::Integer(env.op_kind.into())), (Value::Text("operator_omni".into()), Value::Bytes(env.operator_omni.to_vec())), (Value::Text("result".into()), Value::Integer((env.result as u8).into())), @@ -87,6 +96,37 @@ pub fn encode_canonical(env: &AuditEnvelope) -> Result, AuditError> { Ok(out) } +/// Recursively canonicalize a `ciborium::Value`: sort every map's keys by +/// their canonical CBOR encoding (RFC 8949 §4.2.3 — lexicographic on +/// encoded bytes). Arrays preserve their order (semantic — arrays are +/// ordered collections). Primitives are unchanged. +/// +/// For text keys, canonical CBOR ordering happens to coincide with +/// lexicographic-by-bytes (which equals UTF-8 byte ordering for ASCII). +/// For integer keys (rare in this codebase), it sorts by the encoded +/// length first, then by bytes — also handled by sorting on the +/// ciborium-encoded form of the key. +fn canonicalize(v: Value) -> Value { + match v { + Value::Map(entries) => { + let mut canon: Vec<(Value, Value)> = entries + .into_iter() + .map(|(k, val)| (canonicalize(k), canonicalize(val))) + .collect(); + canon.sort_by(|(a, _), (b, _)| { + let mut a_bytes = Vec::new(); + let mut b_bytes = Vec::new(); + let _ = ciborium::into_writer(a, &mut a_bytes); + let _ = ciborium::into_writer(b, &mut b_bytes); + a_bytes.cmp(&b_bytes) + }); + Value::Map(canon) + } + Value::Array(items) => Value::Array(items.into_iter().map(canonicalize).collect()), + other => other, + } +} + pub fn decode_canonical(bytes: &[u8]) -> Result { let value: Value = ciborium::from_reader(bytes) .map_err(|e| AuditError::Cbor(format!("decode: {e}")))?; @@ -312,6 +352,77 @@ mod tests { assert!(format!("{err}").contains("99")); } + /// op_body inner maps are canonicalized recursively — two envelopes + /// with the SAME op_body content but DIFFERENT insertion order MUST + /// produce identical CBOR bytes + identical envelope_hash. This is + /// the cross-language property: a Go encoder that builds op_body + /// with unsorted keys gets the same hash as the Rust encoder. + #[test] + fn op_body_key_order_does_not_affect_hash() { + let env_a = AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 1, + actor_omni: [0; 32], + operator_omni: [0; 32], + op_kind: 0, + // op_body with keys in alphabetical insertion order. + op_body: Value::Map(vec![ + (Value::Text("aaa".into()), Value::Integer(1.into())), + (Value::Text("bbb".into()), Value::Integer(2.into())), + (Value::Text("ccc".into()), Value::Integer(3.into())), + ]), + result: AuditResult::Success, + intent_text: None, + intent_commitment: None, + }; + // SAME entries in reverse insertion order. + let env_b = AuditEnvelope { + op_body: Value::Map(vec![ + (Value::Text("ccc".into()), Value::Integer(3.into())), + (Value::Text("bbb".into()), Value::Integer(2.into())), + (Value::Text("aaa".into()), Value::Integer(1.into())), + ]), + ..env_a.clone() + }; + // Same content, different order → same canonical bytes + hash. + let bytes_a = encode_canonical(&env_a).unwrap(); + let bytes_b = encode_canonical(&env_b).unwrap(); + assert_eq!(bytes_a, bytes_b); + assert_eq!(env_a.envelope_hash().unwrap(), env_b.envelope_hash().unwrap()); + } + + /// Nested op_body maps also get canonical-sorted (recursion check). + #[test] + fn op_body_nested_map_key_order_does_not_affect_hash() { + let inner_a = Value::Map(vec![ + (Value::Text("x".into()), Value::Integer(1.into())), + (Value::Text("y".into()), Value::Integer(2.into())), + ]); + let inner_b = Value::Map(vec![ + (Value::Text("y".into()), Value::Integer(2.into())), + (Value::Text("x".into()), Value::Integer(1.into())), + ]); + let env_a = AuditEnvelope { + version: ENVELOPE_VERSION, + ts_unix: 1, + actor_omni: [0; 32], + operator_omni: [0; 32], + op_kind: 0, + op_body: Value::Map(vec![(Value::Text("nested".into()), inner_a)]), + result: AuditResult::Success, + intent_text: None, + intent_commitment: None, + }; + let env_b = AuditEnvelope { + op_body: Value::Map(vec![(Value::Text("nested".into()), inner_b)]), + ..env_a.clone() + }; + assert_eq!( + encode_canonical(&env_a).unwrap(), + encode_canonical(&env_b).unwrap() + ); + } + /// Decoder ignores unknown envelope-level keys (forward-compat for a /// future version that adds a top-level field; a v1 decoder reading a /// future envelope still gets the v1 fields back). This test crafts diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 1c4060f..6fea1b8 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -942,19 +942,24 @@ event AuditAppendedV2( bytes32 indexed operatorOmni, bytes32 indexed actorOmni, uint8 indexed opKind, - bytes32 envelopeHash, - uint256 entryIndex + bytes32 envelopeHash ); event AuditRootAppendedV2( bytes32 indexed operatorOmni, bytes32 indexed merkleRoot, bytes32 opKindBitmap, - uint256 rootIndex, uint64 entryCount ); ``` +V2 is event-only — no on-chain storage of entries or roots. The chain's +canonical history is the indexed event log; indexers reconstruct the +per-operator timeline by filtering `AuditAppendedV2` topics. Position +within the operator's stream (an `entryIndex` analog) is derivable from +block number + log index pairs, so the contract doesn't need to carry it +explicitly. + The `indexed opKind` topic lets the explorer query "show all this operator's typed-data signs in chain history" with a single `eth_getLogs` filter, without scanning every audit row. From a7395164b52fb83dec918dcb5d6e34b58d96f26f Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Thu, 21 May 2026 09:32:15 +0800 Subject: [PATCH 07/15] docs+ops: add-op-kind ritual + setup-heima orchestrator + idempotency rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, `cast code `, `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 ` / `fail ` 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`. --- CLAUDE.md | 28 +++ docs/spec/architecture.md | 60 ++++++ scripts/setup-heima.sh | 294 +++++++++++++++++++++++++++++ wiki/audit-envelope-add-op-kind.md | 227 ++++++++++++++++++++++ 4 files changed, 609 insertions(+) create mode 100755 scripts/setup-heima.sh create mode 100644 wiki/audit-envelope-add-op-kind.md diff --git a/CLAUDE.md b/CLAUDE.md index 972ff92..07ec0d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,34 @@ Also: never gloss over a partial implementation in a demo doc or runbook. If the ## Remote broker host (single entry point) All remote-host changes (binary upgrades, systemd edits, nginx/certbot, env tweaks, mock-server redeploys) MUST go through `bash scripts/setup-broker-host.sh` — it's idempotent and auto-detects bootstrap vs upgrade. No ad-hoc `systemctl` edits or hand-built `scp`. +## Heima chain (single entry point) +All chain bring-up + per-actor binding ceremonies (contract deploy, deployer funding, master device registration, agent creation, scope grants, K11 enrollment, audit-row append, worker smoke) MUST go through `bash scripts/setup-heima.sh` — it's idempotent and orchestrates the existing per-action `heima-*.sh` helpers in order. Same posture as `setup-broker-host.sh`: one command, every step pre-checks state + short-circuits when already done. The per-action helpers stay callable directly for surgical re-runs (`bash scripts/heima-scope-set.sh ...`); `setup-heima.sh` is the end-to-end orchestrator. + +## Idempotent remote-setup rule (CLOUD / BLOCKCHAIN / CI / VM) +**Every script that mutates remote state — AWS / Heima / CI runners / EC2 VMs / Cloudflare / Tencent / IAM / DNS — MUST be idempotent.** A second run with the same inputs MUST exit 0 without re-applying the mutation. This is non-negotiable because: + +1. **Operators re-run scripts.** Cloud setup is slow + flaky; a retry-from-the-start posture catches transient failures gracefully only when re-runs are safe. +2. **CI / CD pipelines re-run scripts.** Every CI redeploy or VM provision invokes the same script; non-idempotent scripts double-create resources, double-fund accounts, double-bill operators. +3. **The harness re-runs scripts.** `harness/v2-stage{1,2,3}-demo.sh` invokes every chain helper on every run. A non-idempotent helper means the harness can't be used as a regression gate. + +Concrete shape for idempotent scripts (per the existing `setup-broker-host.sh` / `heima-*.sh` patterns): + +| Mutation type | Pre-check before mutating | Short-circuit shape | +|---|---|---| +| Contract deploy | `cast code ` — non-empty means deployed | `skip already-deployed` (log + exit 0) | +| Chain tx (register / scope / audit append) | `cast call ` returning canonical state | `skip already-registered` / `skip config-matches` | +| Fund EVM account | `cast balance` ≥ requested amount | `skip already-funded` | +| AWS resource (bucket / role / policy) | `aws s3api head-bucket` / `aws iam get-role` | `skip already-exists` + best-effort `update-*` for drift | +| Systemd unit | Diff existing `/etc/systemd/system/` vs target | Write only if drift; `systemctl daemon-reload` only when written | +| Env-var file | Diff existing file vs target content | Write only if drift | +| nginx vhost | Diff existing `/etc/nginx/sites-available/` vs target | Write + reload only if drift | +| DNS A record (Route 53) | `aws route53 list-resource-record-sets` for the name | UPSERT change-batch (no-op when value matches) | +| Key generation (keypair file) | `[ -f ]` | `skip already-exists` (NEVER overwrite — would invalidate downstream encrypted blobs) | + +Output convention: every script logs one of three outcomes per step — `ok proceeding` (mutation applied), `skip ` (no-op), or `fail ` (hard error, exit non-zero). The harness reads these to compute green/red per step. + +If a remote-setup script you're writing CAN'T be made idempotent (e.g., one-shot CAS-burn cap-token mint, append-only audit event), explicitly call it out in the script header AND in the runbook ("step N is intentionally append-only; re-runs add a fresh row + advance entryCount"). Otherwise: idempotent or it doesn't ship. + ## AWS local-profile ↔ remote-IAM mapping Operator workstations use lowercase AWS profile names; the access key/secret inside each profile authenticates as the corresponding remote IAM user (case differences like `agentKeys-admin` on AWS vs `agentkeys-admin` locally are cosmetic — the key is the binding, not the name). Source-of-truth (`awsp` output): diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 6fea1b8..37eced4 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -1061,6 +1061,66 @@ Phases B-E are **independent** once A lands — they can ship in parallel across the three repos. Phase A is the lock-in moment; everything else follows the canonical table. +### 15.3b How to add a new op_kind — the 5-step ritual + +Adding a new audit op_kind (e.g. a new worker emits something the +canonical table doesn't yet cover) is a deliberately small + repeatable +change. Per the non-break invariants above, each new op_kind costs at +most "uglier UI temporarily for old explorers" — never "broken explorer +/ dropped event." Five steps, in this exact order: + +1. **Pick the byte.** Claim the next unused byte in the appropriate + family range from the canonical table in §15.3a (creds 0-9, + memory 10-19, signs 20-29, payments 30-39, scope 40-49, device + 50-59, email 60-69, K3 70-79). If your op is in a NEW family, + claim the next unused 10-block (80-89, 90-99, …). Never reuse a + number; never reorder existing rows. + +2. **Append a row to §15.3a canonical op_kind table.** Format: + `\| KindName \| Byte \| {field: type, …} schema \| Worker that emits \|`. + The schema lists every field in the typed `op_body` — exactly the + shape the corresponding `XxxBody` struct in + [`agentkeys-core::audit::bodies`](../../crates/agentkeys-core/src/audit/bodies.rs) + serializes to. + +3. **Add the Rust variant.** Three files in + [`crates/agentkeys-core/src/audit/`](../../crates/agentkeys-core/src/audit/): + - `op_kind.rs`: new variant in the `AuditOpKind` enum at the byte + you claimed + arm in `from_u8` + arm in `label`. + - `bodies.rs`: new `XxxBody` struct with serde derives, fields + matching the arch.md table row. + - `mod.rs`: new variant in the `TypedAuditBody` enum + arm in + `TypedAuditBody::from_envelope`. + +4. **Wire the emit site.** The component that performs the op + (credentials-service / memory-service / signer / broker / payment- + service / email-service / SidecarRegistry hook / K3EpochCounter + hook) calls + [`agentkeys_core::audit::envelope_for(...)`](../../crates/agentkeys-core/src/audit/client.rs) + to build the envelope, then `AuditClient::append(...)` to emit it + to the audit-service worker. The worker stores the envelope by hash + and (separately, batched) commits the hash on-chain via + `CredentialAudit.appendV2(...)` (after Phase C redeploy). + +5. **Ship the three required tests.** Each new op_kind PR MUST ship: + - **Worker test**: CBOR encode + decode roundtrip on a canonical + fixture for the new body shape. + - **Explorer test**: old explorer + envelope with the new op_kind + → graceful `Unknown(byte)` fallback render, no crash, no dropped + event. Lives in [`subscan-essentials`](https://github.com/litentry/subscan-essentials). + - **Doc test / lint**: the new arch.md row's `Byte` is unique + across the table (the existing + [`audit::op_kind::tests::all_byte_values_unique`](../../crates/agentkeys-core/src/audit/op_kind.rs) + enforces this from the Rust side — keep the doc + code in sync). + +**Critically:** never bump `ENVELOPE_VERSION` for a new op_kind. The +version field is reserved for envelope-level changes (adding / +removing top-level fields). Adding a new op_kind goes through this +ritual at v1 — that's the whole point of the open-enum design. + +**Operator-facing detailed guide:** see [`wiki/audit-envelope-add-op-kind.md`](../../wiki/audit-envelope-add-op-kind.md) +for a worked example + the full PR checklist. + ### 15.4 email-service - **IAM:** `ses:SendRawEmail` from operator's domain (e.g., `bots.litentry.org`); `s3:GetObject` + `s3:PutObject` on `bots//{inbound,sent}/*` diff --git a/scripts/setup-heima.sh b/scripts/setup-heima.sh new file mode 100755 index 0000000..4ddb76a --- /dev/null +++ b/scripts/setup-heima.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# AgentKeys Heima chain setup — single idempotent entry point. +# +# Bootstraps the operator's Heima chain state end-to-end: +# 1. Tool sanity-check +# 2. Source operator-workstation.env +# 3. Chain reachability + chain_id sanity-check +# 4. Generate/reuse deployer key +# 5. Fund deployer (sudo on paseo; balance-check on mainnet) +# 6. Deploy stage-1 contracts (P256Verifier + K11Verifier + +# SidecarRegistry + AgentKeysScope + K3EpochCounter + CredentialAudit) +# 7. Persist contract addresses to operator-workstation.env +# 8. Verify contracts on-chain (read-only RPC checks) +# 9. Register operator master device (first-master bootstrap) +# 10. K11 enrollment (stub or --webauthn) +# 11. Create demo agent device +# 12. Set scope (if --webauthn — else skipped) +# 13. Append a smoke-test audit row (V1 path) +# 14. Tier-A audit relay + worker /healthz smoke +# 15. Summary +# +# Per CLAUDE.md "Heima chain (single entry point)" + "Idempotent +# remote-setup rule": every step pre-checks chain/AWS state and short- +# circuits when the op is already a no-op. The script 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) — those helpers stay callable directly for +# surgical re-runs; this script is the end-to-end orchestrator. +# +# Usage: +# AWS_PROFILE=agentkeys-admin bash scripts/setup-heima.sh [flags] +# +# Default chain: heima mainnet (chain_id 212013). Override with --chain. +# +# Flags (each step also accepts a --skip-N for selective re-runs): +# --chain heima (default) | heima-paseo | anvil +# --session-id operator session label (default: alice) +# --agent-label