diff --git a/CLAUDE.md b/CLAUDE.md index c12df61..972ff92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,58 @@ Switch with `awsp `; verify with `aws sts get-caller-identity`. ### Caller-ARN matching in scripts must be case-insensitive Lowercase the caller_arn before matching, since the remote IAM user is `agentKeys-admin` (capital K) but operator scripts canonicalize on `agentkeys-admin`. Use `tr '[:upper:]' '[:lower:]'` (portable to /bin/bash 3.2) — not `${var,,}` (bash 4+). +## Per-actor + per-data-class isolation invariants (issue #90) + +The OIDC + cap-token + IAM stack enforces a defense-in-depth chain across **four layers**. Every PR that touches storage, OIDC, the broker cap-mint flow, or the worker handlers MUST verify these invariants explicitly in a demo step. A change that doesn't add a corresponding test for the layer it touches is incomplete. + +| Layer | Invariant | Enforced by | Canonical test | +|---|---|---|---| +| **1. Broker cap-mint** | The session JWT's `agentkeys.omni_account` claim MUST match the request's `operator_omni`. Also: `device.operator_omni == session_omni`, `device.actor_omni == req.actor_omni`, `device.roles & ROLE_CAP_MINT`, `isServiceInScope(operator, actor, service) == true`. Returns `OperatorMismatch` / `DeviceBindingMismatch` / `DeviceRoleMissing` / `ServiceNotInScope` otherwise. | [`handlers/cap.rs`](crates/agentkeys-broker-server/src/handlers/cap.rs) — `mint_cap()` | [`harness/v2-stage3-demo.sh`](harness/v2-stage3-demo.sh) step 13 (NEGATIVE cap-mint with cross-actor `operator_omni` → HTTP 4xx) | +| **2. Worker chain-verify** | Independent re-check of layer-1 invariants from the worker's perspective — defense-in-depth against broker compromise. `verify_signature` (broker cap-sig), `check_chain_device`, `check_chain_scope`, `check_chain_k3_epoch`. | [`crates/agentkeys-worker-creds/src/verify.rs`](crates/agentkeys-worker-creds/src/verify.rs) + 26 unit tests | [`harness/v2-stage3-demo.sh`](harness/v2-stage3-demo.sh) steps 11+12 (full HTTP roundtrip exercises every verify hook) | +| **3. AWS IAM PrincipalTag scoping** | STS creds minted via `AssumeRoleWithWebIdentity` carry `PrincipalTag/agentkeys_actor_omni`. S3 resources scoped via `${aws:PrincipalTag/agentkeys_actor_omni}` resource-ARN interpolation. `s3:ListBucket` MUST carry an `s3:prefix=bots/${PrincipalTag}//*` condition (codex P2 — split-statement v3 bucket policy). | [`scripts/provision-vault-role.sh`](scripts/provision-vault-role.sh) + [`scripts/provision-memory-role.sh`](scripts/provision-memory-role.sh) + [`scripts/apply-vault-bucket-policy.sh`](scripts/apply-vault-bucket-policy.sh) + [`scripts/apply-memory-bucket-policy.sh`](scripts/apply-memory-bucket-policy.sh) | [`harness/v2-stage3-demo.sh`](harness/v2-stage3-demo.sh) steps 4-9: POSITIVE write to own prefix, NEGATIVE write + LIST to cross-actor prefix → AccessDenied | +| **4. Per-data-class bucket separation** | Vault-role's IAM permissions MUST be scoped to the vault bucket only; memory-role to the memory bucket only. Vault creds in the wrong bucket → AccessDenied; memory creds in the vault bucket → AccessDenied. Per arch.md §17.2 ("sharing one role across data classes collapses blast radius"). | Per-data-class IAM roles (`agentkeys-vault-role`, `agentkeys-memory-role`) | [`harness/v2-stage3-demo.sh`](harness/v2-stage3-demo.sh) step 10 (vault creds → memory bucket, memory creds → vault bucket, both AccessDenied) | + +**Test-discipline rule**: any PR that adds a NEW worker, a NEW data class (e.g. a payments worker), or a NEW broker auth method MUST extend the stage-3 demo with negative cross-isolation tests for ALL four layers. Don't ship the feature with only POSITIVE-path tests. + +### Cap-tokens are data-class-explicit (issue #90 followup) + +The broker mints FOUR cap endpoints — two per data class — and the `data_class` is a SIGNED FIELD in the cap payload. Workers reject caps whose `data_class` doesn't match their bucket. This is the cap-layer isolation gate, symmetric with the AWS IAM cross-bucket gate (layer 4) but at the broker-signed capability layer. + +``` +POST /v1/cap/cred-store → mints CapPayload { op: Store, data_class: Credentials, ... } +POST /v1/cap/cred-fetch → mints CapPayload { op: Fetch, data_class: Credentials, ... } +POST /v1/cap/memory-put → mints CapPayload { op: Store, data_class: Memory, ... } +POST /v1/cap/memory-get → mints CapPayload { op: Fetch, data_class: Memory, ... } +``` + +What this prevents: + +```bash +# Operator A mints a credentials Store cap: +cred_cap=$(curl -X POST $BROKER/v1/cap/cred-store -d ...) +# → CapPayload { ..., op: store, data_class: credentials } + +# Tries to abuse it against the memory worker: +curl -X POST https://memory.litentry.org/v1/memory/put -d '{"cap": '"$cred_cap"', "plaintext_b64": "..."}' +# → HTTP 403 cap_data_class_mismatch +# The memory worker's verify_cap() calls check_data_class(cap, DataClass::Memory), +# sees cap.payload.data_class == Credentials, rejects. +``` + +The reverse (memory cap submitted to cred worker) is symmetrically blocked. + +**Why two endpoints per data class, not just one + a `data_class` query param**: by making the route the source of truth, the broker can't ever mint a `Memory` cap from a request that hit `/v1/cap/cred-*` — the variant is statically derived in `handlers/cap.rs`, not from user input. Mistakes-on-the-broker-side are impossible to construct. + +**Why this matters beyond the IAM layer**: AWS IAM (layer 3+4) enforces cross-actor + cross-bucket isolation at the AWS-API call site. The `data_class` cap binding enforces it at the cap-authz site — earlier in the trust chain, before the worker even calls AWS. If the AWS IAM grants were ever accidentally too broad, the cap-layer check still rejects. Defense in depth. + +Verified live: + +- `harness/v2-stage3-demo.sh` step 14 — cred-class cap → memory worker → `cap_data_class_mismatch` +- `harness/v2-stage3-demo.sh` step 15 — memory-class cap → cred worker → `cap_data_class_mismatch` +- Unit tests: `crates/agentkeys-worker-creds/src/verify.rs::check_data_class_rejects_cross_class` + serialization test for `DataClass` + +**When a third data class lands** (e.g. payments-audit per arch.md §15.6): mint two more endpoints (`/v1/cap/payaudit-store` + `/v1/cap/payaudit-fetch`), add `DataClass::PaymentsAudit` variant, plumb to the new worker. The pattern is closed-extension: existing data classes don't need to know about the new one. + ## Development Workflow (Anthropic Harness Pattern) On every session start: diff --git a/Cargo.lock b/Cargo.lock index 5d9c71d..fa19163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,7 @@ dependencies = [ name = "agentkeys-daemon" version = "0.1.0" dependencies = [ + "agentkeys-cli", "agentkeys-core", "agentkeys-mcp", "agentkeys-mock-server", @@ -159,6 +160,7 @@ dependencies = [ "base64", "clap", "ed25519-dalek", + "hex", "http-body-util", "hyper 1.9.0", "hyper-util", @@ -256,6 +258,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "agentkeys-worker-audit" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "hex", + "reqwest", + "serde", + "serde_json", + "sha3", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "agentkeys-worker-creds" version = "0.1.0" @@ -264,6 +284,7 @@ dependencies = [ "agentkeys-types", "anyhow", "aws-config", + "aws-credential-types", "aws-sdk-s3", "axum", "base64", @@ -283,6 +304,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "agentkeys-worker-email" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-sdk-s3", + "aws-sdk-sesv2", + "axum", + "clap", + "hex", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "agentkeys-worker-memory" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 57a018d..3184ab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "crates/agentkeys-broker-server", "crates/agentkeys-worker-creds", "crates/agentkeys-worker-memory", + "crates/agentkeys-worker-audit", + "crates/agentkeys-worker-email", ] [workspace.dependencies] diff --git a/crates/agentkeys-broker-server/src/handlers/cap.rs b/crates/agentkeys-broker-server/src/handlers/cap.rs index 930c7b2..e334c8a 100644 --- a/crates/agentkeys-broker-server/src/handlers/cap.rs +++ b/crates/agentkeys-broker-server/src/handlers/cap.rs @@ -58,6 +58,19 @@ impl CapOp { } } +/// Data class the cap-token is bound to. Mirror of +/// `agentkeys_worker_creds::verify::DataClass`. The broker mints with +/// the right variant for each endpoint (`/v1/cap/cred-*` → Credentials, +/// `/v1/cap/memory-*` → Memory) and signs it into the payload; workers +/// reject caps whose data_class doesn't match their bucket. Issue #90 +/// followup — codified in CLAUDE.md. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DataClass { + Credentials, + Memory, +} + /// Cap payload — the signed-over portion of a cap-token. The worker /// verifies `Sha256(json(payload))` against `broker_sig` using the /// broker's session-keypair public key before honoring the cap. @@ -67,6 +80,9 @@ pub struct CapPayload { pub actor_omni: String, pub service: String, pub op: CapOp, + /// Data class binding (issue #90 followup). REQUIRED; workers reject + /// caps whose data_class doesn't match their bucket. + pub data_class: DataClass, pub device_key_hash: String, pub k3_epoch: u64, pub issued_at: u64, @@ -158,7 +174,7 @@ pub async fn cap_cred_store( headers: HeaderMap, Json(req): Json, ) -> Result, CapError> { - mint_cap(state, headers, req, CapOp::Store).await.map(Json) + mint_cap(state, headers, req, CapOp::Store, DataClass::Credentials).await.map(Json) } pub async fn cap_cred_fetch( @@ -166,7 +182,26 @@ pub async fn cap_cred_fetch( headers: HeaderMap, Json(req): Json, ) -> Result, CapError> { - mint_cap(state, headers, req, CapOp::Fetch).await.map(Json) + mint_cap(state, headers, req, CapOp::Fetch, DataClass::Credentials).await.map(Json) +} + +// Memory cap-mint endpoints (issue #90 followup): per-data-class +// explicit binding. The minted cap carries data_class=Memory; the cred +// worker would reject it via verify::check_data_class. +pub async fn cap_memory_put( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, CapError> { + mint_cap(state, headers, req, CapOp::Store, DataClass::Memory).await.map(Json) +} + +pub async fn cap_memory_get( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, CapError> { + mint_cap(state, headers, req, CapOp::Fetch, DataClass::Memory).await.map(Json) } // ─── cap construction ────────────────────────────────────────────────── @@ -176,6 +211,7 @@ async fn mint_cap( headers: HeaderMap, req: CapRequest, op: CapOp, + data_class: DataClass, ) -> Result { validate_hex32(&req.operator_omni, "operator_omni")?; validate_hex32(&req.actor_omni, "actor_omni")?; @@ -256,6 +292,7 @@ async fn mint_cap( actor_omni: format!("0x{}", req_actor.clone()), service: req.service.to_lowercase(), op, + data_class, device_key_hash: format!("0x{}", strip_0x_lc(&req.device_key_hash)), k3_epoch, issued_at: now, @@ -369,18 +406,29 @@ async fn call_get_device( /// bool revoked (word 6, right-aligned) fn parse_device_entry(raw: &str) -> Result { let hex = raw.trim_start_matches("0x"); - if hex.len() < 7 * 64 { + // DeviceEntry post codex H1 (SidecarRegistry.sol) has 11 ABI words: + // word 0 operatorOmni bytes32 + // word 1 actorOmni bytes32 + // word 2 k11CredId bytes32 + // word 3 k11RpIdHash bytes32 (NEW, codex H1) + // word 4 k11PubX uint256 (NEW, codex H1) + // word 5 k11PubY uint256 (NEW, codex H1) + // word 6 tier uint8 (padded) + // word 7 roles uint8 (padded) + // word 8 registeredAt uint64 (padded) + // word 9 lastSignCount uint32 (padded) + // word 10 revoked bool (padded) + if hex.len() < 11 * 64 { return Err(CapError::ChainRpc(format!( - "getDevice returned {} bytes; expected ≥ 7×32", + "getDevice returned {} bytes; expected ≥ 11×32 (post codex H1 struct)", hex.len() / 2 ))); } let operator_omni = hex[0..64].to_lowercase(); let actor_omni = hex[64..128].to_lowercase(); - // word 3 = tier (skip); word 4 = roles; word 5 = registeredAt; word 6 = revoked - let roles_hex = &hex[4 * 64..5 * 64]; - let registered_hex = &hex[5 * 64..6 * 64]; - let revoked_hex = &hex[6 * 64..7 * 64]; + let roles_hex = &hex[7 * 64..8 * 64]; + let registered_hex = &hex[8 * 64..9 * 64]; + let revoked_hex = &hex[10 * 64..11 * 64]; // Take last 2 hex chars (uint8) of the roles word. let roles = u8::from_str_radix(&roles_hex[62..64], 16).unwrap_or(0); let registered_at = u64::from_str_radix(®istered_hex[48..64], 16).unwrap_or(0); @@ -582,17 +630,22 @@ mod tests { #[test] fn parse_device_entry_decodes_well_formed() { - // Hand-built: 7 words of 32 bytes each. operator/actor are - // `0xaa…` and `0xbb…`; tier=1, roles=7 (CAP_MINT|RECOVERY|SCOPE_MGMT), + // 11 ABI words (post codex H1): operator + actor + k11{CredId, + // RpIdHash, PubX, PubY} + tier + roles + registeredAt + + // lastSignCount + revoked. roles=7 (CAP_MINT|RECOVERY|SCOPE_MGMT), // registeredAt=42, revoked=false. let mut raw = String::from("0x"); - raw.push_str(&"a".repeat(64)); // operatorOmni - raw.push_str(&"b".repeat(64)); // actorOmni - raw.push_str(&"0".repeat(64)); // k11CredId (zero) - raw.push_str(&format!("{:0>64x}", 1u64)); // tier=1 - raw.push_str(&format!("{:0>64x}", 7u64)); // roles=7 - raw.push_str(&format!("{:0>64x}", 42u64)); // registeredAt=42 - raw.push_str(&"0".repeat(64)); // revoked=false + raw.push_str(&"a".repeat(64)); // operatorOmni + raw.push_str(&"b".repeat(64)); // actorOmni + raw.push_str(&"0".repeat(64)); // k11CredId + raw.push_str(&"0".repeat(64)); // k11RpIdHash + raw.push_str(&"0".repeat(64)); // k11PubX + raw.push_str(&"0".repeat(64)); // k11PubY + raw.push_str(&format!("{:0>64x}", 1u64)); // tier=1 + raw.push_str(&format!("{:0>64x}", 7u64)); // roles=7 + raw.push_str(&format!("{:0>64x}", 42u64)); // registeredAt=42 + raw.push_str(&"0".repeat(64)); // lastSignCount=0 + raw.push_str(&"0".repeat(64)); // revoked=false let entry = parse_device_entry(&raw).unwrap(); assert_eq!(entry.operator_omni, "a".repeat(64)); assert_eq!(entry.actor_omni, "b".repeat(64)); @@ -604,13 +657,17 @@ mod tests { #[test] fn parse_device_entry_detects_revoked() { let mut raw = String::from("0x"); - raw.push_str(&"a".repeat(64)); - raw.push_str(&"b".repeat(64)); - raw.push_str(&"0".repeat(64)); - raw.push_str(&format!("{:0>64x}", 1u64)); - raw.push_str(&format!("{:0>64x}", 1u64)); - raw.push_str(&format!("{:0>64x}", 100u64)); - raw.push_str(&format!("{:0>64x}", 1u64)); // revoked=true + raw.push_str(&"a".repeat(64)); // operatorOmni + raw.push_str(&"b".repeat(64)); // actorOmni + raw.push_str(&"0".repeat(64)); // k11CredId + raw.push_str(&"0".repeat(64)); // k11RpIdHash + raw.push_str(&"0".repeat(64)); // k11PubX + raw.push_str(&"0".repeat(64)); // k11PubY + raw.push_str(&format!("{:0>64x}", 1u64)); // tier + raw.push_str(&format!("{:0>64x}", 1u64)); // roles + raw.push_str(&format!("{:0>64x}", 100u64)); // registeredAt + raw.push_str(&"0".repeat(64)); // lastSignCount + raw.push_str(&format!("{:0>64x}", 1u64)); // revoked=true let entry = parse_device_entry(&raw).unwrap(); assert!(entry.revoked); } @@ -628,6 +685,7 @@ mod tests { actor_omni: format!("0x{}", "b".repeat(64)), service: "openrouter".into(), op: CapOp::Store, + data_class: DataClass::Credentials, device_key_hash: format!("0x{}", "c".repeat(64)), k3_epoch: 1, issued_at: 1, @@ -637,9 +695,35 @@ mod tests { let j = serde_json::to_string(&p).unwrap(); assert!(j.contains("\"device_key_hash\"")); assert!(j.contains("\"op\":\"store\"")); + assert!(j.contains("\"data_class\":\"credentials\"")); assert!(j.contains("\"issued_at\":1")); } + #[test] + fn cap_payload_serializes_data_class_per_endpoint() { + // The data_class is what makes the cap-token data-class-explicit; + // cred-store endpoints mint with Credentials, memory-* with Memory. + for (dc, expect) in [ + (DataClass::Credentials, "credentials"), + (DataClass::Memory, "memory"), + ] { + let p = CapPayload { + operator_omni: format!("0x{}", "a".repeat(64)), + actor_omni: format!("0x{}", "b".repeat(64)), + service: "openrouter".into(), + op: CapOp::Store, + data_class: dc, + device_key_hash: format!("0x{}", "c".repeat(64)), + k3_epoch: 1, + issued_at: 1, + expires_at: 100, + nonce: "00".repeat(16), + }; + let j = serde_json::to_string(&p).unwrap(); + assert!(j.contains(&format!("\"data_class\":\"{expect}\""))); + } + } + #[test] fn extract_bearer_strips_prefix() { let mut h = HeaderMap::new(); diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index e24df4d..f13a902 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -49,6 +49,11 @@ pub fn create_router(state: SharedState) -> Router { // doing any AES-256-GCM encrypt/decrypt + S3 PUT/GET. .route("/v1/cap/cred-store", post(handlers::cap::cap_cred_store)) .route("/v1/cap/cred-fetch", post(handlers::cap::cap_cred_fetch)) + // Per-data-class memory caps (issue #90 followup). Same shape + + // auth as cred caps but mints with data_class=Memory so the + // memory worker accepts and the cred worker rejects. + .route("/v1/cap/memory-put", post(handlers::cap::cap_memory_put)) + .route("/v1/cap/memory-get", post(handlers::cap::cap_memory_get)) // Stage 7 §3.5 — pluggable auth surface. .route( "/v1/auth/wallet/start", diff --git a/crates/agentkeys-chain/foundry.toml b/crates/agentkeys-chain/foundry.toml index 73fd08a..81c14a0 100644 --- a/crates/agentkeys-chain/foundry.toml +++ b/crates/agentkeys-chain/foundry.toml @@ -21,6 +21,11 @@ evm_version = "london" solc_version = "0.8.20" optimizer = true optimizer_runs = 200 +# P256Verifier.sol uses Jacobian point ops with >16 local stack variables per +# function; legacy codegen hits "stack too deep". The IR pipeline reshuffles +# stack usage and compiles cleanly. No semantic change for the other 4 +# contracts; tested 2026-05-19 against forge test --workspace. +via_ir = true # Match arch.md §6 — events are part of the wire contract; treat them as # strictly as we treat function signatures. Don't let solc silently elide # unused params from event topics. diff --git a/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol b/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol index 72e877e..ea18eb1 100644 --- a/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol +++ b/crates/agentkeys-chain/script/DeployAgentKeysV1.s.sol @@ -2,46 +2,47 @@ pragma solidity ^0.8.20; import {Script, console} from "forge-std/Script.sol"; +import {P256Verifier} from "../src/P256Verifier.sol"; +import {K11Verifier} from "../src/K11Verifier.sol"; import {SidecarRegistry} from "../src/SidecarRegistry.sol"; import {AgentKeysScope} from "../src/AgentKeysScope.sol"; import {K3EpochCounter} from "../src/K3EpochCounter.sol"; import {CredentialAudit} from "../src/CredentialAudit.sol"; -/// @title DeployAgentKeysV1 — atomic deploy of the four v2 stage-1 contracts +/// @title DeployAgentKeysV1 — atomic deploy of the v2 stage-2 contract set /// @notice Called by `scripts/heima-bring-up.sh` step 5 via: /// `forge script script/DeployAgentKeysV1.s.sol --rpc-url /// --private-key <0x...> --broadcast` /// -/// @dev Deploy order matters: SidecarRegistry first (others reference it). -/// AgentKeysScope's constructor takes the registry address; deploy that -/// second. K3EpochCounter + CredentialAudit are independent — last. +/// @dev Deploy order: P256Verifier → K11Verifier → SidecarRegistry → +/// AgentKeysScope → K3EpochCounter → CredentialAudit. Each downstream +/// contract takes the prior addresses via constructor. /// -/// The bring-up script parses stdout for the four "ContractName: -/// 0xAddress" lines to capture addresses; the regex is: +/// The bring-up script parses stdout for "Name: 0xAddress" lines; regex: /// grep -oE ':\s+0x[a-fA-F0-9]{40}' -/// Keep the log shape stable. contract DeployAgentKeysV1 is Script { function run() external { - // Optional override; defaults to the deployer EOA (tx.origin inside the - // vm.startBroadcast block). Stage 2 swaps in an M-of-N multisig address. address signerGov = vm.envOr("SIGNER_GOVERNANCE", address(0)); vm.startBroadcast(); - // tx.origin inside a Forge broadcast IS the --private-key signer. if (signerGov == address(0)) { signerGov = tx.origin; } - SidecarRegistry registry = new SidecarRegistry(); - AgentKeysScope scope = new AgentKeysScope(address(registry)); + P256Verifier p256 = new P256Verifier(); + K11Verifier k11 = new K11Verifier(address(p256)); + SidecarRegistry registry = new SidecarRegistry(address(k11)); + AgentKeysScope scope = new AgentKeysScope(address(registry), address(k11)); K3EpochCounter epoch = new K3EpochCounter(signerGov); - CredentialAudit audit = new CredentialAudit(); + // Audit appendRoot gates on operator-master via the registry (codex M1). + CredentialAudit audit = new CredentialAudit(address(registry)); vm.stopBroadcast(); console.log("Deployer: ", tx.origin); console.log("SignerGovernance:", signerGov); - // Stable "Name: 0xAddress" log shape parsed by heima-bring-up.sh. + console.log("P256Verifier: ", address(p256)); + console.log("K11Verifier: ", address(k11)); console.log("AgentKeysScope: ", address(scope)); console.log("SidecarRegistry: ", address(registry)); console.log("K3EpochCounter: ", address(epoch)); diff --git a/crates/agentkeys-chain/src/AgentKeysScope.sol b/crates/agentkeys-chain/src/AgentKeysScope.sol index 2b00420..f4a062a 100644 --- a/crates/agentkeys-chain/src/AgentKeysScope.sol +++ b/crates/agentkeys-chain/src/AgentKeysScope.sol @@ -1,9 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.20; -/// @notice Minimal SidecarRegistry surface AgentKeysScope needs for auth. +import {K11Verifier} from "./K11Verifier.sol"; + +/// @notice Minimal SidecarRegistry surface AgentKeysScope needs for K11 auth. interface ISidecarRegistry { + struct DeviceEntry { + bytes32 operatorOmni; + bytes32 actorOmni; + bytes32 k11CredId; + bytes32 k11RpIdHash; + uint256 k11PubX; + uint256 k11PubY; + uint8 tier; + uint8 roles; + uint64 registeredAt; + uint32 lastSignCount; + bool revoked; + } + function operatorMasterWallet(bytes32 operatorOmni) external view returns (address); + function operatorNonce(bytes32 operatorOmni) external view returns (uint256); + function getDevice(bytes32 deviceKeyHash) external view returns (DeviceEntry memory); + function ROLE_SCOPE_MGMT() external view returns (uint8); + function TIER_MASTER() external view returns (uint8); } /// @title AgentKeysScope — per-(operator, agent) scope state @@ -11,30 +31,43 @@ interface ISidecarRegistry { /// Read by the broker on cap-mint AND by workers on cap-verify /// (arch.md §12.4, §13.1, §19). /// -/// @dev Stage-1 sovereign-mode authorization: scope mutations require -/// `msg.sender == SidecarRegistry.operatorMasterWallet[operator]`. -/// K11 assertion is required (bytes-non-empty) but not P-256-verified -/// on-chain — same deferral as SidecarRegistry. Per arch.md §6.4 the -/// broker pre-verifies + signs the mutation; on-chain we trust the -/// sender + K11 presence as the gate. +/// @dev Stage-2 (#90) hardening: scope mutations are K11-bound via on-chain +/// P-256 verify against the asserting master's registered K11 pubkey. +/// K11 challenge commits to (operation || operator || agent || services +/// hash || chainid || scopeNonce[op][agent]) so a captured sig cannot +/// be replayed for a different scope target. contract AgentKeysScope { ISidecarRegistry public immutable registry; + K11Verifier public immutable k11Verifier; + + bytes32 public constant OP_SET_SCOPE = keccak256("agentkeys:v1:set-scope"); + bytes32 public constant OP_REVOKE_SCOPE = keccak256("agentkeys:v1:revoke-scope"); struct Scope { - bytes32[] services; // keccak256(name) of each in-scope service - bool readOnly; // if true, agent can READ stored creds but not store new ones - uint128 maxPerCall; // hard per-call cap (units depend on service) - uint128 maxPerPeriod; // sliding-window cap; workers enforce - uint128 maxTotal; // lifetime cap - uint32 periodSeconds; // sliding-window duration (0 = no period limit) - uint64 updatedAt; // block.timestamp of last set - bool exists; // distinguishes "never set" from "set to all-zero" + bytes32[] services; + bool readOnly; + uint128 maxPerCall; + uint128 maxPerPeriod; + uint128 maxTotal; + uint32 periodSeconds; + uint64 updatedAt; + bool exists; + } + + struct K11Assertion { + bytes32 attestingDeviceKeyHash; + bytes authenticatorData; + bytes clientDataJSON; + uint256 challengeLocation; + uint256 r; + uint256 s; } /// @notice operator_omni → agent_omni → Scope mapping(bytes32 => mapping(bytes32 => Scope)) private scopes; + /// @notice per-(operator, agent) monotonic nonce for anti-replay of K11 + mapping(bytes32 => mapping(bytes32 => uint256)) public scopeNonce; - // ─── Events ────────────────────────────────────────────────────────── event ScopeUpdated( bytes32 indexed operatorOmni, bytes32 indexed agentOmni, @@ -47,14 +80,16 @@ contract AgentKeysScope { ); event ScopeRevoked(bytes32 indexed operatorOmni, bytes32 indexed agentOmni); - // ─── Errors ────────────────────────────────────────────────────────── error OperatorNotRegistered(bytes32 operatorOmni); error NotAuthorized(address caller, address expected); - error K11AssertionRequired(); + error InvalidAttestingDevice(bytes32 deviceKeyHash); + error K11VerificationFailed(); + error K11RoleMissing(uint8 required); error ScopeNotSet(bytes32 operatorOmni, bytes32 agentOmni); - constructor(address registryAddr) { + constructor(address registryAddr, address k11VerifierAddr) { registry = ISidecarRegistry(registryAddr); + k11Verifier = K11Verifier(k11VerifierAddr); } /// @notice Grant or replace an agent's scope. Master-mutation, K11-gated. @@ -67,12 +102,30 @@ contract AgentKeysScope { uint128 maxPerPeriod, uint128 maxTotal, uint32 periodSeconds, - bytes calldata k11Assertion + K11Assertion calldata assertion ) external { address master = registry.operatorMasterWallet(operatorOmni); if (master == address(0)) revert OperatorNotRegistered(operatorOmni); if (msg.sender != master) revert NotAuthorized(msg.sender, master); - if (k11Assertion.length == 0) revert K11AssertionRequired(); + + bytes32 servicesDigest = keccak256(abi.encode(services)); + bytes32 expectedChallenge = keccak256( + abi.encode( + OP_SET_SCOPE, + operatorOmni, + agentOmni, + servicesDigest, + readOnly, + maxPerCall, + maxPerPeriod, + maxTotal, + periodSeconds, + block.chainid, + scopeNonce[operatorOmni][agentOmni] + ) + ); + _verifyK11(expectedChallenge, operatorOmni, assertion); + scopeNonce[operatorOmni][agentOmni] += 1; scopes[operatorOmni][agentOmni] = Scope({ services: services, @@ -98,21 +151,34 @@ contract AgentKeysScope { } /// @notice Revoke an agent's entire scope. Master-mutation, K11-gated. - function revokeScope(bytes32 operatorOmni, bytes32 agentOmni, bytes calldata k11Assertion) - external - { + function revokeScope( + bytes32 operatorOmni, + bytes32 agentOmni, + K11Assertion calldata assertion + ) external { address master = registry.operatorMasterWallet(operatorOmni); if (master == address(0)) revert OperatorNotRegistered(operatorOmni); if (msg.sender != master) revert NotAuthorized(msg.sender, master); - if (k11Assertion.length == 0) revert K11AssertionRequired(); if (!scopes[operatorOmni][agentOmni].exists) { revert ScopeNotSet(operatorOmni, agentOmni); } + + bytes32 expectedChallenge = keccak256( + abi.encode( + OP_REVOKE_SCOPE, + operatorOmni, + agentOmni, + block.chainid, + scopeNonce[operatorOmni][agentOmni] + ) + ); + _verifyK11(expectedChallenge, operatorOmni, assertion); + scopeNonce[operatorOmni][agentOmni] += 1; + delete scopes[operatorOmni][agentOmni]; emit ScopeRevoked(operatorOmni, agentOmni); } - /// @notice Read the full scope struct for an (operator, agent) pair. function getScope(bytes32 operatorOmni, bytes32 agentOmni) external view @@ -121,7 +187,6 @@ contract AgentKeysScope { return scopes[operatorOmni][agentOmni]; } - /// @notice Fast-path "is this service in scope?" check for hot worker paths. function isServiceInScope(bytes32 operatorOmni, bytes32 agentOmni, bytes32 serviceHash) external view @@ -129,9 +194,46 @@ contract AgentKeysScope { { Scope storage s = scopes[operatorOmni][agentOmni]; if (!s.exists) return false; - for (uint256 i = 0; i < s.services.length; i++) { + for (uint256 i = 0; i < s.services.length; ++i) { if (s.services[i] == serviceHash) return true; } return false; } + + /// @dev Verify K11 assertion against an asserting MASTER device with the + /// SCOPE_MGMT role. Caller is responsible for incrementing the per- + /// (operator, agent) scopeNonce after this returns. + function _verifyK11( + bytes32 expectedChallenge, + bytes32 expectedOperatorOmni, + K11Assertion calldata a + ) internal view { + ISidecarRegistry.DeviceEntry memory entry = registry.getDevice(a.attestingDeviceKeyHash); + if (entry.registeredAt == 0 || entry.revoked) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + if (entry.tier != registry.TIER_MASTER()) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + if (entry.operatorOmni != expectedOperatorOmni) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + uint8 requiredRole = registry.ROLE_SCOPE_MGMT(); + if ((entry.roles & requiredRole) == 0) { + revert K11RoleMissing(requiredRole); + } + + bool ok = k11Verifier.verifyAssertion( + expectedChallenge, + entry.k11RpIdHash, + a.authenticatorData, + a.clientDataJSON, + a.challengeLocation, + a.r, + a.s, + entry.k11PubX, + entry.k11PubY + ); + if (!ok) revert K11VerificationFailed(); + } } diff --git a/crates/agentkeys-chain/src/CredentialAudit.sol b/crates/agentkeys-chain/src/CredentialAudit.sol index e71cfad..738adc7 100644 --- a/crates/agentkeys-chain/src/CredentialAudit.sol +++ b/crates/agentkeys-chain/src/CredentialAudit.sol @@ -1,6 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.20; +/// @notice Minimal SidecarRegistry surface CredentialAudit needs to gate +/// tier-A `appendRoot` against the operator's master wallet. +interface ISidecarRegistryForAudit { + function operatorMasterWallet(bytes32 operatorOmni) external view returns (address); +} + /// @title CredentialAudit — append-only audit log for credential CRUD /// @notice Per arch.md §15.3 tier C (sovereign default), each credential /// CRUD operation lands on chain as an append. Block-explorer @@ -19,6 +25,18 @@ contract CredentialAudit { uint8 public constant OP_READ = 1; uint8 public constant OP_TEARDOWN = 2; + /// @notice SidecarRegistry — used to gate `appendRoot` so only the + /// operator's master wallet can commit a Merkle root for + /// that operator (codex review finding M1: prevent any + /// account from polluting an operator's root list). + ISidecarRegistryForAudit public immutable registry; + + error NotOperatorMaster(address caller, address expected); + + constructor(address registryAddr) { + registry = ISidecarRegistryForAudit(registryAddr); + } + struct AuditEntry { bytes32 actorOmni; // who did it (the agent, not the operator) bytes32 serviceHash; // keccak256(service_name) @@ -30,6 +48,18 @@ contract CredentialAudit { /// @notice operator_omni → append-only list of entries. mapping(bytes32 => AuditEntry[]) private entries; + /// @notice tier-A Merkle-batched audit roots. The audit-service worker + /// accumulates per-operator events off-chain, builds a Merkle + /// tree, and commits one root per batch. Operators reconstruct + /// per-event proofs from leaves stored in S3 + /// (`s3:///audit/.jsonl`). arch.md §15.3 tier A. + struct AuditRoot { + bytes32 merkleRoot; + uint64 entryCount; + uint64 timestamp; + } + mapping(bytes32 => AuditRoot[]) private roots; + event AuditAppended( bytes32 indexed operatorOmni, bytes32 indexed actorOmni, @@ -39,6 +69,13 @@ contract CredentialAudit { bytes32 payloadHash ); + event AuditRootAppended( + bytes32 indexed operatorOmni, + bytes32 indexed merkleRoot, + uint256 rootIndex, + uint64 entryCount + ); + /// @notice Append an audit row. Open to any caller — the chain itself /// orders writes, and the indexer filters by operator_omni. /// Spam-resistance is via gas cost (every append is a tx fee). @@ -82,4 +119,70 @@ contract CredentialAudit { function entryCount(bytes32 operatorOmni) external view returns (uint256) { return entries[operatorOmni].length; } + + // ─── tier A: Merkle-batched audit roots ────────────────────────────── + /// @notice Commit one Merkle root summarising a batch of audit events. + /// Called by the audit-service worker (arch.md §15.3 tier A). + function appendRoot(bytes32 operatorOmni, bytes32 merkleRoot, uint64 batchEntryCount) + external + { + // Codex review M1: prevent any caller from appending roots for an + // arbitrary operator. Only the operator's master wallet (per the + // SidecarRegistry's first-call-wins bootstrap) can commit roots. + address master = registry.operatorMasterWallet(operatorOmni); + if (master == address(0) || msg.sender != master) { + revert NotOperatorMaster(msg.sender, master); + } + AuditRoot memory r = AuditRoot({ + merkleRoot: merkleRoot, + entryCount: batchEntryCount, + timestamp: uint64(block.timestamp) + }); + uint256 idx = roots[operatorOmni].length; + roots[operatorOmni].push(r); + emit AuditRootAppended(operatorOmni, merkleRoot, idx, batchEntryCount); + } + + function rootCount(bytes32 operatorOmni) external view returns (uint256) { + return roots[operatorOmni].length; + } + + function getRoot(bytes32 operatorOmni, uint256 rootIndex) + external + view + returns (AuditRoot memory) + { + return roots[operatorOmni][rootIndex]; + } + + /// @notice Verify a single audit event is included in a previously + /// committed Merkle root. `leaf` is the application-level hash + /// of the audit event (e.g. keccak256(abi.encode(actor, service, + /// opType, payloadHash, timestamp))). `proof` is a sorted-pairs + /// Merkle proof. + /// + /// @dev Domain-separated hashing (codex M2): leaves are prefixed with + /// 0x00 and internal nodes with 0x01 before keccak256, so an + /// internal node digest cannot impersonate a leaf at a shorter + /// depth. Workers MUST mirror this scheme when producing proofs. + function verifyEntryInRoot( + bytes32 operatorOmni, + uint256 rootIndex, + bytes32[] calldata proof, + bytes32 leaf + ) external view returns (bool) { + if (rootIndex >= roots[operatorOmni].length) return false; + bytes32 root = roots[operatorOmni][rootIndex].merkleRoot; + // Domain-prefix the leaf. + bytes32 computed = keccak256(abi.encodePacked(bytes1(0x00), leaf)); + for (uint256 i = 0; i < proof.length; ++i) { + bytes32 sibling = proof[i]; + if (computed < sibling) { + computed = keccak256(abi.encodePacked(bytes1(0x01), computed, sibling)); + } else { + computed = keccak256(abi.encodePacked(bytes1(0x01), sibling, computed)); + } + } + return computed == root; + } } diff --git a/crates/agentkeys-chain/src/K11Verifier.sol b/crates/agentkeys-chain/src/K11Verifier.sol new file mode 100644 index 0000000..253f0af --- /dev/null +++ b/crates/agentkeys-chain/src/K11Verifier.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {P256Verifier} from "./P256Verifier.sol"; + +/// @title K11Verifier — WebAuthn-aware on-chain assertion verifier +/// @notice Verifies a WebAuthn navigator.credentials.get() assertion ON CHAIN +/// by binding the authenticator's signature to an expected challenge +/// (computed from the operation params + per-operator nonce) and +/// calling the pure-Solidity P-256 verifier. +/// +/// @dev Standard WebAuthn signs `sha256(authData || sha256(clientDataJSON))` +/// where `clientDataJSON.challenge = base64url(our_challenge)`. +/// +/// On-chain flow: +/// 1. Caller computes the expected 32-byte challenge from the +/// operation context (e.g. `keccak256("agentkeys:device-revoke" || +/// operator_omni || target || chainid || nonce)`). +/// 2. CLI invokes WebAuthn with `challenge = our_challenge`; receives +/// `authenticatorData`, `clientDataJSON`, `r`, `s`. +/// 3. CLI submits to chain: (authData, clientDataJSON, challengeLocation, +/// r, s) plus the operation params. +/// 4. Contract computes `expectedB64 = base64url(our_challenge)` (43 chars, +/// no padding — WebAuthn spec). +/// 5. Contract reads `clientDataJSON[challengeLocation..+43]` and compares +/// to `expectedB64`. Since K11 sig commits to the full clientDataJSON +/// via the inner sha256, the attacker cannot lie about the substring +/// while keeping the sig valid. +/// 6. Contract computes `msgHash = sha256(authData || sha256(clientDataJSON))` +/// and calls `P256Verifier.verify(...)`. +/// +/// Anti-replay: the challenge commits to a per-operator monotonic nonce +/// (`SidecarRegistry.operatorNonce[op]`). Contract increments the nonce +/// after each successful master mutation, so captured K11 sigs from a +/// previous tx don't validate. +/// +/// This is the daimo-style pattern (cf. https://github.com/daimo-eth/p256-verifier), +/// minus the wider "WebAuthn options" surface — we only support the +/// fixed-shape challenge binding. +contract K11Verifier { + P256Verifier public immutable p256; + + /// @notice Length of base64url-encoded 32-byte challenge (no padding). + uint256 internal constant CHALLENGE_B64_LEN = 43; + + /// @notice authData flag bits (per WebAuthn spec). + uint8 internal constant FLAG_UP = 0x01; // User Present + uint8 internal constant FLAG_UV = 0x04; // User Verified + + /// @notice Bytes 1..21 of a canonical webauthn.get clientDataJSON: + /// `"type":"webauthn.get"` — used as a prefix-anchor for the + /// on-chain type check. The opening `{` is byte 0; this string + /// starts at byte 1. We compare byte-by-byte to reject + /// `webauthn.create` assertions being replayed as `.get`. + bytes internal constant TYPE_FIELD_WEBAUTHN_GET = + bytes('"type":"webauthn.get"'); + + error ChallengeMismatch(); + error MalformedAuthenticatorData(); + error MalformedClientDataJSON(); + error RpIdHashMismatch(); + error UserPresenceMissing(); + error WrongClientDataType(); + + constructor(address p256Addr) { + p256 = P256Verifier(p256Addr); + } + + /// @notice Verify a WebAuthn assertion is valid + bound to expectedChallenge. + /// @param expectedChallenge 32-byte hash the caller wants K11 to commit to + /// (operation context + nonce). MUST be reconstructable by the contract + /// from operation params so the caller cannot lie. + /// @param authenticatorData Raw 37+ bytes from the authenticator. + /// @param clientDataJSON Raw JSON string from the authenticator. + /// @param challengeLocation Byte offset in clientDataJSON where the + /// base64url-encoded challenge value starts. + /// @param r,s ECDSA signature. + /// @param pubX,pubY P-256 public key for the credential. + function verifyAssertion( + bytes32 expectedChallenge, + bytes32 expectedRpIdHash, + bytes calldata authenticatorData, + bytes calldata clientDataJSON, + uint256 challengeLocation, + uint256 r, + uint256 s, + uint256 pubX, + uint256 pubY + ) external view returns (bool) { + if (authenticatorData.length < 37) revert MalformedAuthenticatorData(); + // clientDataJSON must hold at least: `{"type":"webauthn.get","challenge":"<43>"`. + // That's 1 (opening `{`) + 21 (TYPE_FIELD_WEBAUTHN_GET) + 1 (`,`) + + // 14 (`"challenge":"`) + 43 (challenge) = 80 bytes minimum. + if (clientDataJSON.length < 80) revert MalformedClientDataJSON(); + if (challengeLocation + CHALLENGE_B64_LEN > clientDataJSON.length) { + revert MalformedClientDataJSON(); + } + + // Codex H1 step A: authData[0:32] must equal expectedRpIdHash. + // Without this, an assertion signed under a different RP (e.g. + // attacker-controlled `evil.localhost`) could pass as `localhost`. + for (uint256 i = 0; i < 32; ++i) { + if (authenticatorData[i] != expectedRpIdHash[i]) revert RpIdHashMismatch(); + } + + // Codex H1 step B: authData[32] flags must include UP (user-present) + // and UV (user-verified). Otherwise a stolen K11 device without + // biometric/PIN proof could mint assertions silently. + uint8 flags = uint8(authenticatorData[32]); + if ((flags & (FLAG_UP | FLAG_UV)) != (FLAG_UP | FLAG_UV)) revert UserPresenceMissing(); + + // Codex H1 step C: clientDataJSON must start with `{"type":"webauthn.get"`. + // Rejects `webauthn.create` (enrollment) assertions being replayed + // as `.get` (authentication). Byte 0 is `{`; the type field begins + // at byte 1. + bytes memory expectedType = TYPE_FIELD_WEBAUTHN_GET; + for (uint256 i = 0; i < expectedType.length; ++i) { + if (clientDataJSON[i + 1] != expectedType[i]) revert WrongClientDataType(); + } + + // Step 1: encode expectedChallenge to base64url (43 chars, no padding). + bytes memory expectedB64 = _base64UrlEncode32(expectedChallenge); + + // Step 2: compare to clientDataJSON[challengeLocation..+43]. + for (uint256 i = 0; i < CHALLENGE_B64_LEN; ++i) { + if (clientDataJSON[challengeLocation + i] != expectedB64[i]) { + revert ChallengeMismatch(); + } + } + + // Step 3: compute msgHash = sha256(authData || sha256(clientDataJSON)) + bytes32 cdjHash = sha256(clientDataJSON); + bytes32 msgHash = sha256(abi.encodePacked(authenticatorData, cdjHash)); + + // Step 4: P-256 verify. + return p256.verify(msgHash, r, s, pubX, pubY); + } + + /// @notice Extract the 4-byte signCount (big-endian) from authenticatorData. + /// @dev authData layout: rpIdHash(32) || flags(1) || signCount(4) || ... + function readSignCount(bytes calldata authenticatorData) + external + pure + returns (uint32) + { + if (authenticatorData.length < 37) revert MalformedAuthenticatorData(); + return uint32(bytes4(authenticatorData[33:37])); + } + + /// @dev Encode 32 bytes → 43-char base64url (no padding) per RFC 4648 §5. + function _base64UrlEncode32(bytes32 input) internal pure returns (bytes memory) { + bytes memory alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + bytes memory out = new bytes(CHALLENGE_B64_LEN); + + // Process 30 bytes in 10 groups of 3 bytes → 4 chars each = 40 chars. + for (uint256 g = 0; g < 10; ++g) { + uint256 i = g * 3; + uint256 b0 = uint256(uint8(input[i])); + uint256 b1 = uint256(uint8(input[i + 1])); + uint256 b2 = uint256(uint8(input[i + 2])); + uint256 o = g * 4; + out[o] = alphabet[b0 >> 2]; + out[o + 1] = alphabet[((b0 & 0x3) << 4) | (b1 >> 4)]; + out[o + 2] = alphabet[((b1 & 0xf) << 2) | (b2 >> 6)]; + out[o + 3] = alphabet[b2 & 0x3f]; + } + + // Remaining 2 bytes (index 30, 31) → 3 chars (43 total). + uint256 b30 = uint256(uint8(input[30])); + uint256 b31 = uint256(uint8(input[31])); + out[40] = alphabet[b30 >> 2]; + out[41] = alphabet[((b30 & 0x3) << 4) | (b31 >> 4)]; + out[42] = alphabet[(b31 & 0xf) << 2]; + + return out; + } +} diff --git a/crates/agentkeys-chain/src/P256Verifier.sol b/crates/agentkeys-chain/src/P256Verifier.sol new file mode 100644 index 0000000..41d3592 --- /dev/null +++ b/crates/agentkeys-chain/src/P256Verifier.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +/// @title P256Verifier — pure-Solidity NIST P-256 ECDSA signature verifier +/// @notice Verifies WebAuthn / FIDO2 authenticator (K11) assertions on chain +/// until Heima ships an EIP-7212 / RIP-7212 P-256 precompile. +/// +/// @dev Heima is at London EVM level (verified 2026-05-19: mixHash=null, +/// withdrawalsRoot=null, blobGasUsed=null) — no native P-256 +/// precompile at 0x100 or 0x0b. This contract performs the verify +/// in pure Solidity using Jacobian coordinates + Shamir's trick +/// double-scalar multiplication. Roughly ~700k gas per verify; +/// acceptable because K11 mutations are master-only and rare +/// (scope grant/revoke, multi-master pairing, recovery). Per-call +/// hot paths (broker cap-mint, worker cap-verify) never invoke this. +/// +/// Algorithm reference: standard ECDSA verify with: +/// 1. Validate r,s ∈ [1, n-1] and (Qx, Qy) on curve. +/// 2. e = msgHash mod n +/// 3. sInv = s^-1 mod n +/// 4. u1 = e * sInv mod n; u2 = r * sInv mod n +/// 5. R' = u1*G + u2*Q (Shamir's trick; Jacobian) +/// 6. Return R'.x mod n == r +/// +/// Jacobian formulas: dbl-2001-b and add-2007-bl from EFD +/// (https://hyperelliptic.org/EFD/g1p/auto-shortw-jacobian-3.html). +/// +/// The caller (CLI) pre-extracts (r, s, msgHash, pubX, pubY) from the +/// raw WebAuthn assertion (authData || sha256(clientDataJSON)) and +/// submits the 5 cleaned values. On-chain CBOR/JSON parsing was +/// rejected (option 1 of the design Q): the CLI already has webauthn +/// parsing for the client-side ceremony — re-running it in Solidity +/// would add ~3M gas and ~500 lines of unaudited parser code. +contract P256Verifier { + // ─── NIST P-256 (secp256r1) curve parameters ───────────────────────── + /// @notice Field prime: 2^256 - 2^224 + 2^192 + 2^96 - 1 + uint256 internal constant P = + 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff; + /// @notice Curve order + uint256 internal constant N = + 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551; + /// @notice Curve constant b (a = -3, implicit in dbl-2001-b) + uint256 internal constant B = + 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b; + /// @notice Generator G.x + uint256 internal constant GX = + 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296; + /// @notice Generator G.y + uint256 internal constant GY = + 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5; + + /// @notice Verify a P-256 ECDSA signature. + /// @param msgHash 32-byte hash the authenticator signed (typically + /// sha256(authData || sha256(clientDataJSON))). + /// @param r ECDSA r component. + /// @param s ECDSA s component. + /// @param pubX Public key X coordinate. + /// @param pubY Public key Y coordinate. + /// @return valid True iff signature verifies under (pubX, pubY). + function verify(bytes32 msgHash, uint256 r, uint256 s, uint256 pubX, uint256 pubY) + external + view + returns (bool valid) + { + // Range checks per FIPS 186-5 6.4.2. + if (r == 0 || r >= N) return false; + if (s == 0 || s >= N) return false; + if (pubX >= P || pubY >= P) return false; + if (pubX == 0 && pubY == 0) return false; // disallow point at infinity + if (!_onCurve(pubX, pubY)) return false; + + uint256 e = uint256(msgHash) % N; + uint256 sInv = _modInverse(s, N); + uint256 u1 = mulmod(e, sInv, N); + uint256 u2 = mulmod(r, sInv, N); + + (uint256 rx, bool isInf) = _doubleScalarMul(u1, u2, pubX, pubY); + if (isInf) return false; + return rx % N == r; + } + + /// @dev On-curve check: y² ≡ x³ - 3x + b (mod p). + function _onCurve(uint256 x, uint256 y) internal pure returns (bool) { + uint256 lhs = mulmod(y, y, P); + uint256 x3 = mulmod(mulmod(x, x, P), x, P); + uint256 threeX = mulmod(3, x, P); + // rhs = x³ - 3x + b (mod p) + uint256 rhs = addmod(addmod(x3, P - threeX, P), B, P); + return lhs == rhs; + } + + /// @dev Modular inverse via Fermat's little theorem (m prime) using + /// the modexp precompile at address 0x05. + function _modInverse(uint256 x, uint256 m) internal view returns (uint256 result) { + uint256 fermatExp = m - 2; + assembly { + let ptr := mload(0x40) + mstore(ptr, 0x20) // base length + mstore(add(ptr, 0x20), 0x20) // exp length + mstore(add(ptr, 0x40), 0x20) // mod length + mstore(add(ptr, 0x60), x) + mstore(add(ptr, 0x80), fermatExp) + mstore(add(ptr, 0xa0), m) + if iszero(staticcall(gas(), 0x05, ptr, 0xc0, ptr, 0x20)) { revert(0, 0) } + result := mload(ptr) + } + } + + /// @dev Jacobian point doubling on y² = x³ - 3x + b (a = -3). + /// Formula dbl-2001-b: 4M + 4S + 8add. Returns (0,0,0) for ∞. + function _jacDouble(uint256 x1, uint256 y1, uint256 z1) + internal + pure + returns (uint256 x3, uint256 y3, uint256 z3) + { + if (z1 == 0) return (0, 0, 0); + uint256 delta = mulmod(z1, z1, P); + uint256 gamma = mulmod(y1, y1, P); + uint256 beta = mulmod(x1, gamma, P); + uint256 alpha = + mulmod(3, mulmod(addmod(x1, P - delta, P), addmod(x1, delta, P), P), P); + x3 = addmod(mulmod(alpha, alpha, P), P - mulmod(8, beta, P), P); + uint256 yz = addmod(y1, z1, P); + z3 = addmod(mulmod(yz, yz, P), P - addmod(gamma, delta, P), P); + uint256 fourBetaMinusX3 = addmod(mulmod(4, beta, P), P - x3, P); + y3 = addmod( + mulmod(alpha, fourBetaMinusX3, P), P - mulmod(8, mulmod(gamma, gamma, P), P), P + ); + } + + /// @dev Jacobian + Jacobian addition. Formula add-2007-bl: 11M + 5S + 9add. + /// Handles the P + (-P) = ∞ case explicitly, and delegates to doubling + /// when both inputs are the same point. + function _jacAdd( + uint256 x1, + uint256 y1, + uint256 z1, + uint256 x2, + uint256 y2, + uint256 z2 + ) internal pure returns (uint256 x3, uint256 y3, uint256 z3) { + if (z1 == 0) return (x2, y2, z2); + if (z2 == 0) return (x1, y1, z1); + + uint256 z1z1 = mulmod(z1, z1, P); + uint256 z2z2 = mulmod(z2, z2, P); + uint256 u1 = mulmod(x1, z2z2, P); + uint256 u2 = mulmod(x2, z1z1, P); + uint256 s1 = mulmod(mulmod(y1, z2, P), z2z2, P); + uint256 s2 = mulmod(mulmod(y2, z1, P), z1z1, P); + + if (u1 == u2) { + if (s1 != s2) return (0, 0, 0); // P + (-P) = ∞ + return _jacDouble(x1, y1, z1); + } + + uint256 h = addmod(u2, P - u1, P); + uint256 i = mulmod(mulmod(2, h, P), mulmod(2, h, P), P); + uint256 j = mulmod(h, i, P); + uint256 r = mulmod(2, addmod(s2, P - s1, P), P); + uint256 v = mulmod(u1, i, P); + x3 = addmod(addmod(mulmod(r, r, P), P - j, P), P - mulmod(2, v, P), P); + y3 = addmod( + mulmod(r, addmod(v, P - x3, P), P), P - mulmod(2, mulmod(s1, j, P), P), P + ); + uint256 z1z2 = addmod(z1, z2, P); + z3 = mulmod( + addmod(mulmod(z1z2, z1z2, P), P - addmod(z1z1, z2z2, P), P), h, P + ); + } + + /// @dev Convert a Jacobian X coordinate back to affine. + /// affine.x = jac.x / z² mod p. + function _jacToAffineX(uint256 x, uint256 z) internal view returns (uint256) { + uint256 zInv = _modInverse(z, P); + return mulmod(x, mulmod(zInv, zInv, P), P); + } + + /// @dev Compute u1*G + u2*Q via Shamir's trick (process both scalars + /// simultaneously, sharing doublings). Precomputed table: + /// idx=0 (b1=0,b2=0): no-op + /// idx=1 (b1=0,b2=1): add Q + /// idx=2 (b1=1,b2=0): add G + /// idx=3 (b1=1,b2=1): add G+Q + function _doubleScalarMul(uint256 k1, uint256 k2, uint256 qx, uint256 qy) + internal + view + returns (uint256 affineX, bool isInfinity) + { + // Precompute G+Q once. + (uint256 sumX, uint256 sumY, uint256 sumZ) = _jacAdd(GX, GY, 1, qx, qy, 1); + + // Accumulator starts at ∞. + uint256 x = 0; + uint256 y = 0; + uint256 z = 0; + + for (uint256 i = 0; i < 256; ++i) { + (x, y, z) = _jacDouble(x, y, z); + uint256 b1 = (k1 >> (255 - i)) & 1; + uint256 b2 = (k2 >> (255 - i)) & 1; + uint256 idx = (b1 << 1) | b2; + if (idx == 1) { + (x, y, z) = _jacAdd(x, y, z, qx, qy, 1); + } else if (idx == 2) { + (x, y, z) = _jacAdd(x, y, z, GX, GY, 1); + } else if (idx == 3) { + (x, y, z) = _jacAdd(x, y, z, sumX, sumY, sumZ); + } + } + + if (z == 0) return (0, true); + return (_jacToAffineX(x, z), false); + } +} diff --git a/crates/agentkeys-chain/src/SidecarRegistry.sol b/crates/agentkeys-chain/src/SidecarRegistry.sol index b3ec619..d890e49 100644 --- a/crates/agentkeys-chain/src/SidecarRegistry.sol +++ b/crates/agentkeys-chain/src/SidecarRegistry.sol @@ -1,15 +1,22 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.20; +import {K11Verifier} from "./K11Verifier.sol"; + /// @title SidecarRegistry — per-operator device-key bindings /// @notice Single source of truth for "is this device registered to this operator?" /// Workers re-verify caps against this state on every call (arch.md §10, §13.1). /// -/// @dev Stage-1 minimal shape. K11 WebAuthn assertions are stored as opaque bytes -/// but NOT verified on-chain — the broker pre-verifies via webauthn-rs and we -/// trust the call site. On-chain P-256 verification lands when EIP-7212 is -/// live on Heima (stage 2+). Bytes are still stored so an off-chain auditor -/// can re-check. +/// @dev Stage-2 (#90) hardening: +/// - K11 assertions are P-256 verified ON CHAIN via [K11Verifier] + +/// [P256Verifier] (Heima is at London EVM, no EIP-7212 precompile). +/// - K11 assertion challenge is bound to (operation_kind || operator || +/// params || chainid || operatorNonce[operator]) so a captured K11 +/// sig cannot be replayed for a different operation. +/// - Multi-master M-of-N recovery quorum: `revokeDevice` of a MASTER +/// device requires >= recoveryThreshold[operator] valid K11 sigs +/// from distinct registered masters with the RECOVERY role. +/// - DeviceEntry stores K11 P-256 pubkey (x, y) for on-chain verify. contract SidecarRegistry { // ─── Role bitfield (per device, per arch.md §6.3) ──────────────────── uint8 public constant ROLE_CAP_MINT = 1 << 0; @@ -20,32 +27,46 @@ contract SidecarRegistry { uint8 public constant TIER_MASTER = 1; uint8 public constant TIER_AGENT = 2; + /// @notice Operation kind codes used in challenge-msg construction. + bytes32 public constant OP_REGISTER_2ND_MASTER = keccak256("agentkeys:v1:register-master"); + bytes32 public constant OP_REVOKE_MASTER = keccak256("agentkeys:v1:revoke-master"); + bytes32 public constant OP_SET_THRESHOLD = keccak256("agentkeys:v1:set-recovery-threshold"); + struct DeviceEntry { - bytes32 operatorOmni; // SHA256("agentkeys"||"evm"||initial_master_wallet) per arch.md §14.1 - bytes32 actorOmni; // == operatorOmni for masters; HDKD-derived for agents (arch.md §14) - bytes32 k11CredId; // WebAuthn cred id (0 for agents) - uint8 tier; // TIER_MASTER | TIER_AGENT - uint8 roles; // bitfield ROLE_CAP_MINT | ROLE_RECOVERY | ROLE_SCOPE_MGMT - uint64 registeredAt; // block.timestamp + bytes32 operatorOmni; + bytes32 actorOmni; + bytes32 k11CredId; // WebAuthn cred id (indexer hint; 0 for agents) + bytes32 k11RpIdHash; // sha256(rpId) — bound at register time, checked on every K11 verify (codex H1) + uint256 k11PubX; // P-256 X for on-chain verify (0 for agents) + uint256 k11PubY; // P-256 Y for on-chain verify (0 for agents) + uint8 tier; + uint8 roles; + uint64 registeredAt; + uint32 lastSignCount; // anti-replay per-credential counter bool revoked; } - /// @notice device_pubkey_hash (= keccak256(D_pub)) → DeviceEntry - mapping(bytes32 => DeviceEntry) public devices; + /// @notice WebAuthn assertion payload submitted on chain. Caller provides + /// the raw authData + clientDataJSON; the contract reconstructs + /// the expected challenge from operation params + per-operator + /// nonce and binds the K11 sig to that challenge. + struct K11Assertion { + bytes32 attestingDeviceKeyHash; // which registered master is asserting + bytes authenticatorData; + bytes clientDataJSON; + uint256 challengeLocation; + uint256 r; + uint256 s; + } - /// @notice per-operator device list (for enumeration; gas-bounded by per-call write cost) - mapping(bytes32 => bytes32[]) private operatorDevices; + K11Verifier public immutable k11Verifier; - /// @notice operator → wallet authorized to make master-mutation calls. - /// Set on the FIRST master device register (first-call-wins); - /// subsequent master mutations must come from this address. - /// Sovereign mode (arch.md §22a default): this IS the - /// operator's `current_master_wallet`. + mapping(bytes32 => DeviceEntry) public devices; + mapping(bytes32 => bytes32[]) private operatorDevices; mapping(bytes32 => address) public operatorMasterWallet; + mapping(bytes32 => uint8) public recoveryThreshold; // default 1 (single master can revoke) + mapping(bytes32 => uint256) public operatorNonce; // ++ on every K11-gated mutation - // ─── Events ────────────────────────────────────────────────────────── - /// @notice Indexer hook for "new device bound to operator". Workers - /// consume this to invalidate per-operator caches. event DeviceRegistered( bytes32 indexed deviceKeyHash, bytes32 indexed operatorOmni, @@ -56,68 +77,130 @@ contract SidecarRegistry { ); event DeviceRevoked(bytes32 indexed deviceKeyHash, bytes32 indexed operatorOmni); event OperatorBootstrapped(bytes32 indexed operatorOmni, address indexed masterWallet); + event RecoveryThresholdSet(bytes32 indexed operatorOmni, uint8 newThreshold); - // ─── Errors ────────────────────────────────────────────────────────── error DeviceAlreadyRegistered(bytes32 deviceKeyHash); error DeviceNotRegistered(bytes32 deviceKeyHash); error DeviceAlreadyRevoked(bytes32 deviceKeyHash); error OperatorNotRegistered(bytes32 operatorOmni); error NotAuthorized(address caller, address expected); - error K11AssertionRequired(); - - /// @notice Register the FIRST master device for an operator (first call wins; - /// subsequent master-mutations need this caller). - /// @dev For initial bootstrap, `msg.sender` becomes the operator's master - /// wallet. Per arch.md §10.1, this address is the operator's - /// current_master_wallet in sovereign mode. K11 assertion not required - /// for the first device (chicken-and-egg — there's no prior K11 to - /// attest to). - function registerMasterDevice( + error K11VerificationFailed(); + error InvalidAttestingDevice(bytes32 deviceKeyHash); + error InsufficientQuorum(uint8 got, uint8 required); + error DuplicateAttestor(bytes32 deviceKeyHash); + error StaleSignCount(uint32 got, uint32 last); + error InvalidRecoveryThreshold(); + error K11RoleMissing(uint8 required); + + constructor(address k11VerifierAddr) { + k11Verifier = K11Verifier(k11VerifierAddr); + } + + // ─── Master device registration ────────────────────────────────────── + /// @notice Register the FIRST master device for an operator. First call wins; + /// subsequent master mutations need this sender. + /// @dev For initial bootstrap (no existing master), no K11 assertion is + /// required (chicken-and-egg — there's no prior K11 to attest with). + function registerFirstMasterDevice( bytes32 deviceKeyHash, bytes32 operatorOmni, bytes32 actorOmni, bytes32 k11CredId, + bytes32 k11RpIdHash, + uint256 k11PubX, + uint256 k11PubY, bytes calldata attestation, - uint8 roles, - bytes calldata k11Assertion + uint8 roles ) external { if (devices[deviceKeyHash].registeredAt != 0) { revert DeviceAlreadyRegistered(deviceKeyHash); } - - address existingMaster = operatorMasterWallet[operatorOmni]; - if (existingMaster == address(0)) { - // First master for this operator — bootstrap. - operatorMasterWallet[operatorOmni] = msg.sender; - emit OperatorBootstrapped(operatorOmni, msg.sender); - } else { - // Adding a 2nd+ master device — must come from current master AND - // include a K11 assertion of the existing master (per arch.md §10.3.1 - // cross-device confirmation). - if (msg.sender != existingMaster) revert NotAuthorized(msg.sender, existingMaster); - if (k11Assertion.length == 0) revert K11AssertionRequired(); + if (operatorMasterWallet[operatorOmni] != address(0)) { + // Operator already has a first master; use registerAdditionalMasterDevice. + revert DeviceAlreadyRegistered(deviceKeyHash); } + operatorMasterWallet[operatorOmni] = msg.sender; + recoveryThreshold[operatorOmni] = 1; + emit OperatorBootstrapped(operatorOmni, msg.sender); + devices[deviceKeyHash] = DeviceEntry({ operatorOmni: operatorOmni, actorOmni: actorOmni, k11CredId: k11CredId, + k11RpIdHash: k11RpIdHash, + k11PubX: k11PubX, + k11PubY: k11PubY, tier: TIER_MASTER, roles: roles, registeredAt: uint64(block.timestamp), + lastSignCount: 0, revoked: false }); operatorDevices[operatorOmni].push(deviceKeyHash); emit DeviceRegistered(deviceKeyHash, operatorOmni, actorOmni, TIER_MASTER, roles, k11CredId); - // `attestation` is accepted but only emitted via the indexed event topics - // for now; future versions verify it on-chain (see contract docstring). + attestation; // accepted but only emitted via event topics + } + + /// @notice Register a 2nd+ master device. Existing master signs a K11 + /// assertion authorizing the new device. Per arch.md §10.3.1. + function registerAdditionalMasterDevice( + bytes32 newDeviceKeyHash, + bytes32 operatorOmni, + bytes32 newActorOmni, + bytes32 newK11CredId, + bytes32 newK11RpIdHash, + uint256 newK11PubX, + uint256 newK11PubY, + bytes calldata attestation, + uint8 newRoles, + K11Assertion calldata existingMasterAssertion + ) external { + if (devices[newDeviceKeyHash].registeredAt != 0) { + revert DeviceAlreadyRegistered(newDeviceKeyHash); + } + address master = operatorMasterWallet[operatorOmni]; + if (master == address(0)) revert OperatorNotRegistered(operatorOmni); + if (msg.sender != master) revert NotAuthorized(msg.sender, master); + + bytes32 expectedChallenge = keccak256( + abi.encode( + OP_REGISTER_2ND_MASTER, + operatorOmni, + newDeviceKeyHash, + newRoles, + block.chainid, + operatorNonce[operatorOmni] + ) + ); + _verifyAndConsumeK11( + expectedChallenge, operatorOmni, ROLE_RECOVERY, existingMasterAssertion + ); + + devices[newDeviceKeyHash] = DeviceEntry({ + operatorOmni: operatorOmni, + actorOmni: newActorOmni, + k11CredId: newK11CredId, + k11RpIdHash: newK11RpIdHash, + k11PubX: newK11PubX, + k11PubY: newK11PubY, + tier: TIER_MASTER, + roles: newRoles, + registeredAt: uint64(block.timestamp), + lastSignCount: 0, + revoked: false + }); + operatorDevices[operatorOmni].push(newDeviceKeyHash); + + emit DeviceRegistered( + newDeviceKeyHash, operatorOmni, newActorOmni, TIER_MASTER, newRoles, newK11CredId + ); attestation; } - /// @notice Register an agent device. Called by the operator's master after - /// minting a link code (arch.md §10.2). Agents never hold K11 and - /// only ever get the CAP_MINT role. + /// @notice Register an agent device (link-code redeem path, K10-only). + /// Per arch.md §10.2 — agents never hold K11. function registerAgentDevice( bytes32 deviceKeyHash, bytes32 operatorOmni, @@ -136,9 +219,13 @@ contract SidecarRegistry { operatorOmni: operatorOmni, actorOmni: actorOmni, k11CredId: bytes32(0), + k11RpIdHash: bytes32(0), + k11PubX: 0, + k11PubY: 0, tier: TIER_AGENT, roles: ROLE_CAP_MINT, registeredAt: uint64(block.timestamp), + lastSignCount: 0, revoked: false }); operatorDevices[operatorOmni].push(deviceKeyHash); @@ -150,40 +237,235 @@ contract SidecarRegistry { agentPopSig; } - /// @notice Revoke a device. Master mutations require K11 assertion. - function revokeDevice(bytes32 deviceKeyHash, bytes calldata k11Assertion) external { + /// @notice Revoke an agent device. K10-only (no K11 — agents have none). + function revokeAgentDevice(bytes32 deviceKeyHash) external { DeviceEntry storage entry = devices[deviceKeyHash]; if (entry.registeredAt == 0) revert DeviceNotRegistered(deviceKeyHash); if (entry.revoked) revert DeviceAlreadyRevoked(deviceKeyHash); + if (entry.tier != TIER_AGENT) revert NotAuthorized(msg.sender, address(0)); address master = operatorMasterWallet[entry.operatorOmni]; if (msg.sender != master) revert NotAuthorized(msg.sender, master); - if (entry.tier == TIER_MASTER && k11Assertion.length == 0) { - revert K11AssertionRequired(); + entry.revoked = true; + emit DeviceRevoked(deviceKeyHash, entry.operatorOmni); + } + + /// @notice Revoke a master device. Requires M-of-N K11 assertions where M = + /// recoveryThreshold[operator]. Each assertion must come from a + /// distinct registered MASTER device with the RECOVERY role. + /// + /// @dev Refuses to revoke if doing so would leave fewer than 1 + /// active master with the RECOVERY role for the operator — + /// that would permanently strand the operator (no surviving + /// master means no future master mutations are possible). + /// Same applies to keeping enough recovery-capable masters + /// to satisfy the current threshold. + function revokeMasterDevice( + bytes32 targetDeviceKeyHash, + K11Assertion[] calldata recoveryAssertions + ) external { + DeviceEntry storage entry = devices[targetDeviceKeyHash]; + if (entry.registeredAt == 0) revert DeviceNotRegistered(targetDeviceKeyHash); + if (entry.revoked) revert DeviceAlreadyRevoked(targetDeviceKeyHash); + if (entry.tier != TIER_MASTER) revert NotAuthorized(msg.sender, address(0)); + + bytes32 operatorOmni = entry.operatorOmni; + address master = operatorMasterWallet[operatorOmni]; + if (msg.sender != master) revert NotAuthorized(msg.sender, master); + + uint8 threshold = recoveryThreshold[operatorOmni]; + if (threshold == 0) threshold = 1; + if (recoveryAssertions.length < threshold) { + revert InsufficientQuorum(uint8(recoveryAssertions.length), threshold); } + // Post-revoke must leave at least max(1, threshold) recovery-capable + // masters — never strand the operator. Codex review finding C1. + uint8 activeRecovery = _activeRecoveryMasterCount(operatorOmni); + uint8 remainingAfter = activeRecovery - 1; + uint8 minRequired = threshold > 1 ? threshold : 1; + if (remainingAfter < minRequired) { + revert InsufficientQuorum(remainingAfter, minRequired); + } + + bytes32 expectedChallenge = keccak256( + abi.encode( + OP_REVOKE_MASTER, + operatorOmni, + targetDeviceKeyHash, + block.chainid, + operatorNonce[operatorOmni] + ) + ); + + _verifyQuorum( + expectedChallenge, + operatorOmni, + ROLE_RECOVERY, + recoveryAssertions, + threshold + ); + entry.revoked = true; - emit DeviceRevoked(deviceKeyHash, entry.operatorOmni); + emit DeviceRevoked(targetDeviceKeyHash, operatorOmni); } - /// @notice Returns the device entry. For external consumers; redundant - /// with the auto-generated `devices(bytes32)` accessor but lets - /// callers retrieve the full struct in one call. + /// @notice Update the per-operator recovery threshold. Master-only, + /// K11-gated (single sig from any master with RECOVERY role). + /// + /// @dev Cannot set threshold higher than the current count of + /// active masters with the RECOVERY role — that would create + /// an unsatisfiable quorum and permanently freeze future + /// master mutations. Codex review finding C2. + function setRecoveryThreshold( + bytes32 operatorOmni, + uint8 newThreshold, + K11Assertion calldata assertion + ) external { + address master = operatorMasterWallet[operatorOmni]; + if (master == address(0)) revert OperatorNotRegistered(operatorOmni); + if (msg.sender != master) revert NotAuthorized(msg.sender, master); + if (newThreshold == 0) revert InvalidRecoveryThreshold(); + uint8 activeRecovery = _activeRecoveryMasterCount(operatorOmni); + if (newThreshold > activeRecovery) revert InvalidRecoveryThreshold(); + + bytes32 expectedChallenge = keccak256( + abi.encode( + OP_SET_THRESHOLD, + operatorOmni, + uint256(newThreshold), + block.chainid, + operatorNonce[operatorOmni] + ) + ); + _verifyAndConsumeK11(expectedChallenge, operatorOmni, ROLE_RECOVERY, assertion); + + recoveryThreshold[operatorOmni] = newThreshold; + emit RecoveryThresholdSet(operatorOmni, newThreshold); + } + + // ─── Views ─────────────────────────────────────────────────────────── function getDevice(bytes32 deviceKeyHash) external view returns (DeviceEntry memory) { return devices[deviceKeyHash]; } - /// @notice Enumerate device hashes registered to an operator. Workers - /// typically don't call this on hot paths (they look up by - /// deviceKeyHash directly); useful for explorers + UIs. function getOperatorDevices(bytes32 operatorOmni) external view returns (bytes32[] memory) { return operatorDevices[operatorOmni]; } - /// @notice Quick "is this device valid right now?" check used by workers. function isActive(bytes32 deviceKeyHash) external view returns (bool) { DeviceEntry storage entry = devices[deviceKeyHash]; return entry.registeredAt != 0 && !entry.revoked; } + + // ─── K11 verification helpers ──────────────────────────────────────── + /// @dev Count active master devices with the RECOVERY role for an + /// operator. Used by revokeMasterDevice + setRecoveryThreshold to + /// enforce the "never strand the operator" invariant. O(N) over + /// the operator's device list; N is small (operators run a handful + /// of master devices typically). + function _activeRecoveryMasterCount(bytes32 operatorOmni) internal view returns (uint8) { + bytes32[] storage list = operatorDevices[operatorOmni]; + uint256 count = 0; + for (uint256 i = 0; i < list.length; ++i) { + DeviceEntry storage e = devices[list[i]]; + if ( + e.registeredAt != 0 + && !e.revoked + && e.tier == TIER_MASTER + && (e.roles & ROLE_RECOVERY) != 0 + ) { + unchecked { count += 1; } + } + } + // Saturate at u8 max — operators with > 255 active masters are not a + // real shape (UX collapses long before). + return count > 255 ? 255 : uint8(count); + } + + /// @notice Public view for off-chain tooling — operators inspecting + /// "how many active recovery-capable masters do I have right + /// now?" before raising the recovery threshold. + function activeRecoveryMasterCount(bytes32 operatorOmni) external view returns (uint8) { + return _activeRecoveryMasterCount(operatorOmni); + } + + /// @dev Verify single K11 assertion + bump per-operator nonce + sign-count. + function _verifyAndConsumeK11( + bytes32 expectedChallenge, + bytes32 expectedOperatorOmni, + uint8 requiredRole, + K11Assertion calldata a + ) internal { + _verifyK11One(expectedChallenge, expectedOperatorOmni, requiredRole, a); + operatorNonce[expectedOperatorOmni] += 1; + } + + function _verifyK11One( + bytes32 expectedChallenge, + bytes32 expectedOperatorOmni, + uint8 requiredRole, + K11Assertion calldata a + ) internal { + DeviceEntry storage entry = devices[a.attestingDeviceKeyHash]; + if (entry.registeredAt == 0 || entry.revoked) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + if (entry.tier != TIER_MASTER) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + if (entry.operatorOmni != expectedOperatorOmni) { + revert InvalidAttestingDevice(a.attestingDeviceKeyHash); + } + if ((entry.roles & requiredRole) == 0) { + revert K11RoleMissing(requiredRole); + } + + uint32 signCount = k11Verifier.readSignCount(a.authenticatorData); + if (signCount <= entry.lastSignCount && entry.lastSignCount != 0) { + revert StaleSignCount(signCount, entry.lastSignCount); + } + + bool ok = k11Verifier.verifyAssertion( + expectedChallenge, + entry.k11RpIdHash, + a.authenticatorData, + a.clientDataJSON, + a.challengeLocation, + a.r, + a.s, + entry.k11PubX, + entry.k11PubY + ); + if (!ok) revert K11VerificationFailed(); + + entry.lastSignCount = signCount; + } + + /// @dev Verify M-of-N K11 quorum + bump per-operator nonce. Each assertion + /// must be from a distinct device. + function _verifyQuorum( + bytes32 expectedChallenge, + bytes32 expectedOperatorOmni, + uint8 requiredRole, + K11Assertion[] calldata assertions, + uint8 threshold + ) internal { + uint256 nValid = 0; + for (uint256 i = 0; i < assertions.length; ++i) { + for (uint256 j = 0; j < i; ++j) { + if (assertions[i].attestingDeviceKeyHash == assertions[j].attestingDeviceKeyHash) + { + revert DuplicateAttestor(assertions[i].attestingDeviceKeyHash); + } + } + _verifyK11One(expectedChallenge, expectedOperatorOmni, requiredRole, assertions[i]); + unchecked { + ++nValid; + } + } + if (nValid < threshold) revert InsufficientQuorum(uint8(nValid), threshold); + operatorNonce[expectedOperatorOmni] += 1; + } } diff --git a/crates/agentkeys-chain/test/AgentKeysV1.t.sol b/crates/agentkeys-chain/test/AgentKeysV1.t.sol index 781c758..2ef420b 100644 --- a/crates/agentkeys-chain/test/AgentKeysV1.t.sol +++ b/crates/agentkeys-chain/test/AgentKeysV1.t.sol @@ -2,12 +2,23 @@ pragma solidity ^0.8.20; import {Test, console} from "forge-std/Test.sol"; +import {P256Verifier} from "../src/P256Verifier.sol"; +import {K11Verifier} from "../src/K11Verifier.sol"; import {SidecarRegistry} from "../src/SidecarRegistry.sol"; import {AgentKeysScope} from "../src/AgentKeysScope.sol"; import {K3EpochCounter} from "../src/K3EpochCounter.sol"; import {CredentialAudit} from "../src/CredentialAudit.sol"; +/// @title AgentKeysV1Test — sanity tests for the v2 stage-2 contract set. +/// @dev K11-gated flows are tested with EMPTY/INVALID assertions to verify +/// the guard logic rejects them — they SHOULD revert. End-to-end with +/// a real valid K11 assertion is tested in the CLI integration tests +/// (Rust side), where we have a software P-256 authenticator that can +/// produce the full (authData || clientDataJSON || r, s) chain bound +/// to a contract-computed challenge. contract AgentKeysV1Test is Test { + P256Verifier p256; + K11Verifier k11; SidecarRegistry registry; AgentKeysScope scope; K3EpochCounter epoch; @@ -17,7 +28,7 @@ contract AgentKeysV1Test is Test { address attacker; bytes32 operatorOmni = keccak256("operator-alice"); - bytes32 actorOmniMaster = operatorOmni; // arch.md §14: master's actor_omni == operatorOmni + bytes32 actorOmniMaster = operatorOmni; bytes32 actorOmniAgentA = keccak256(abi.encodePacked(operatorOmni, "//agent-A")); bytes32 deviceKeyHashMaster = keccak256("D_pub_master"); @@ -25,95 +36,127 @@ contract AgentKeysV1Test is Test { bytes32 deviceKeyHash2ndMaster = keccak256("D_pub_master2"); bytes32 k11CredId = keccak256("k11-cred-master"); - bytes k11Assertion = hex"deadbeef"; - bytes attestation = hex"cafe"; + bytes32 k11RpIdHash = keccak256("localhost"); // codex H1: bound at register time + + // Stub pubkey coords. Bogus values — the contracts only check liveness + // semantics in this test file; signature verification with real P-256 + // numbers is covered by P256Verifier.t.sol + K11Verifier.t.sol and the + // Rust-side CLI integration tests. + uint256 k11PubX = uint256(keccak256("stub-k11-pubX")); + uint256 k11PubY = uint256(keccak256("stub-k11-pubY")); function setUp() public { master = makeAddr("master"); attacker = makeAddr("attacker"); - registry = new SidecarRegistry(); - scope = new AgentKeysScope(address(registry)); + p256 = new P256Verifier(); + k11 = new K11Verifier(address(p256)); + registry = new SidecarRegistry(address(k11)); + scope = new AgentKeysScope(address(registry), address(k11)); epoch = new K3EpochCounter(address(this)); - audit = new CredentialAudit(); + audit = new CredentialAudit(address(registry)); } - // ─── SidecarRegistry: register first master ────────────────────────── - function test_RegisterMasterDevice_FirstCallBootstrapsOperator() public { - // Precompute role bitfield BEFORE the prank — `registry.ROLE_*()` calls - // would each consume a single-use `vm.prank` and the actual - // registerMasterDevice call would then run with the default sender. + // ─── SidecarRegistry: first-master bootstrap ───────────────────────── + function test_RegisterFirstMasterDevice_BootstrapsOperator() public { uint8 fullRoles = registry.ROLE_CAP_MINT() | registry.ROLE_RECOVERY() | registry.ROLE_SCOPE_MGMT(); - uint8 masterTier = registry.TIER_MASTER(); vm.prank(master); - registry.registerMasterDevice( + registry.registerFirstMasterDevice( deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, - attestation, - fullRoles, - "" // first-call: no K11 assertion required + k11RpIdHash, + k11PubX, + k11PubY, + hex"cafe", + fullRoles ); assertEq(registry.operatorMasterWallet(operatorOmni), master); + assertEq(uint256(registry.recoveryThreshold(operatorOmni)), 1); SidecarRegistry.DeviceEntry memory entry = registry.getDevice(deviceKeyHashMaster); assertEq(entry.operatorOmni, operatorOmni); - assertEq(entry.actorOmni, actorOmniMaster); - assertEq(uint256(entry.tier), uint256(masterTier)); + assertEq(uint256(entry.tier), uint256(registry.TIER_MASTER())); assertFalse(entry.revoked); + assertEq(entry.k11PubX, k11PubX); + assertEq(entry.k11PubY, k11PubY); } - function test_RegisterMasterDevice_RejectsDuplicate() public { + function test_RegisterFirstMaster_RejectsDuplicateBootstrap() public { vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" + registry.registerFirstMasterDevice( + deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, k11RpIdHash, k11PubX, k11PubY, "", 7 ); + // Second bootstrap with a different device hash → rejected because + // operatorMasterWallet is now set. vm.prank(master); vm.expectRevert( abi.encodeWithSelector( - SidecarRegistry.DeviceAlreadyRegistered.selector, deviceKeyHashMaster + SidecarRegistry.DeviceAlreadyRegistered.selector, deviceKeyHash2ndMaster ) ); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" + registry.registerFirstMasterDevice( + deviceKeyHash2ndMaster, + operatorOmni, + actorOmniMaster, + k11CredId, + k11RpIdHash, + k11PubX, + k11PubY, + "", + 7 ); } - function test_RegisterSecondMaster_RequiresExistingMasterAndK11() public { - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" - ); - // attacker can't add a 2nd master + // ─── SidecarRegistry: 2nd master device requires K11 ──────────────── + function test_RegisterAdditionalMaster_RejectsAttacker() public { + _registerFirstMaster(); + SidecarRegistry.K11Assertion memory bogusK11 = _bogusAssertion(deviceKeyHashMaster); vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector(SidecarRegistry.NotAuthorized.selector, attacker, master) ); - registry.registerMasterDevice( - deviceKeyHash2ndMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, k11Assertion - ); - // master can, with K11 - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHash2ndMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, k11Assertion + registry.registerAdditionalMasterDevice( + deviceKeyHash2ndMaster, + operatorOmni, + actorOmniMaster, + k11CredId, + k11RpIdHash, + k11PubX, + k11PubY, + hex"cafe", + 3, + bogusK11 ); - // master can NOT without K11 (after bootstrap, K11 is required for masters) - bytes32 thirdHash = keccak256("third"); + } + + function test_RegisterAdditionalMaster_RejectsInvalidK11() public { + _registerFirstMaster(); + SidecarRegistry.K11Assertion memory bogusK11 = _bogusAssertion(deviceKeyHashMaster); + // Master submits with bogus K11 → fails challenge match (or P-256 + // verify). Exact revert: either ChallengeMismatch (caller's bogus + // clientDataJSON is wrong) or K11VerificationFailed. We accept any + // revert. vm.prank(master); - vm.expectRevert(SidecarRegistry.K11AssertionRequired.selector); - registry.registerMasterDevice( - thirdHash, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" + vm.expectRevert(); + registry.registerAdditionalMasterDevice( + deviceKeyHash2ndMaster, + operatorOmni, + actorOmniMaster, + k11CredId, + k11RpIdHash, + k11PubX, + k11PubY, + hex"cafe", + 3, + bogusK11 ); } - // ─── SidecarRegistry: agent registration ───────────────────────────── + // ─── SidecarRegistry: agent ────────────────────────────────────────── function test_RegisterAgent_RequiresMasterCaller() public { - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" - ); - // attacker can't register an agent + _registerFirstMaster(); vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector(SidecarRegistry.NotAuthorized.selector, attacker, master) @@ -121,7 +164,6 @@ contract AgentKeysV1Test is Test { registry.registerAgentDevice( deviceKeyHashAgentA, operatorOmni, actorOmniAgentA, hex"deadbeef", hex"cafe" ); - // master can vm.prank(master); registry.registerAgentDevice( deviceKeyHashAgentA, operatorOmni, actorOmniAgentA, hex"deadbeef", hex"cafe" @@ -130,6 +172,8 @@ contract AgentKeysV1Test is Test { assertEq(uint256(entry.tier), uint256(registry.TIER_AGENT())); assertEq(uint256(entry.roles), uint256(registry.ROLE_CAP_MINT())); assertEq(entry.k11CredId, bytes32(0)); + assertEq(entry.k11PubX, 0); + assertEq(entry.k11PubY, 0); } function test_RegisterAgent_RejectsBeforeOperatorBootstrap() public { @@ -141,97 +185,70 @@ contract AgentKeysV1Test is Test { ); } - // ─── SidecarRegistry: revoke ───────────────────────────────────────── - function test_RevokeDevice() public { - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" - ); + function test_RevokeAgent() public { + _registerFirstMaster(); vm.prank(master); registry.registerAgentDevice( deviceKeyHashAgentA, operatorOmni, actorOmniAgentA, hex"deadbeef", hex"cafe" ); - - // Revoke the agent — no K11 required for agent revoke vm.prank(master); - registry.revokeDevice(deviceKeyHashAgentA, ""); + registry.revokeAgentDevice(deviceKeyHashAgentA); assertFalse(registry.isActive(deviceKeyHashAgentA)); + } - // Master revoke requires K11 - vm.prank(master); - vm.expectRevert(SidecarRegistry.K11AssertionRequired.selector); - registry.revokeDevice(deviceKeyHashMaster, ""); + function test_RevokeAgent_RejectsRevokingMaster() public { + _registerFirstMaster(); vm.prank(master); - registry.revokeDevice(deviceKeyHashMaster, k11Assertion); - assertFalse(registry.isActive(deviceKeyHashMaster)); + vm.expectRevert(); + registry.revokeAgentDevice(deviceKeyHashMaster); } - // ─── AgentKeysScope ────────────────────────────────────────────────── - function test_SetScope() public { + // ─── SidecarRegistry: master revoke requires quorum ────────────────── + function test_RevokeMaster_RejectsInsufficientQuorum() public { + _registerFirstMaster(); + SidecarRegistry.K11Assertion[] memory empty = new SidecarRegistry.K11Assertion[](0); vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" + vm.expectRevert( + abi.encodeWithSelector(SidecarRegistry.InsufficientQuorum.selector, uint8(0), uint8(1)) ); + registry.revokeMasterDevice(deviceKeyHashMaster, empty); + } - bytes32[] memory services = new bytes32[](2); - services[0] = keccak256("openrouter"); - services[1] = keccak256("brave-search"); - + function test_RevokeMaster_RejectsInvalidAssertion() public { + _registerFirstMaster(); + SidecarRegistry.K11Assertion[] memory bogus = new SidecarRegistry.K11Assertion[](1); + bogus[0] = _bogusAssertion(deviceKeyHashMaster); vm.prank(master); - scope.setScopeWithWebauthn( - operatorOmni, - actorOmniAgentA, - services, - false, // read_only - 1000, // maxPerCall - 10000, // maxPerPeriod - 100000, // maxTotal - 86400, // period: 1 day - k11Assertion - ); - - AgentKeysScope.Scope memory s = scope.getScope(operatorOmni, actorOmniAgentA); - assertTrue(s.exists); - assertEq(s.services.length, 2); - assertEq(s.services[0], keccak256("openrouter")); - assertTrue(scope.isServiceInScope(operatorOmni, actorOmniAgentA, keccak256("openrouter"))); - assertFalse(scope.isServiceInScope(operatorOmni, actorOmniAgentA, keccak256("elevenlabs"))); + vm.expectRevert(); + registry.revokeMasterDevice(deviceKeyHashMaster, bogus); } + // ─── AgentKeysScope: rejects without K11 ───────────────────────────── function test_SetScope_RejectsAttacker() public { - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" - ); + _registerFirstMaster(); bytes32[] memory services = new bytes32[](0); - + AgentKeysScope.K11Assertion memory bogus = _bogusScopeAssertion(deviceKeyHashMaster); vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector(AgentKeysScope.NotAuthorized.selector, attacker, master) ); scope.setScopeWithWebauthn( - operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, k11Assertion + operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, bogus ); } - function test_RevokeScope() public { - vm.prank(master); - registry.registerMasterDevice( - deviceKeyHashMaster, operatorOmni, actorOmniMaster, k11CredId, attestation, 7, "" - ); - bytes32[] memory services = new bytes32[](1); - services[0] = keccak256("openrouter"); + function test_SetScope_RejectsInvalidK11() public { + _registerFirstMaster(); + bytes32[] memory services = new bytes32[](0); + AgentKeysScope.K11Assertion memory bogus = _bogusScopeAssertion(deviceKeyHashMaster); vm.prank(master); + vm.expectRevert(); scope.setScopeWithWebauthn( - operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, k11Assertion + operatorOmni, actorOmniAgentA, services, false, 0, 0, 0, 0, bogus ); - vm.prank(master); - scope.revokeScope(operatorOmni, actorOmniAgentA, k11Assertion); - AgentKeysScope.Scope memory s = scope.getScope(operatorOmni, actorOmniAgentA); - assertFalse(s.exists); } - // ─── K3EpochCounter ────────────────────────────────────────────────── + // ─── K3EpochCounter (unchanged from PR #87) ────────────────────────── function test_K3EpochCounter_AdvanceAndTransferGovernance() public { assertEq(epoch.currentEpoch(), 1); epoch.advanceEpoch(); @@ -253,17 +270,141 @@ contract AgentKeysV1Test is Test { assertEq(epoch.currentEpoch(), 3); } - // ─── CredentialAudit ───────────────────────────────────────────────── + // ─── CredentialAudit (unchanged from PR #87) ───────────────────────── function test_CredentialAudit_AppendAndRead() public { bytes32 svc = keccak256("openrouter"); bytes32 payload = keccak256("blob-1"); audit.append(operatorOmni, actorOmniAgentA, svc, audit.OP_STORE(), payload); audit.append(operatorOmni, actorOmniAgentA, svc, audit.OP_READ(), payload); assertEq(audit.entryCount(operatorOmni), 2); - CredentialAudit.AuditEntry[] memory page = audit.getEntries(operatorOmni, 0, 10); assertEq(page.length, 2); assertEq(page[0].opType, audit.OP_STORE()); assertEq(page[1].opType, audit.OP_READ()); } + + // ─── CredentialAudit tier-A Merkle root path (#90 follow-up) ──────── + function test_CredentialAudit_AppendRoot_AndVerifyMembership() public { + _registerFirstMaster(); // operatorMasterWallet must be set for appendRoot auth (codex M1). + + // Build a 4-leaf Merkle tree of audit events with domain separation + // (codex M2): 0x00 prefix on leaves, 0x01 on internal nodes. + bytes32 raw0 = keccak256("audit-event-0"); + bytes32 raw1 = keccak256("audit-event-1"); + bytes32 raw2 = keccak256("audit-event-2"); + bytes32 raw3 = keccak256("audit-event-3"); + bytes32 leaf0 = _leafPrefix(raw0); + bytes32 leaf1 = _leafPrefix(raw1); + bytes32 leaf2 = _leafPrefix(raw2); + bytes32 leaf3 = _leafPrefix(raw3); + bytes32 h01 = _hashPair(leaf0, leaf1); + bytes32 h23 = _hashPair(leaf2, leaf3); + bytes32 root = _hashPair(h01, h23); + + vm.prank(master); + audit.appendRoot(operatorOmni, root, 4); + assertEq(audit.rootCount(operatorOmni), 1); + + // Verify leaf2 is in the root via proof [leaf3, h01]. + // Note: pass the RAW leaf to verifyEntryInRoot — the contract + // applies the prefix internally. + bytes32[] memory proof = new bytes32[](2); + proof[0] = leaf3; + proof[1] = h01; + assertTrue(audit.verifyEntryInRoot(operatorOmni, 0, proof, raw2)); + + // Reject a tampered leaf. + assertFalse(audit.verifyEntryInRoot(operatorOmni, 0, proof, keccak256("nope"))); + + // Reject out-of-range root index. + bytes32[] memory emptyProof = new bytes32[](0); + assertFalse(audit.verifyEntryInRoot(operatorOmni, 99, emptyProof, raw0)); + + // Attacker tries to pass an internal-node digest as a leaf — the + // domain prefix makes it impossible. Codex M2 fix. + bytes32[] memory shortProof = new bytes32[](1); + shortProof[0] = h23; + // Try: claim h01 (internal node) is a leaf. verifyEntryInRoot + // prefixes it with 0x00 → keccak(0x00 || h01) ≠ h01. + assertFalse(audit.verifyEntryInRoot(operatorOmni, 0, shortProof, h01)); + } + + function test_CredentialAudit_AppendRoot_RejectsNonMaster() public { + _registerFirstMaster(); + bytes32 root = keccak256("dummy"); + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(CredentialAudit.NotOperatorMaster.selector, attacker, master) + ); + audit.appendRoot(operatorOmni, root, 1); + } + + function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { + // Internal-node prefix per codex M2. + return a < b + ? keccak256(abi.encodePacked(bytes1(0x01), a, b)) + : keccak256(abi.encodePacked(bytes1(0x01), b, a)); + } + + function _leafPrefix(bytes32 raw) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(bytes1(0x00), raw)); + } + + // ─── Helpers ───────────────────────────────────────────────────────── + function _registerFirstMaster() internal { + uint8 fullRoles = + registry.ROLE_CAP_MINT() | registry.ROLE_RECOVERY() | registry.ROLE_SCOPE_MGMT(); + vm.prank(master); + registry.registerFirstMasterDevice( + deviceKeyHashMaster, + operatorOmni, + actorOmniMaster, + k11CredId, + k11RpIdHash, + k11PubX, + k11PubY, + "", + fullRoles + ); + } + + /// @dev Bogus assertion for SidecarRegistry — fails challenge or P-256 + /// verify by construction; used to exercise the revert paths. + function _bogusAssertion(bytes32 attestingDevice) + internal + pure + returns (SidecarRegistry.K11Assertion memory) + { + bytes memory authData = new bytes(37); + bytes memory cdj = bytes( + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}' + ); + return SidecarRegistry.K11Assertion({ + attestingDeviceKeyHash: attestingDevice, + authenticatorData: authData, + clientDataJSON: cdj, + challengeLocation: 36, + r: 1, + s: 1 + }); + } + + function _bogusScopeAssertion(bytes32 attestingDevice) + internal + pure + returns (AgentKeysScope.K11Assertion memory) + { + bytes memory authData = new bytes(37); + bytes memory cdj = bytes( + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}' + ); + return AgentKeysScope.K11Assertion({ + attestingDeviceKeyHash: attestingDevice, + authenticatorData: authData, + clientDataJSON: cdj, + challengeLocation: 36, + r: 1, + s: 1 + }); + } } diff --git a/crates/agentkeys-chain/test/K11Verifier.t.sol b/crates/agentkeys-chain/test/K11Verifier.t.sol new file mode 100644 index 0000000..c78eef4 --- /dev/null +++ b/crates/agentkeys-chain/test/K11Verifier.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {P256Verifier} from "../src/P256Verifier.sol"; +import {K11Verifier} from "../src/K11Verifier.sol"; + +/// @title K11VerifierTest — smoke tests for challenge-binding + WebAuthn +/// envelope checks (rpIdHash, UP|UV flags, type prefix). +contract K11VerifierTest is Test { + K11Verifier verifier; + + /// Test fixtures used across the suite. authData has the right layout so + /// each test only changes the bit it's exercising. + bytes32 constant RP_ID_HASH = keccak256("localhost"); + uint8 constant FLAGS_OK = 0x05; // UP=0x01 | UV=0x04 + + function setUp() public { + P256Verifier p256 = new P256Verifier(); + verifier = new K11Verifier(address(p256)); + } + + /// Build a 37-byte authData with the right rpIdHash + flags + zero counter. + function _authData(bytes32 rpIdHash, uint8 flags) internal pure returns (bytes memory) { + bytes memory ad = new bytes(37); + for (uint256 i = 0; i < 32; ++i) ad[i] = rpIdHash[i]; + ad[32] = bytes1(flags); + // bytes 33..37 = sign count (zero) + return ad; + } + + function test_challenge_mismatch_reverts() public { + bytes32 expectedChallenge = keccak256("op:1"); + bytes memory authData = _authData(RP_ID_HASH, FLAGS_OK); + string memory wrongJSON = + '{"type":"webauthn.get","challenge":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","origin":"https://localhost"}'; + uint256 challengeLocation = 36; + + vm.expectRevert(K11Verifier.ChallengeMismatch.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(wrongJSON), + challengeLocation, 1, 1, 1, 1 + ); + } + + function test_short_authData_reverts() public { + bytes32 expectedChallenge = keccak256("op:1"); + bytes memory shortAuthData = new bytes(36); + string memory json = + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}'; + vm.expectRevert(K11Verifier.MalformedAuthenticatorData.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, shortAuthData, bytes(json), 36, 1, 1, 1, 1 + ); + } + + function test_clientDataJSON_too_short_reverts() public { + bytes32 expectedChallenge = keccak256("op:1"); + bytes memory authData = _authData(RP_ID_HASH, FLAGS_OK); + string memory tooShort = "0123456789"; + vm.expectRevert(K11Verifier.MalformedClientDataJSON.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(tooShort), 0, 1, 1, 1, 1 + ); + } + + function test_rpIdHash_mismatch_reverts() public { + bytes32 expectedChallenge = bytes32(0); + // authData has rpIdHash = sha256("evil.localhost") (wrong) + bytes memory authData = _authData(keccak256("evil.localhost"), FLAGS_OK); + string memory goodJSON = + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}'; + vm.expectRevert(K11Verifier.RpIdHashMismatch.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(goodJSON), 36, 1, 1, 1, 1 + ); + } + + function test_missing_user_presence_reverts() public { + bytes32 expectedChallenge = bytes32(0); + // authData has rpIdHash OK but flags=0 (no UP, no UV) + bytes memory authData = _authData(RP_ID_HASH, 0x00); + string memory goodJSON = + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}'; + vm.expectRevert(K11Verifier.UserPresenceMissing.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(goodJSON), 36, 1, 1, 1, 1 + ); + + // UP only (no UV) still reverts. + authData = _authData(RP_ID_HASH, 0x01); + vm.expectRevert(K11Verifier.UserPresenceMissing.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(goodJSON), 36, 1, 1, 1, 1 + ); + } + + function test_wrong_clientData_type_reverts() public { + bytes32 expectedChallenge = bytes32(0); + bytes memory authData = _authData(RP_ID_HASH, FLAGS_OK); + // type = webauthn.create (enrollment) → should be rejected when used + // for assertion verification (replay-across-mode attack). + string memory createJSON = + '{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}'; + vm.expectRevert(K11Verifier.WrongClientDataType.selector); + verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(createJSON), 39, 1, 1, 1, 1 + ); + } + + function test_readSignCount() public view { + bytes memory authData = _authData(RP_ID_HASH, FLAGS_OK); + authData[33] = 0x12; + authData[34] = 0x34; + authData[35] = 0x56; + authData[36] = 0x78; + uint32 count = verifier.readSignCount(authData); + assertEq(count, 0x12345678); + } + + function test_readSignCount_zero() public view { + bytes memory authData = new bytes(37); + uint32 count = verifier.readSignCount(authData); + assertEq(count, 0); + } + + function test_base64_encoding_of_zero_challenge() public { + // All-zero challenge → 43 'A's in base64url. All envelope checks + // pass; P-256 verify returns false on bogus r/s/pubkey. + bytes32 expectedChallenge = bytes32(0); + bytes memory authData = _authData(RP_ID_HASH, FLAGS_OK); + string memory goodJSON = + '{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","origin":"https://localhost"}'; + uint256 challengeLocation = 36; + bool ok = verifier.verifyAssertion( + expectedChallenge, RP_ID_HASH, authData, bytes(goodJSON), + challengeLocation, 1, 1, 1, 1 + ); + assertFalse(ok); + } +} diff --git a/crates/agentkeys-chain/test/P256Verifier.t.sol b/crates/agentkeys-chain/test/P256Verifier.t.sol new file mode 100644 index 0000000..91fbd19 --- /dev/null +++ b/crates/agentkeys-chain/test/P256Verifier.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import {P256Verifier} from "../src/P256Verifier.sol"; + +/// @title P256VerifierTest — cross-check against known good test vectors. +/// @dev Test vectors are from RFC 6979 §A.2.5 (P-256 / SHA-256, msg="sample") +/// and a synthetic "test" vector (msg="test"). Both are deterministic +/// ECDSA so r/s match across implementations. +contract P256VerifierTest is Test { + P256Verifier verifier; + + function setUp() public { + verifier = new P256Verifier(); + } + + // ─── RFC 6979 §A.2.5 — P-256 / SHA-256 — msg = "sample" ────────────── + // Private key: c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721 + function test_verify_rfc6979_sample() public view { + bytes32 msgHash = 0xaf2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf; + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + uint256 r = 0xefd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716; + uint256 s = 0xf7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8; + assertTrue(verifier.verify(msgHash, r, s, pubX, pubY), "RFC 6979 sample should verify"); + } + + // ─── RFC 6979 §A.2.5 — P-256 / SHA-256 — msg = "test" ──────────────── + function test_verify_rfc6979_test() public view { + bytes32 msgHash = 0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08; + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + uint256 r = 0xf1abb023518351cd71d881567b1ea663ed3efcf6c5132b354f28d3b0b7d38367; + uint256 s = 0x019f4113742a2b14bd25926b49c649155f267e60d3814b4c0cc84250e46f0083; + assertTrue(verifier.verify(msgHash, r, s, pubX, pubY), "RFC 6979 test should verify"); + } + + // ─── Mutation rejections ───────────────────────────────────────────── + function test_verify_rejects_tampered_msg() public view { + bytes32 msgHash = 0xaf2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf; + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + uint256 r = 0xefd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716; + uint256 s = 0xf7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8; + + // Flip a byte in msgHash → must fail. + bytes32 tampered = bytes32(uint256(msgHash) ^ uint256(0x1)); + assertFalse(verifier.verify(tampered, r, s, pubX, pubY)); + } + + function test_verify_rejects_zero_r() public view { + bytes32 msgHash = bytes32(uint256(1)); + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + assertFalse(verifier.verify(msgHash, 0, 1, pubX, pubY)); + } + + function test_verify_rejects_zero_s() public view { + bytes32 msgHash = bytes32(uint256(1)); + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + assertFalse(verifier.verify(msgHash, 1, 0, pubX, pubY)); + } + + function test_verify_rejects_pubkey_not_on_curve() public view { + bytes32 msgHash = bytes32(uint256(1)); + // pubX changed by 1 — definitely off-curve. + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb7; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + assertFalse(verifier.verify(msgHash, 1, 1, pubX, pubY)); + } + + function test_verify_rejects_point_at_infinity() public view { + assertFalse(verifier.verify(bytes32(uint256(1)), 1, 1, 0, 0)); + } + + // ─── Gas measurement ───────────────────────────────────────────────── + function test_gas_singleVerify() public view { + bytes32 msgHash = 0xaf2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf; + uint256 pubX = 0x60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6; + uint256 pubY = 0x7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299; + uint256 r = 0xefd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716; + uint256 s = 0xf7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8; + + uint256 gasBefore = gasleft(); + bool ok = verifier.verify(msgHash, r, s, pubX, pubY); + uint256 gasUsed = gasBefore - gasleft(); + console.log("P256 verify gas:", gasUsed); + assertTrue(ok); + // London EVM block gas limit is ~30M; we want comfortably under that. + assertLt(gasUsed, 2_000_000, "verify must fit under 2M gas"); + } +} diff --git a/crates/agentkeys-cli/src/k11_webauthn.rs b/crates/agentkeys-cli/src/k11_webauthn.rs index 487d42f..0d076f2 100644 --- a/crates/agentkeys-cli/src/k11_webauthn.rs +++ b/crates/agentkeys-cli/src/k11_webauthn.rs @@ -196,6 +196,41 @@ pub struct WebauthnEnrollment { pub enrolled_at_unix: u64, /// `"webauthn"` (NOT `"stage1-stub"`). pub mode: String, + /// Optional RP ID override. Default `"localhost"`. Companion daemon mode + /// uses `"companion.localhost"` to get a SECOND, distinct credential in + /// the platform keychain on the same Mac. + #[serde(default)] + pub rp_id: Option, +} + +/// Chain-ready K11 assertion payload — all the fields the on-chain +/// K11Verifier / SidecarRegistry need, pre-extracted from the raw WebAuthn +/// outputs. Produced by [`assert_webauthn_for_chain`] for callers building +/// on-chain `revokeMasterDevice` / `setScopeWithWebauthn` txs. +/// +/// Field correspondence with the contracts: +/// - `authenticator_data_hex` → `K11Assertion.authenticatorData` +/// - `client_data_json` (raw bytes; UTF-8 string OK) → `clientDataJSON` +/// - `challenge_location` → byte offset of the value's first char +/// - `r_hex, s_hex` → ECDSA (r, s) components in 0x-prefixed hex (32 bytes each) +/// - `pub_x_hex, pub_y_hex` → P-256 public key coords in 0x-prefixed hex +/// - `expected_challenge_hex` → the 32-byte challenge the contract should +/// reconstruct from operation params + nonce; CLI re-emits it for the +/// operator's eyeball-verify +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct K11ChainAssertion { + pub operator_omni: String, + pub credential_id_b64url: String, + pub authenticator_data_hex: String, + pub client_data_json_b64url: String, + pub client_data_json_utf8: String, + pub challenge_location: usize, + pub r_hex: String, + pub s_hex: String, + pub pub_x_hex: String, + pub pub_y_hex: String, + pub expected_challenge_hex: String, + pub sign_count: u32, } #[derive(Debug, Clone, Serialize)] @@ -242,11 +277,27 @@ struct ClientDataJson { } pub fn enrollment_path(operator_omni: &str) -> PathBuf { + enrollment_path_with_rp(operator_omni, "localhost") +} + +/// rp_id-aware enrollment path so primary (rp_id=localhost) and companion +/// (rp_id=companion.localhost) credentials live in distinct files. +/// Backward-compat: `rp_id=localhost` yields the original filename +/// `.json` so existing primary enrollments still load. +pub fn enrollment_path_with_rp(operator_omni: &str, rp_id: &str) -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let suffix = if rp_id == "localhost" { + String::new() + } else { + format!("--{rp_id}") + }; PathBuf::from(home) .join(".agentkeys") .join("k11") - .join(format!("{}.json", operator_omni.trim_start_matches("0x"))) + .join(format!( + "{}{suffix}.json", + operator_omni.trim_start_matches("0x") + )) } /// Run the enrollment ceremony. Blocks (awaits) until the browser POSTs @@ -257,7 +308,17 @@ pub fn enrollment_path(operator_omni: &str) -> PathBuf { /// `#[tokio::main]`). Creating a nested runtime via `block_on` panics /// with "Cannot start a runtime from within a runtime". pub async fn enroll_webauthn(operator_omni: &str) -> Result { - enroll_webauthn_inner(operator_omni).await + enroll_webauthn_inner(operator_omni, "localhost").await +} + +/// Same as [`enroll_webauthn`] but with a configurable RP ID. The companion +/// daemon uses RP ID `"companion.localhost"` so the platform keychain +/// creates a distinct passkey from the primary daemon on the same Mac. +pub async fn enroll_webauthn_with_rp( + operator_omni: &str, + rp_id: &str, +) -> Result { + enroll_webauthn_inner(operator_omni, rp_id).await } /// Run the assert ceremony. Returns the assertion bytes @@ -266,16 +327,50 @@ pub async fn assert_webauthn( operator_omni: &str, message: &[u8], ) -> Result, WebauthnError> { - assert_webauthn_inner(operator_omni, message).await + assert_webauthn_inner(operator_omni, message, "localhost").await +} + +/// Same as [`assert_webauthn`] but for the companion daemon — uses RP ID +/// `"companion.localhost"` so the platform keychain creates a SECOND, +/// distinct passkey on the same Mac. +pub async fn assert_webauthn_with_rp( + operator_omni: &str, + message: &[u8], + rp_id: &str, +) -> Result, WebauthnError> { + assert_webauthn_inner(operator_omni, message, rp_id).await } -async fn enroll_webauthn_inner(operator_omni: &str) -> Result { +/// Chain-ready variant: runs the ceremony, then post-processes the result +/// into the exact field set the on-chain K11Verifier needs (r, s as 256-bit +/// integers, pubX, pubY, authData, clientDataJSON, challengeLocation, sign +/// count). The `expected_challenge` param MUST be the same 32-byte value the +/// on-chain contract will reconstruct from operation params + nonce — we +/// re-emit it in the output so the caller can sanity-check before broadcasting. +pub async fn assert_webauthn_for_chain( + operator_omni: &str, + expected_challenge: [u8; 32], + rp_id: &str, +) -> Result { + let enrollment = load_enrollment_with_rp(operator_omni, rp_id)?; + let parts = assert_webauthn_inner_parts(operator_omni, expected_challenge, rp_id).await?; + extract_chain_assertion(&enrollment, expected_challenge, &parts) +} + +async fn enroll_webauthn_inner( + operator_omni: &str, + rp_id: &str, +) -> Result { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .map_err(|e| WebauthnError::Bind(e.to_string()))?; let local_addr = listener.local_addr().map_err(|e| WebauthnError::Bind(e.to_string()))?; let port = local_addr.port(); - let rp_origin = format!("http://localhost:{port}"); + // Bind URL uses 127.0.0.1; but the browser must see the RP ID (e.g. + // `companion.localhost` for the companion daemon) as the effective + // domain. Modern Chrome/Safari treat `*.localhost` as loopback so + // `http://companion.localhost:PORT` resolves without /etc/hosts. + let rp_origin = format!("http://{rp_id}:{port}"); let mut challenge_bytes = [0u8; 32]; use rand_core::RngCore; @@ -283,7 +378,7 @@ async fn enroll_webauthn_inner(operator_omni: &str) -> Result Result Result Result, WebauthnError> { - // Load the previously-enrolled credential. - let enrollment = load_enrollment(operator_omni)?; + // Legacy callers pass arbitrary-length message bytes; we sha256 them + // to fit WebAuthn's 32-byte challenge slot. This produces an assertion + // bound to the message (challenge ≡ sha256(message)) but is NOT + // suitable for chain submission — the contract expects challenge to + // BE the operation hash, not sha256(operation hash). Use + // `assert_webauthn_for_chain` for that path. + let mut h = Sha256::new(); + h.update(message); + let challenge_bytes: [u8; 32] = h.finalize().into(); + let parts = assert_webauthn_inner_parts(operator_omni, challenge_bytes, rp_id).await?; + let mut out = Vec::with_capacity( + parts.authenticator_data.len() + parts.client_data_json.len() + parts.signature_der.len(), + ); + out.extend_from_slice(&parts.authenticator_data); + out.extend_from_slice(&parts.client_data_json); + out.extend_from_slice(&parts.signature_der); + Ok(out) +} + +async fn assert_webauthn_inner_parts( + operator_omni: &str, + challenge_bytes: [u8; 32], + rp_id: &str, +) -> Result { + // Load the previously-enrolled credential for THIS rp_id (primary vs + // companion live in distinct files; see enrollment_path_with_rp). + let enrollment = load_enrollment_with_rp(operator_omni, rp_id)?; + // Sanity: the stored rp_id should match what we asked for. If not, the + // file was written by an older CLI; reject so the user re-enrolls cleanly. + let enrolled_rp = enrollment.rp_id.clone().unwrap_or_else(|| "localhost".to_string()); + if enrolled_rp != rp_id { + return Err(WebauthnError::Io(format!( + "K11 credential at ~/.agentkeys/k11/{}--{rp_id}.json was enrolled with rp_id={enrolled_rp:?} \ + but assert was called with rp_id={rp_id:?}. Re-enroll the credential with the \ + matching --rp-id flag.", + operator_omni.trim_start_matches("0x") + ))); + } let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .map_err(|e| WebauthnError::Bind(e.to_string()))?; let port = listener.local_addr().map_err(|e| WebauthnError::Bind(e.to_string()))?.port(); - let rp_origin = format!("http://localhost:{port}"); + let rp_origin = format!("http://{rp_id}:{port}"); - // WebAuthn challenge = sha256(application message). The browser signs - // over (authenticatorData || sha256(clientDataJSON)) and clientDataJSON - // includes this challenge — so the resulting signature binds to our - // application message. - let mut h = Sha256::new(); - h.update(message); - let challenge_bytes = h.finalize(); + // The 32-byte challenge passed in IS the value WebAuthn signs over (no + // additional hashing). Caller is responsible for deciding whether to + // pre-hash an arbitrary message (legacy callers) or pass a pre-computed + // 32-byte commitment (chain submission via assert_webauthn_for_chain). let challenge_b64url = URL_SAFE_NO_PAD.encode(challenge_bytes); let ctx = Arc::new(ServerCtx { - rp_id: "localhost".to_string(), + rp_id: rp_id.to_string(), rp_origin: rp_origin.clone(), operator_omni: operator_omni.to_string(), challenge_b64url: challenge_b64url.clone(), allow_credential_b64url: Some(enrollment.credential_id_b64url.clone()), - message_hex: Some(hex::encode(message)), + message_hex: Some(hex::encode(challenge_bytes)), }); let (tx, rx) = oneshot::channel::(); @@ -412,7 +541,7 @@ async fn assert_webauthn_inner( .map_err(|_| WebauthnError::Timeout(CEREMONY_TIMEOUT_SECS))? .map_err(|e| WebauthnError::Io(format!("oneshot recv: {e}")))?; - finalize_assert(&enrollment, &challenge_b64url, &rp_origin, &post) + finalize_assert_parts(&enrollment, &challenge_b64url, &rp_origin, &post) } /// RAII guard: when dropped, aborts the wrapped tokio task. Used to @@ -444,6 +573,7 @@ fn open_in_browser(url: &str) -> Result<(), WebauthnError> { fn finalize_enroll( operator_omni: &str, + rp_id: &str, expected_challenge: &str, expected_origin: &str, post: &EnrollPost, @@ -489,15 +619,16 @@ fn finalize_enroll( ))); } - // Verify rpIdHash == sha256("localhost"). This binds the credential - // to our relying party so a passkey enrolled against a different RP - // can't be replayed here. + // Verify rpIdHash == sha256(rp_id). This binds the credential to our + // relying party so a passkey enrolled against a different RP can't be + // replayed here. Primary daemon: rp_id = "localhost". Companion daemon: + // "companion.localhost". let mut h = Sha256::new(); - h.update(b"localhost"); + h.update(rp_id.as_bytes()); let expected_rp_id_hash = h.finalize(); if parsed.rp_id_hash != expected_rp_id_hash.as_slice() { return Err(WebauthnError::Cbor(format!( - "rpIdHash mismatch: expected sha256('localhost'), got {}", + "rpIdHash mismatch: expected sha256({rp_id:?}), got {}", hex::encode(&parsed.rp_id_hash) ))); } @@ -523,19 +654,27 @@ fn finalize_enroll( .map(|d| d.as_secs()) .unwrap_or(0), mode: "webauthn".to_string(), + rp_id: Some(rp_id.to_string()), }) } -fn finalize_assert( +/// Verified parts of a WebAuthn assertion — extracted from the raw post and +/// ready for either chain submission (use [`extract_chain_assertion`]) or the +/// flat-bytes legacy format ([`finalize_assert`]). +pub struct AssertParts { + pub authenticator_data: Vec, + pub client_data_json: Vec, + pub signature_der: Vec, +} + +fn finalize_assert_parts( enrollment: &WebauthnEnrollment, expected_challenge: &str, expected_origin: &str, post: &AssertPost, -) -> Result, WebauthnError> { - // Cross-check the credential id the browser used against the one - // we enrolled. The browser will only sign with a passkey whose id - // was in `allowCredentials` — but a debug build of the page could - // be tweaked, and verifying here is cheap. +) -> Result { + // Cross-check credential id, parse clientDataJSON, verify sig, return + // the three parts so the caller can pick the output format. if post.id != enrollment.credential_id_b64url { return Err(WebauthnError::Cbor(format!( "assertion credential id ({}) doesn't match enrolled credential ({})", @@ -562,27 +701,18 @@ fn finalize_assert( got: cd.origin, }); } - let authenticator_data = URL_SAFE_NO_PAD .decode(&post.authenticator_data) .map_err(|e| WebauthnError::B64Decode(format!("authenticatorData: {e}")))?; let signature_der = URL_SAFE_NO_PAD .decode(&post.signature) .map_err(|e| WebauthnError::B64Decode(format!("signature: {e}")))?; - - // WebAuthn signature contract (per W3C WebAuthn §6.3.3): - // sig = ECDSA-sign(privkey, authenticatorData || sha256(clientDataJSON)) - // The signed bytes are the CONCATENATION (authData || cd_hash) — the - // verify function then sha256's the message internally. The previous - // code SHA256'd this concatenation BEFORE passing to verify, so - // verify was effectively checking sha256(sha256(...)) (codex audit). let mut h = Sha256::new(); h.update(&client_data_bytes); let cd_hash = h.finalize(); let mut signed_bytes = Vec::with_capacity(authenticator_data.len() + cd_hash.len()); signed_bytes.extend_from_slice(&authenticator_data); signed_bytes.extend_from_slice(&cd_hash); - let pubkey_hex = enrollment.cose_pubkey_hex.trim_start_matches("0x"); let pubkey_bytes = hex::decode(pubkey_hex) .map_err(|e| WebauthnError::InvalidCosePubkey(format!("hex: {e}")))?; @@ -595,22 +725,91 @@ fn finalize_assert( return Err(WebauthnError::InvalidCosePubkey("not on curve".into())); }; let verifying_key = VerifyingKey::from(pubkey); - let sig = Signature::from_der(&signature_der) .map_err(|e| WebauthnError::SigParse(e.to_string()))?; - // Pass the message unhashed; `Verifier::verify` on p256::ecdsa::VerifyingKey - // applies SHA-256 internally per the ECDSA-with-SHA256 contract. verifying_key .verify(&signed_bytes, &sig) .map_err(|_| WebauthnError::SigInvalid)?; + Ok(AssertParts { authenticator_data, client_data_json: client_data_bytes, signature_der }) +} - // Return the WebAuthn assertion in its canonical transport shape: - // authenticatorData || clientDataJSON || signature - let mut out = Vec::with_capacity(authenticator_data.len() + client_data_bytes.len() + signature_der.len()); - out.extend_from_slice(&authenticator_data); - out.extend_from_slice(&client_data_bytes); - out.extend_from_slice(&signature_der); - Ok(out) +/// Convert verified WebAuthn assertion parts into the chain-ready payload +/// (r, s decimal-extracted from DER, pubkey coords split, challenge location +/// in clientDataJSON found, etc.). The contract uses these fields to verify +/// the assertion on chain via [K11Verifier]. +pub fn extract_chain_assertion( + enrollment: &WebauthnEnrollment, + expected_challenge: [u8; 32], + parts: &AssertParts, +) -> Result { + // Parse DER signature → (r, s) as 32-byte big-endian integers. + let sig = Signature::from_der(&parts.signature_der) + .map_err(|e| WebauthnError::SigParse(format!("der → (r,s): {e}")))?; + let sig_bytes = sig.to_bytes(); // 64 bytes: r || s + if sig_bytes.len() != 64 { + return Err(WebauthnError::SigParse(format!( + "sig.to_bytes() returned {} bytes; expected 64", + sig_bytes.len() + ))); + } + let r_hex = format!("0x{}", hex::encode(&sig_bytes[0..32])); + let s_hex = format!("0x{}", hex::encode(&sig_bytes[32..64])); + + // Split COSE pubkey into X, Y. + let pk_hex = enrollment.cose_pubkey_hex.trim_start_matches("0x"); + let pk_bytes = hex::decode(pk_hex) + .map_err(|e| WebauthnError::InvalidCosePubkey(format!("hex: {e}")))?; + if pk_bytes.len() != 65 || pk_bytes[0] != 0x04 { + return Err(WebauthnError::InvalidCosePubkey(format!( + "expected 0x04 || X(32) || Y(32) = 65 bytes; got {} bytes", + pk_bytes.len() + ))); + } + let pub_x_hex = format!("0x{}", hex::encode(&pk_bytes[1..33])); + let pub_y_hex = format!("0x{}", hex::encode(&pk_bytes[33..65])); + + // Find the challenge location in clientDataJSON (byte offset of the + // value's first char). Search for the literal `"challenge":"` prefix. + let cdj_utf8 = std::str::from_utf8(&parts.client_data_json) + .map_err(|e| WebauthnError::SerdeJson(format!("cdj utf-8: {e}")))?; + let needle = "\"challenge\":\""; + let challenge_location = cdj_utf8 + .find(needle) + .map(|p| p + needle.len()) + .ok_or_else(|| { + WebauthnError::SerdeJson(format!( + "clientDataJSON missing {needle:?} prefix: {cdj_utf8}" + )) + })?; + + // Extract sign count from authData[33..37] (big-endian uint32). + if parts.authenticator_data.len() < 37 { + return Err(WebauthnError::Cbor(format!( + "authenticatorData {} bytes; expected ≥ 37", + parts.authenticator_data.len() + ))); + } + let sign_count = u32::from_be_bytes([ + parts.authenticator_data[33], + parts.authenticator_data[34], + parts.authenticator_data[35], + parts.authenticator_data[36], + ]); + + Ok(K11ChainAssertion { + operator_omni: enrollment.operator_omni.clone(), + credential_id_b64url: enrollment.credential_id_b64url.clone(), + authenticator_data_hex: format!("0x{}", hex::encode(&parts.authenticator_data)), + client_data_json_b64url: URL_SAFE_NO_PAD.encode(&parts.client_data_json), + client_data_json_utf8: cdj_utf8.to_string(), + challenge_location, + r_hex, + s_hex, + pub_x_hex, + pub_y_hex, + expected_challenge_hex: format!("0x{}", hex::encode(expected_challenge)), + sign_count, + }) } struct AttestedCredential { @@ -711,7 +910,8 @@ fn extract_attested_credential(att_obj_bytes: &[u8]) -> Result Result<(), WebauthnError> { - let path = enrollment_path(&enrollment.operator_omni); + let rp_id = enrollment.rp_id.as_deref().unwrap_or("localhost"); + let path = enrollment_path_with_rp(&enrollment.operator_omni, rp_id); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| WebauthnError::Io(e.to_string()))?; } @@ -731,7 +931,14 @@ pub fn persist_enrollment(enrollment: &WebauthnEnrollment) -> Result<(), Webauth } pub fn load_enrollment(operator_omni: &str) -> Result { - let path = enrollment_path(operator_omni); + load_enrollment_with_rp(operator_omni, "localhost") +} + +pub fn load_enrollment_with_rp( + operator_omni: &str, + rp_id: &str, +) -> Result { + let path = enrollment_path_with_rp(operator_omni, rp_id); let bytes = fs::read(&path).map_err(|e| WebauthnError::Io(format!("read {path:?}: {e}")))?; let enrollment: WebauthnEnrollment = serde_json::from_slice(&bytes) .map_err(|e| WebauthnError::SerdeJson(format!("parse {path:?}: {e}")))?; @@ -747,30 +954,57 @@ pub fn load_enrollment(operator_omni: &str) -> Result>) -> impl IntoResponse { + let is_companion = ctx.rp_id.contains("companion"); + let role_label = if is_companion { "COMPANION MASTER" } else { "PRIMARY MASTER" }; + let role_tagline = if is_companion { + "Bind a SECOND platform passkey for M-of-N recovery quorum." + } else { + "Bind a platform passkey for master-tier authorisation." + }; + let role_accent = if is_companion { "#a855f7" } else { "#0a84ff" }; + let role_emoji = if is_companion { "🛡️" } else { "🔑" }; + // Short, human-readable name shown by macOS in the Touch ID dialog + // ("Use Touch ID to sign in to 'localhost' with your passkey for ..." + // — macOS displays user.name there, NOT the full omni hex). + let user_name_short = if is_companion { + "AgentKeys Companion Master" + } else { + "AgentKeys Primary Master" + }; let html = format!( r##" -AgentKeys — K11 enrollment +AgentKeys — Enroll {role_label} {shared_css} +
-
- - AgentKeys -
+
{role_emoji} {role_label}

K11 enrollment

-

Bind a platform passkey for master-tier authorisation.

+

{role_tagline}

Operator
{omni}
+
RP ID
+
{rp_id_display}
Authenticator
Platform (Touch ID / Windows Hello / Secure Enclave)
Algorithm
ECDSA P-256 / SHA-256 (ES256)

Press the button below. macOS will prompt for Touch ID.

- +