From 0f040baca3f2435675375af79689df800fd2287c Mon Sep 17 00:00:00 2001 From: bordumb Date: Thu, 11 Jun 2026 22:00:28 +0100 Subject: [PATCH 01/32] =?UTF-8?q?feat(cli):=20add=20--print-uri=20to=20aut?= =?UTF-8?q?hs=20pair=20=E2=80=94=20emit=20the=20token=20URI=20for=20out-of?= =?UTF-8?q?-band=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auths://pair?... URI existed only inside the rendered QR (offline mode printed it unconditionally; LAN and online not at all). One flag-gated helper (print_token_uri in pair/common.rs) now serves all three initiate paths: - lan: printed after the tokened endpoint is set, before the wait loop - online: printed inside the SessionCreated status callback - offline: the previously unconditional URI line is now gated by the flag Composable with --no-qr. Closes demo gap LTL-4 (lost-the-laptop): scripts can capture the URI and deliver it via simctl openurl instead of scraping endpoint+code and re-synthesizing the URI in the app. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .../src/commands/device/pair/common.rs | 8 ++++ .../auths-cli/src/commands/device/pair/lan.rs | 7 ++++ .../auths-cli/src/commands/device/pair/mod.rs | 38 +++++++++++++++++-- .../src/commands/device/pair/offline.rs | 7 +++- .../src/commands/device/pair/online.rs | 6 +++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/auths-cli/src/commands/device/pair/common.rs b/crates/auths-cli/src/commands/device/pair/common.rs index 3769bf84..8d3918f1 100644 --- a/crates/auths-cli/src/commands/device/pair/common.rs +++ b/crates/auths-cli/src/commands/device/pair/common.rs @@ -10,6 +10,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use auths_sdk::core_config::EnvironmentConfig; use auths_sdk::pairing::PairingSession; +use auths_sdk::pairing::PairingToken; use auths_sdk::pairing::SubmitResponseRequest; use auths_sdk::signing::PassphraseProvider; @@ -58,6 +59,13 @@ pub(crate) fn print_pairing_header(mode: &str, registry: &str, controller_did: & println!(); } +/// Print the pairing token URI (`--print-uri`) — the exact string the QR +/// encodes (`PairingToken::to_uri`). The URI itself is unstyled so scripts +/// can capture it from the output and deliver it out of band. +pub(crate) fn print_token_uri(token: &PairingToken) { + println!(" {} {}", style("URI:").dim(), token.to_uri()); +} + /// Print a styled completion footer with device info. /// /// One-line format matching the Signal/WhatsApp "device linked" UX: diff --git a/crates/auths-cli/src/commands/device/pair/lan.rs b/crates/auths-cli/src/commands/device/pair/lan.rs index dec2af15..20f20898 100644 --- a/crates/auths-cli/src/commands/device/pair/lan.rs +++ b/crates/auths-cli/src/commands/device/pair/lan.rs @@ -33,6 +33,7 @@ use super::lan_server::{LanPairingServer, detect_lan_ip}; pub async fn handle_initiate_lan( now: chrono::DateTime, no_qr: bool, + print_uri: bool, no_mdns: bool, verify: bool, recover: Option, @@ -190,6 +191,12 @@ pub async fn handle_initiate_lan( session.token.expires_at.format("%H:%M:%S"), expiry_secs ); + if print_uri { + // The token's endpoint is final here (set right after server start), + // so this is the same URI the QR above encodes. + println!(); + print_token_uri(&session.token); + } println!(); println!(" {}", style("(Press Ctrl+C to cancel)").dim()); println!(); diff --git a/crates/auths-cli/src/commands/device/pair/mod.rs b/crates/auths-cli/src/commands/device/pair/mod.rs index 2e791a95..a877db32 100644 --- a/crates/auths-cli/src/commands/device/pair/mod.rs +++ b/crates/auths-cli/src/commands/device/pair/mod.rs @@ -56,6 +56,11 @@ pub struct PairCommand { #[clap(long, hide_short_help = true)] pub no_qr: bool, + /// Print the pairing token URI (the same auths://pair?... string the QR + /// encodes) so scripts can deliver it out of band + #[clap(long, hide_short_help = true)] + pub print_uri: bool, + /// Custom timeout in seconds for the pairing session (default: 300 = 5 minutes) #[clap( long, @@ -129,9 +134,13 @@ pub fn handle_pair( match (&cmd.join, &cmd.registry, cmd.offline) { // Offline mode takes priority - (None, _, true) => { - offline::handle_initiate_offline(now, cmd.no_qr, cmd.timeout, &cmd.capabilities) - } + (None, _, true) => offline::handle_initiate_offline( + now, + cmd.no_qr, + cmd.print_uri, + cmd.timeout, + &cmd.capabilities, + ), // Join with explicit registry -> online join (Some(code), Some(registry), _) => { @@ -160,6 +169,7 @@ pub fn handle_pair( now, registry, cmd.no_qr, + cmd.print_uri, cmd.timeout, &cmd.capabilities, env_config, @@ -174,6 +184,7 @@ pub fn handle_pair( rt.block_on(lan::handle_initiate_lan( now, cmd.no_qr, + cmd.print_uri, cmd.no_mdns, cmd.verify, cmd.recover.clone(), @@ -192,6 +203,7 @@ pub fn handle_pair( now, DEFAULT_REGISTRY, cmd.no_qr, + cmd.print_uri, cmd.timeout, &cmd.capabilities, env_config, @@ -205,6 +217,26 @@ pub fn handle_pair( mod tests { use auths_sdk::pairing::normalize_short_code; + #[test] + fn test_print_uri_flag_parses_and_composes_with_no_qr() { + use clap::Parser; + let cmd = super::PairCommand::parse_from(["pair", "--print-uri", "--no-qr"]); + assert!(cmd.print_uri); + assert!(cmd.no_qr); + let cmd = super::PairCommand::parse_from(["pair"]); + assert!(!cmd.print_uri); + } + + #[test] + fn test_print_uri_visible_in_long_help() { + use clap::CommandFactory; + let help = super::PairCommand::command().render_long_help().to_string(); + assert!( + help.contains("--print-uri"), + "pair --help must advertise --print-uri" + ); + } + #[test] fn test_code_normalization() { let codes = vec![ diff --git a/crates/auths-cli/src/commands/device/pair/offline.rs b/crates/auths-cli/src/commands/device/pair/offline.rs index c50e0100..68d0a246 100644 --- a/crates/auths-cli/src/commands/device/pair/offline.rs +++ b/crates/auths-cli/src/commands/device/pair/offline.rs @@ -13,6 +13,7 @@ use super::common::*; pub(crate) fn handle_initiate_offline( now: chrono::DateTime, no_qr: bool, + print_uri: bool, expiry_secs: u64, capabilities: &[auths_keri::Capability], ) -> Result<()> { @@ -72,8 +73,10 @@ pub(crate) fn handle_initiate_offline( expiry_secs ); println!(); - println!(" {} {}", style("URI:").dim(), session.token.to_uri()); - println!(); + if print_uri { + print_token_uri(&session.token); + println!(); + } println!( " {}{}", WARN, diff --git a/crates/auths-cli/src/commands/device/pair/online.rs b/crates/auths-cli/src/commands/device/pair/online.rs index 7a9e7ad8..1e91e29f 100644 --- a/crates/auths-cli/src/commands/device/pair/online.rs +++ b/crates/auths-cli/src/commands/device/pair/online.rs @@ -20,6 +20,7 @@ pub(crate) async fn handle_initiate_online( now: chrono::DateTime, registry: &str, no_qr: bool, + print_uri: bool, expiry_secs: u64, capabilities: &[auths_keri::Capability], env_config: &EnvironmentConfig, @@ -60,6 +61,7 @@ pub(crate) async fn handle_initiate_online( let registry_str = registry.to_string(); let no_qr_flag = no_qr; + let print_uri_flag = print_uri; let on_status = move |status: PairingStatus| match status { PairingStatus::SessionCreated { token, ttl_seconds } => { @@ -99,6 +101,10 @@ pub(crate) async fn handle_initiate_online( token.expires_at.format("%H:%M:%S"), ttl_seconds ); + if print_uri_flag { + println!(); + print_token_uri(&token); + } println!(); println!(" {}", style("(Press Ctrl+C to cancel)").dim()); println!(); From 051cd23c94724c9ddce932cca9c0e45a407183eb Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 04:40:13 +0100 Subject: [PATCH 02/32] =?UTF-8?q?feat:=20ri-burndown=20cycles=201-7=20?= =?UTF-8?q?=E2=80=94=20close=20AITFC-1,=20DOTAK-1,=20LTL-6b,=20LTL-R1,=20P?= =?UTF-8?q?WNTS-1,=20V-WASM,=20AITFC-6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform sculpts from seven recursive-improvement cycles, each gated on probe GREEN + fleet matrix + demo behavioral harness: - AITFC-1/AITFC-6 (auditor-in-the-faraday-cage): compliance anchored releases — attest_release anchors a kind-tagged ReleaseAttestation in the org KEL with content-addressed blob storage; discover_releases derives signed_at from the anchoring position; CLI compliance attest / report --discover - DOTAK-1 (death-of-the-api-key): OIDC artifact policy join — oidc_policy verifier, artifact oidc CLI, infra-http OIDC platforms - LTL-6b/LTL-R1 (lost-the-laptop): delegated inception FFI context and phone-driven shared-KEL rotation (pairing daemon + mobile FFI) - PWNTS-1 (pipeline-with-nothing-to-steal): signed-artifact machine identity in CI workflows - V-WASM (verify-the-world): closed as free win (fix already upstream) 525/525 sdk+cli tests, workspace clippy clean at cycle 7 gate. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- Cargo.lock | 1 + crates/auths-cli/src/commands/artifact/mod.rs | 99 ++- .../auths-cli/src/commands/artifact/oidc.rs | 117 +++ .../auths-cli/src/commands/artifact/verify.rs | 121 ++- crates/auths-cli/src/commands/compliance.rs | 328 +++++++- crates/auths-cli/src/commands/demo.rs | 17 +- crates/auths-cli/src/commands/org.rs | 1 + .../auths-cli/src/commands/unified_verify.rs | 15 +- crates/auths-id/src/attestation/create.rs | 7 +- .../tests/cases/attestation_input_golden.rs | 3 +- crates/auths-id/tests/cases/lifecycle.rs | 1 + crates/auths-infra-http/Cargo.toml | 5 +- crates/auths-infra-http/src/lib.rs | 2 +- crates/auths-infra-http/src/oidc_platforms.rs | 12 + crates/auths-infra-http/src/oidc_validator.rs | 28 + crates/auths-keri/src/events.rs | 103 +++ crates/auths-keri/src/lib.rs | 3 +- crates/auths-mcp-server/Cargo.toml | 3 +- crates/auths-mcp-server/src/config.rs | 62 ++ crates/auths-mcp-server/src/keri_auth.rs | 85 +- crates/auths-mcp-server/src/lib.rs | 23 +- crates/auths-mcp-server/src/main.rs | 116 ++- crates/auths-mcp-server/src/middleware.rs | 93 ++- crates/auths-mcp-server/src/routes.rs | 31 +- crates/auths-mcp-server/src/state.rs | 32 +- crates/auths-mcp-server/tests/cases/routes.rs | 63 ++ crates/auths-mobile-ffi/Cargo.lock | 16 +- .../src/delegated_inception_context.rs | 413 ++++++++++ crates/auths-mobile-ffi/src/lib.rs | 9 +- .../auths-mobile-ffi/src/pairing_context.rs | 120 ++- .../src/shared_kel_context.rs | 770 ++++++++++++++---- crates/auths-pairing-daemon/src/error.rs | 12 +- crates/auths-pairing-daemon/src/handlers.rs | 70 +- crates/auths-pairing-daemon/src/router.rs | 6 +- crates/auths-pairing-daemon/src/state.rs | 54 +- .../auths-pairing-daemon/tests/cases/mod.rs | 1 + .../tests/cases/shared_kel_rot.rs | 276 +++++++ crates/auths-pairing-protocol/src/types.rs | 44 + .../auths-sdk/src/domains/compliance/dsse.rs | 111 ++- .../auths-sdk/src/domains/compliance/mod.rs | 10 +- .../auths-sdk/src/domains/compliance/query.rs | 12 + .../src/domains/compliance/releases.rs | 304 +++++++ .../auths-sdk/src/domains/device/service.rs | 2 + .../auths-sdk/src/domains/identity/service.rs | 1 + .../src/domains/org/offline_verify.rs | 11 +- crates/auths-sdk/src/domains/org/service.rs | 1 + .../auths-sdk/src/domains/signing/service.rs | 64 +- crates/auths-sdk/src/pairing/delegation.rs | 40 +- .../src/workflows/ci/machine_identity.rs | 59 +- crates/auths-sdk/src/workflows/compliance.rs | 8 +- .../auths-sdk/tests/cases/compliance_query.rs | 225 ++++- .../tests/cases/ephemeral_signing.rs | 250 ++++-- crates/auths-verifier/src/core.rs | 7 + crates/auths-verifier/src/lib.rs | 5 + crates/auths-verifier/src/oidc_policy.rs | 318 ++++++++ docs/plans/blockers/fixes.md | 316 +++++++ docs/plans/blockers/revolutionary.md | 69 ++ docs/prompts/red_team.md | 2 +- 58 files changed, 4515 insertions(+), 462 deletions(-) create mode 100644 crates/auths-cli/src/commands/artifact/oidc.rs create mode 100644 crates/auths-mobile-ffi/src/delegated_inception_context.rs create mode 100644 crates/auths-pairing-daemon/tests/cases/shared_kel_rot.rs create mode 100644 crates/auths-sdk/src/domains/compliance/releases.rs create mode 100644 crates/auths-verifier/src/oidc_policy.rs create mode 100644 docs/plans/blockers/fixes.md create mode 100644 docs/plans/blockers/revolutionary.md diff --git a/Cargo.lock b/Cargo.lock index ab5f8ead..0370e5db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,6 +700,7 @@ name = "auths-mcp-server" version = "0.1.3" dependencies = [ "anyhow", + "auths-api", "auths-jwt", "auths-rp", "auths-sdk", diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 1bca15c1..dad09bb5 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -1,5 +1,6 @@ pub mod core; pub mod file; +pub mod oidc; pub mod publish; pub mod sign; pub mod verify; @@ -10,6 +11,7 @@ use std::sync::Arc; use anyhow::{Context, Result, bail}; use auths_sdk::core_config::EnvironmentConfig; +use auths_sdk::domains::signing::service::EphemeralSignRequest; use auths_sdk::registration::DEFAULT_REGISTRY_URL; use auths_sdk::signing::PassphraseProvider; use auths_sdk::signing::validate_commit_sha; @@ -91,6 +93,35 @@ pub enum ArtifactSubcommand { #[arg(long, requires = "ci")] ci_platform: Option, + /// Path to the runner's OIDC token (the keyless exchange: the token is + /// validated against the issuer's JWKS and the verified claims are + /// embedded in the signed attestation as an OIDC binding). + #[arg( + long, + value_name = "TOKEN-FILE", + requires = "ci", + requires = "oidc_audience" + )] + oidc_token: Option, + + /// Expected OIDC token audience (exact match). Required with --oidc-token. + #[arg(long, value_name = "AUD", requires = "oidc_token")] + oidc_audience: Option, + + /// Expected OIDC token issuer (exact match). + #[arg( + long, + value_name = "URL", + requires = "oidc_token", + default_value = oidc::DEFAULT_OIDC_ISSUER + )] + oidc_issuer: String, + + /// Pinned JWKS file for offline/air-gapped token validation + /// (default: fetch the issuer's published JWKS over HTTPS). + #[arg(long, value_name = "JWKS-FILE", requires = "oidc_token")] + oidc_jwks: Option, + /// Transparency log to submit to (overrides default from trust config). #[arg(long, value_name = "LOG_ID")] log: Option, @@ -195,6 +226,12 @@ pub enum ArtifactSubcommand { /// (offline) Emit the typed verdict as JSON. #[arg(long)] json: bool, + + /// OIDC-subject policy file to JOIN against the attestation's signed + /// OIDC binding (issuer + repository [+ workflow_ref] the org trusts). + /// Fail-closed: a missing binding or any mismatch fails verification. + #[arg(long, value_name = "POLICY-FILE")] + oidc_policy: Option, }, } @@ -358,6 +395,10 @@ pub fn handle_artifact( no_commit, ci, ci_platform, + oidc_token, + oidc_audience, + oidc_issuer, + oidc_jwks, log, allow_unlogged, } => { @@ -409,14 +450,44 @@ pub fn handle_artifact( #[allow(clippy::disallowed_methods)] let now = chrono::Utc::now(); + // The keyless exchange, sign side: validate the runner's OIDC + // token and embed the verified claims in the signed envelope. + let oidc_binding = match &oidc_token { + Some(token_path) => { + let audience = oidc_audience.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "--oidc-token requires --oidc-audience \ + (the audience the token was minted for)" + ) + })?; + let binding = oidc::resolve_oidc_binding( + token_path, + &oidc_issuer, + audience, + oidc_jwks.as_deref(), + &ci_env.platform, + now, + )?; + eprintln!( + " OIDC identity verified: {} (issuer {})", + binding.subject, binding.issuer + ); + Some(binding) + } + None => None, + }; + let result = auths_sdk::domains::signing::service::sign_artifact_ephemeral( now, - &data, - artifact_name, - commit_sha, - expires_in, - note, - Some(ci_env_json), + EphemeralSignRequest { + data: &data, + artifact_name, + commit_sha, + expires_in, + note, + ci_env: Some(ci_env_json), + oidc_binding, + }, ) .map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?; @@ -545,6 +616,7 @@ pub fn handle_artifact( member, signed_at, json, + oidc_policy, } => { if offline { return verify::handle_offline_verify( @@ -558,12 +630,15 @@ pub fn handle_artifact( let rt = tokio::runtime::Runtime::new()?; rt.block_on(verify::handle_verify( &file, - signature, - identity_bundle, - witness_receipts, - &witness_keys, - witness_threshold, - verify_commit, + verify::VerifyArtifactArgs { + signature, + identity_bundle, + witness_receipts, + witness_keys, + witness_threshold, + verify_commit, + oidc_policy, + }, )) } } diff --git a/crates/auths-cli/src/commands/artifact/oidc.rs b/crates/auths-cli/src/commands/artifact/oidc.rs new file mode 100644 index 00000000..94e0a839 --- /dev/null +++ b/crates/auths-cli/src/commands/artifact/oidc.rs @@ -0,0 +1,117 @@ +//! The sign-time half of the keyless CI exchange: validate the runner's +//! OIDC token and turn it into a signature-covered [`OidcBinding`]. +//! +//! The runner never holds an org key. It presents the token its CI platform +//! minted for the job; we validate it — signature against the issuer's JWKS, +//! issuer, audience, expiry — and embed the verified, platform-normalized +//! claims in the attestation's signed envelope. The org's side of the +//! exchange happens at verify time: `artifact verify --oidc-policy` joins +//! these claims against the OIDC-subject policy the org pinned. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Utc}; + +use auths_infra_http::{HttpJwksClient, HttpJwtValidator, PinnedJwksClient}; +use auths_sdk::domains::signing::ci_env::CiPlatform; +use auths_sdk::workflows::ci::machine_identity::{ + OidcMachineIdentityConfig, create_machine_identity_from_oidc_token, +}; +use auths_verifier::core::OidcBinding; + +/// The default OIDC issuer when none is given: GitHub Actions, the platform +/// the zero-secret CI story ships on first. +pub const DEFAULT_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; + +/// Map the detected CI platform onto the OIDC claim-normalization platform. +/// +/// Fail-closed: a platform without a known OIDC claim shape cannot produce a +/// binding the policy join can trust, so it is an error — never a guess. +fn oidc_platform(platform: &CiPlatform) -> Result<&'static str> { + match platform { + CiPlatform::GithubActions => Ok("github"), + CiPlatform::GitlabCi => Ok("gitlab"), + CiPlatform::CircleCi => Ok("circleci"), + CiPlatform::Generic | CiPlatform::Local => bail!( + "--oidc-token needs a CI platform with a known OIDC claim shape \ + (GitHub Actions, GitLab CI, or CircleCI) — detected {:?}", + platform + ), + } +} + +/// Validate an OIDC token and return the verified binding to embed. +/// +/// Args: +/// * `token_path`: File holding the raw JWT (tokens are bearer secrets — +/// they travel by file, never argv). +/// * `issuer`: Expected token issuer (exact match). +/// * `audience`: Expected token audience (exact match). +/// * `jwks_path`: Optional pinned JWKS file. When absent, the issuer's +/// published JWKS is fetched over HTTPS. +/// * `platform`: The CI platform whose claim shape normalizes the token. +/// * `now`: Current time for expiry validation. +pub fn resolve_oidc_binding( + token_path: &Path, + issuer: &str, + audience: &str, + jwks_path: Option<&Path>, + platform: &CiPlatform, + now: DateTime, +) -> Result { + let oidc_platform = oidc_platform(platform)?; + + let token = std::fs::read_to_string(token_path) + .with_context(|| format!("Failed to read OIDC token from {token_path:?}"))?; + let token = token.trim(); + if token.is_empty() { + bail!("OIDC token file {token_path:?} is empty"); + } + + let validator = match jwks_path { + Some(path) => { + let jwks_raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read pinned JWKS from {path:?}"))?; + let jwks: serde_json::Value = serde_json::from_str(&jwks_raw) + .with_context(|| format!("Pinned JWKS {path:?} is not valid JSON"))?; + HttpJwtValidator::new(Arc::new(PinnedJwksClient::new(jwks))) + } + None => HttpJwtValidator::new(Arc::new(HttpJwksClient::with_default_ttl())), + }; + + let config = OidcMachineIdentityConfig { + issuer: issuer.to_string(), + audience: audience.to_string(), + platform: oidc_platform.to_string(), + }; + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + let identity = rt + .block_on(create_machine_identity_from_oidc_token( + token, + config, + Arc::new(validator), + now, + )) + .map_err(|e| anyhow::anyhow!("OIDC token validation failed: {e}"))?; + + Ok(OidcBinding::from(&identity)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_platform_cannot_present_a_token() { + let err = oidc_platform(&CiPlatform::Local).expect_err("local must be rejected"); + assert!(err.to_string().contains("known OIDC claim shape")); + } + + #[test] + fn github_maps_to_github_claim_shape() { + assert_eq!(oidc_platform(&CiPlatform::GithubActions).unwrap(), "github"); + } +} diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index af1a52d3..cb34c3f0 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use auths_keri::witness::SignedReceipt; use auths_verifier::core::Attestation; +use auths_verifier::oidc_policy::{OidcPolicyJoin, OidcSubjectPolicy}; use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses, @@ -35,23 +36,71 @@ struct VerifyArtifactResult { #[serde(skip_serializing_if = "Option::is_none")] commit_verified: Option, #[serde(skip_serializing_if = "Option::is_none")] + oidc_join: Option, + #[serde(skip_serializing_if = "Option::is_none")] error: Option, } +/// Inputs for [`handle_verify`] beyond the artifact path — named fields +/// (the `AttestationInput` pattern) so call sites stay readable as the +/// verify surface grows. +pub struct VerifyArtifactArgs { + /// Path to the signature file (defaults to `.auths.json`). + pub signature: Option, + /// Identity bundle for stateless verification. + pub identity_bundle: Option, + /// Witness receipts file. + pub witness_receipts: Option, + /// Witness public keys as DID:hex pairs. + pub witness_keys: Vec, + /// Number of witnesses required. + pub witness_threshold: usize, + /// Also verify the source commit's signing attestation. + pub verify_commit: bool, + /// OIDC-subject policy to JOIN against the signed OIDC binding. + pub oidc_policy: Option, +} + /// Execute the `artifact verify` command. /// /// Exit codes: 0=valid, 1=invalid, 2=error. -pub async fn handle_verify( - file: &Path, - signature: Option, - identity_bundle: Option, - witness_receipts: Option, - witness_keys: &[String], - witness_threshold: usize, - verify_commit: bool, -) -> Result<()> { +pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> { + let VerifyArtifactArgs { + signature, + identity_bundle, + witness_receipts, + witness_keys, + witness_threshold, + verify_commit, + oidc_policy, + } = args; + let witness_keys = &witness_keys; let file_str = file.to_string_lossy().to_string(); + // 0. Parse the OIDC-subject policy up front: an unreadable or malformed + // policy is a "could not attempt" (exit 2), not a verdict. + let oidc_policy = match &oidc_policy { + None => None, + Some(path) => { + let raw = match fs::read_to_string(path) { + Ok(r) => r, + Err(e) => { + return output_error( + &file_str, + 2, + &format!("Failed to read OIDC policy {path:?}: {e}"), + ); + } + }; + match OidcSubjectPolicy::parse(&raw) { + Ok(p) => Some(p), + Err(e) => { + return output_error(&file_str, 2, &format!("{e}")); + } + } + } + }; + // 1. Locate and load signature file let sig_path = signature.unwrap_or_else(|| { let mut p = file.to_path_buf(); @@ -129,6 +178,7 @@ pub async fn handle_verify( issuer: Some(attestation.issuer.to_string()), commit_sha: attestation.commit_sha.clone(), commit_verified: None, + oidc_join: None, error: Some(format!( "Digest mismatch: file={}, attestation={}", file_digest.hex, artifact_meta.digest.hex @@ -247,6 +297,55 @@ pub async fn handle_verify( } } + // 8a2. The keyless exchange, verify side: JOIN the attestation's + // signature-covered OIDC binding against the org's pinned policy. + // Fail-closed: only a chain-valid attestation has a trustworthy + // binding, and a missing binding or any claim mismatch is a + // verification failure, never a pass. + let mut oidc_error: Option = None; + let oidc_join = match &oidc_policy { + None => None, + Some(_) if !valid => { + // Already failing — the binding's claims can't be trusted, so the + // join is not attempted (and cannot rescue the verdict). + None + } + Some(policy) => match &attestation.oidc_binding { + None => { + valid = false; + oidc_error = Some( + "OIDC policy join failed: attestation carries no OIDC binding \ + — signer presented no verified OIDC identity" + .to_string(), + ); + None + } + Some(binding) => match policy.join(binding) { + Ok(join) => { + if !is_json_mode() { + eprintln!( + " OIDC policy join: {} via {} (issuer {})", + join.repository, + join.workflow_ref.as_deref().unwrap_or("any workflow"), + join.issuer + ); + } + Some(join) + } + Err(e) => { + valid = false; + oidc_error = Some(format!("OIDC policy join failed: {e}")); + None + } + }, + }, + }; + if let Some(ref msg) = oidc_error + && !is_json_mode() + { + eprintln!(" {msg}"); + } + // 8b. Display commit linkage info (always, when present) let commit_sha_val = attestation.commit_sha.clone(); if let Some(ref sha) = commit_sha_val @@ -313,7 +412,8 @@ pub async fn handle_verify( issuer: Some(identity_did.to_string()), commit_sha: commit_sha_val, commit_verified, - error: None, + oidc_join, + error: oidc_error, }, ) } @@ -421,6 +521,7 @@ fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> { issuer: None, commit_sha: None, commit_verified: None, + oidc_join: None, error: Some(message.to_string()), }; println!("{}", serde_json::to_string(&result)?); diff --git a/crates/auths-cli/src/commands/compliance.rs b/crates/auths-cli/src/commands/compliance.rs index 871d4431..7298cdc4 100644 --- a/crates/auths-cli/src/commands/compliance.rs +++ b/crates/auths-cli/src/commands/compliance.rs @@ -1,31 +1,37 @@ //! `auths compliance` — compliance-as-a-query evidence packs. //! //! Thin presentation layer over [`auths_sdk::workflows::compliance`]: it reads the -//! period's releases, calls the SDK query engine to classify each signer's authority -//! **at release** (by KEL position), embeds the honest witness verdict, optionally -//! embeds the org KEL bundle for offline verification, and optionally org-signs the -//! pack as a DSSE-wrapped in-toto statement. No domain logic lives here. +//! period's releases (from a caller file, or **discovered** from the release +//! attestations anchored in the org KEL), calls the SDK query engine to classify +//! each signer's authority **at release** (by KEL position), embeds the honest +//! witness verdict, optionally embeds the org KEL bundle for offline verification, +//! and optionally org-signs the pack as a DSSE-wrapped in-toto statement. +//! `compliance attest` is the signing-time half: it anchors the release fact so the +//! report can derive it. No domain logic lives here. use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, Utc}; use clap::{Parser, Subcommand, ValueEnum}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use auths_crypto::CurveType; use auths_sdk::context::AuthsContext; use auths_sdk::keychain::{KeyAlias, extract_public_key_bytes}; use auths_sdk::storage_layout::layout; use auths_sdk::workflows::compliance::{ - ComplianceFramework, ReleaseRecord, TransparencyInclusion, VsaParams, build_evidence_pack, - build_framework_report, build_offline_evidence_pack, load_witness_policy, sign_evidence_pack, - sign_framework_report, + ArtifactDigest, ComplianceFramework, ReleaseRecord, TransparencyInclusion, + VerifiedEvidencePack, VsaParams, attest_release, build_evidence_pack, build_framework_report, + build_offline_evidence_pack, discover_releases, load_witness_policy, sign_evidence_pack, + sign_framework_report, verify_signed_evidence_pack_offline, }; +use auths_sdk::workflows::roots::parse_roots_typed; use auths_verifier::{IdentityDID, Prefix}; use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; use crate::factories::storage::build_auths_context; +use crate::ux::format::{JsonResponse, Output, is_json_mode}; /// Default keychain alias for an org's signing key (`org-{slug}`), matching the /// `auths org` convention. @@ -56,6 +62,14 @@ fn resolve_org_signing( Ok((org_alias, curve)) } +/// Hash an artifact file with SHA-256 into a parsed `sha256:` digest. +fn file_artifact_digest(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let bytes = fs::read(path).with_context(|| format!("Failed to read artifact file {path:?}"))?; + let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes))); + ArtifactDigest::parse(&digest).map_err(|e| anyhow!("computed digest rejected: {e}")) +} + /// One release entry in the `--releases` JSON file. `signer` accepts a `did:keri:` /// or a bare KEL prefix; `transparency` is the optional log inclusion evidence. #[derive(Debug, Clone, serde::Deserialize)] @@ -111,11 +125,21 @@ impl From for ComplianceFramework { #[command( about = "Compliance as a query — offline-verifiable evidence packs", after_help = "Examples: + auths compliance attest --org did:keri:EOrg --artifact dist/cli-v2.4.0.tar.gz \\ + --signer did:keri:EMember + # At signing time: anchor the release (artifact digest + + # signer) in the org KEL — the position becomes log fact + auths compliance report --org did:keri:EOrg --period 2026-Q3 \\ - --framework slsa --releases releases.json --offline --out acme-2026Q3.evidence - # Build an offline-verifiable evidence pack + --framework slsa --discover --offline --out acme-2026Q3.evidence + # Build an offline-verifiable evidence pack from the + # releases anchored in the org KEL (signed_at derived) -Releases file (JSON array): + auths compliance verify --pack acme-2026Q3.evidence --roots auths-roots + # Auditor-side: verify a signed pack offline (exit 0 + # authentic / exit 1 rejected) — no account, no network + +Releases file (JSON array, caller-asserted alternative to --discover): [{\"artifact_digest\":\"sha256:…\",\"signer\":\"did:keri:EMember\",\"signed_at\":41}]" )] pub struct ComplianceCommand { @@ -126,7 +150,33 @@ pub struct ComplianceCommand { /// Subcommands for compliance queries. #[derive(Subcommand, Debug, Clone)] pub enum ComplianceSubcommand { + /// Anchor a release attestation (artifact digest + signer) in the org KEL — + /// the signing position becomes part of the tamper-evident log + #[command(group(clap::ArgGroup::new("artifact_source").required(true)))] + Attest { + /// Organization identity ID (`did:keri:…`) or bare prefix + #[arg(long)] + org: String, + + /// Artifact file to hash (sha256) and attest + #[arg(long, group = "artifact_source")] + artifact: Option, + + /// Pre-computed artifact digest (`sha256:<64 hex>`) + #[arg(long, group = "artifact_source")] + digest: Option, + + /// The signing member's identity (`did:keri:…`) or bare prefix + #[arg(long)] + signer: String, + + /// Org signing key alias (defaults to the org slug alias) + #[arg(long)] + key: Option, + }, + /// Produce a compliance evidence pack for a reporting period. + #[command(group(clap::ArgGroup::new("release_source").required(true)))] Report { /// Organization identity ID (`did:keri:…`) or bare prefix #[arg(long)] @@ -150,8 +200,14 @@ pub enum ComplianceSubcommand { verifier_id: String, /// JSON file: array of `{ artifact_digest, signer, signed_at?, transparency? }` - #[arg(long)] - releases: PathBuf, + /// — caller-asserted positions (alternative to `--discover`) + #[arg(long, group = "release_source")] + releases: Option, + + /// Derive the rows from the release attestations anchored in the org KEL + /// (`compliance attest`); each row's `signed_at` IS its anchoring position + #[arg(long, group = "release_source")] + discover: bool, /// Embed the org KEL bundle so each row verifies offline (no network) #[arg(long)] @@ -173,6 +229,17 @@ pub enum ComplianceSubcommand { #[arg(long)] out: Option, }, + + /// Verify a DSSE-signed evidence pack offline — no account, no network, no keychain + Verify { + /// The DSSE-signed pack file (from `compliance report --offline --sign`) + #[arg(long)] + pack: PathBuf, + + /// Pinned trust-roots file (one `did:keri:…` per line) — the only trust input + #[arg(long)] + roots: PathBuf, + }, } /// Handle `auths compliance` subcommands. @@ -192,6 +259,74 @@ pub fn handle_compliance( now: DateTime, ) -> Result<()> { match cmd.subcommand { + ComplianceSubcommand::Attest { + org, + artifact, + digest, + signer, + key, + } => { + let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?; + let org_prefix = + Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); + let signer_prefix = Prefix::new_unchecked( + signer + .strip_prefix("did:keri:") + .unwrap_or(&signer) + .to_string(), + ); + + let artifact_digest = match (&artifact, &digest) { + (Some(path), None) => file_artifact_digest(path)?, + (None, Some(d)) => { + ArtifactDigest::parse(d).map_err(|e| anyhow!("invalid --digest value: {e}"))? + } + _ => unreachable!("clap requires exactly one of --artifact/--digest"), + }; + + let sdk_ctx = build_auths_context( + &repo_path, + &ctx.env_config, + Some(ctx.passphrase_provider.clone()), + )?; + let (org_alias, _curve) = resolve_org_signing(&sdk_ctx, &org, key)?; + let anchored = attest_release( + &sdk_ctx, + &org_prefix, + &org_alias, + artifact_digest, + signer_prefix, + ) + .context("Failed to anchor the release attestation in the org KEL")?; + + if is_json_mode() { + JsonResponse { + success: true, + command: "compliance attest".to_string(), + data: Some(serde_json::json!({ + "org": format!("did:keri:{}", org_prefix.as_str()), + "artifact_digest": anchored.artifact_digest.as_str(), + "signer": format!("did:keri:{}", anchored.signer.as_str()), + "signed_at": anchored.signed_at.to_string(), + "attestation_said": anchored.attestation_said.as_str(), + })), + error: None, + } + .print()?; + } else { + let out = Output::stdout(); + println!( + "{}", + out.success("Release attestation anchored in the org KEL") + ); + println!(" Artifact: {}", anchored.artifact_digest); + println!(" Signer: did:keri:{}", anchored.signer.as_str()); + println!(" Signed at: org KEL seq {}", anchored.signed_at); + println!(" SAID: {}", anchored.attestation_said.as_str()); + } + Ok(()) + } + ComplianceSubcommand::Report { org, period, @@ -199,6 +334,7 @@ pub fn handle_compliance( predicate, verifier_id, releases, + discover, offline, sign, key, @@ -213,13 +349,6 @@ pub fn handle_compliance( let org_did = IdentityDID::from_prefix(org_prefix.as_str()) .map_err(|e| anyhow!("invalid org identifier '{org}': {e}"))?; - let raw = fs::read_to_string(&releases) - .with_context(|| format!("Failed to read releases file {releases:?}"))?; - let inputs: Vec = serde_json::from_str(&raw) - .with_context(|| format!("Invalid JSON in releases file {releases:?}"))?; - let records: Vec = - inputs.into_iter().map(ReleaseInput::into_record).collect(); - let policy_path = witness_policy.or_else(|| { std::env::var("AUTHS_WITNESS_POLICY_PATH") .ok() @@ -232,6 +361,21 @@ pub fn handle_compliance( &ctx.env_config, Some(passphrase_provider.clone()), )?; + + let records: Vec = if discover { + discover_releases(sdk_ctx.registry.as_ref(), &org_prefix) + .context("Failed to discover anchored releases from the org KEL")? + } else { + // clap's release_source group guarantees one of the two is set. + let Some(releases) = releases else { + return Err(anyhow!("one of --releases or --discover is required")); + }; + let raw = fs::read_to_string(&releases) + .with_context(|| format!("Failed to read releases file {releases:?}"))?; + let inputs: Vec = serde_json::from_str(&raw) + .with_context(|| format!("Invalid JSON in releases file {releases:?}"))?; + inputs.into_iter().map(ReleaseInput::into_record).collect() + }; let framework = ComplianceFramework::from(framework); let pack = if offline { @@ -306,6 +450,150 @@ pub fn handle_compliance( } Ok(()) } + + ComplianceSubcommand::Verify { pack, roots } => { + let envelope_json = fs::read_to_string(&pack) + .with_context(|| format!("Failed to read evidence pack {pack:?}"))?; + let roots_raw = fs::read_to_string(&roots) + .with_context(|| format!("Failed to read pinned-roots file {roots:?}"))?; + let pinned = parse_roots_typed(&roots_raw) + .map_err(|e| anyhow!("pinned roots file rejected: {e}"))?; + + // Hard rejections (no envelope, bad DSSE signature, unpinned org, + // KEL tamper, duplicity) surface here as errors → exit 1. + let verified = verify_signed_evidence_pack_offline(&envelope_json, &pinned) + .map_err(|e| anyhow!("evidence REJECTED: {e}"))?; + let authentic = verified.authentic(); + + if is_json_mode() { + JsonResponse { + success: authentic, + command: "compliance verify".to_string(), + data: Some(verify_verdict_json(&pack, &verified, authentic)), + error: (!authentic) + .then(|| "a row is inconsistent with the embedded log".to_string()), + } + .print()?; + } else { + print_verify_report(&pack, &verified, authentic); + } + + // A verified envelope whose rows diverge from the embedded log is + // still a rejection — the auditor's contract is the exit code. + if !authentic { + return Err(anyhow!( + "evidence REJECTED — a row is inconsistent with the embedded log" + )); + } + Ok(()) + } + } +} + +/// The wire label of a row's authority verdict, read from its serde tag (the +/// single source of truth for the vocabulary the pack itself carries). +fn authority_label(verdict: &auths_sdk::workflows::compliance::RowVerdict) -> String { + serde_json::to_value(&verdict.authority_at_release) + .ok() + .and_then(|v| v["authority_at_signing"].as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "?".to_string()) +} + +/// The machine-readable verdict for `compliance verify --json`. +fn verify_verdict_json( + pack_path: &Path, + verified: &VerifiedEvidencePack, + authentic: bool, +) -> serde_json::Value { + serde_json::json!({ + "pack": pack_path, + "org": verified.pack.org.as_str(), + "period": verified.pack.period, + "framework": verified.pack.framework, + "dsse_signature": "verified", + "org_key_source": "authenticated embedded KEL", + "org_kel_seq": verified.org_kel_seq.to_string(), + "root_pinned": true, + "rows": verified.verdicts, + "authentic": authentic, + }) +} + +/// Render the auditor-facing verification report (green = proven, red = rejected). +fn print_verify_report(pack_path: &Path, verified: &VerifiedEvidencePack, authentic: bool) { + let out = Output::stdout(); + println!( + "Offline evidence-pack verification of {}", + pack_path.display() + ); + println!(" Org: {}", verified.pack.org.as_str()); + println!( + " Period: {} Framework: {:?}", + verified.pack.period, verified.pack.framework + ); + println!( + " {}", + out.success( + "DSSE signature verified — org key resolved from the authenticated KEL, not a keychain" + ) + ); + println!( + " {}", + out.success(&format!( + "Root pinned · no duplicity · org KEL signature-authenticated (seq {})", + verified.org_kel_seq + )) + ); + println!(" Rows:"); + for v in &verified.verdicts { + let label = authority_label(v); + let row_ok = v.authority_consistent && label.starts_with("authorized"); + let mark = if v.authority_consistent { "✓" } else { "✗" }; + let transparency = match v.transparency_verified { + Some(true) => "logged", + Some(false) => "TRANSPARENCY-FAIL", + None => "unlogged", + }; + let line = format!( + "{mark} {} {} {label}", + truncated(&v.artifact_digest, 19), + truncated(v.signer.as_str(), 21), + ); + let line = if row_ok { + out.success(&line) + } else { + out.error(&line) + }; + println!( + " {line} {}", + out.dim(&format!( + "consistent={} transparency={transparency}", + v.authority_consistent + )) + ); + } + if authentic { + println!( + " {}", + out.success( + "Verdict: AUTHENTIC — evidence verified offline, trusting only the pinned roots" + ) + ); + } else { + println!( + " {}", + out.error("Verdict: REJECTED — evidence inconsistent with its own log") + ); + } +} + +/// First `n` characters with an ellipsis when truncated (row alignment helper). +fn truncated(s: &str, n: usize) -> String { + let cut: String = s.chars().take(n).collect(); + if cut.len() < s.len() { + format!("{cut}…") + } else { + cut } } diff --git a/crates/auths-cli/src/commands/demo.rs b/crates/auths-cli/src/commands/demo.rs index 888c95a6..1fb818e3 100644 --- a/crates/auths-cli/src/commands/demo.rs +++ b/crates/auths-cli/src/commands/demo.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, anyhow}; use chrono::Utc; use serde_json::Value; -use auths_sdk::domains::signing::service::sign_artifact_ephemeral; +use auths_sdk::domains::signing::service::{EphemeralSignRequest, sign_artifact_ephemeral}; use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; @@ -32,12 +32,15 @@ impl ExecutableCommand for DemoCommand { let t_sign = Instant::now(); let sign_result = sign_artifact_ephemeral( Utc::now(), - data, - Some("demo.txt".into()), - DEMO_COMMIT_SHA.into(), - None, - Some("auths demo — local only".into()), - None, + EphemeralSignRequest { + data, + artifact_name: Some("demo.txt".into()), + commit_sha: DEMO_COMMIT_SHA.into(), + expires_in: None, + note: Some("auths demo — local only".into()), + ci_env: None, + oidc_binding: None, + }, ) .map_err(|e| anyhow!("{}", e))?; let sign_ms = t_sign.elapsed().as_millis(); diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index 3b6ce453..adf53b10 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -540,6 +540,7 @@ pub fn handle_org( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, passphrase_provider.as_ref(), diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index 2c98e3fa..6bcf5f47 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -191,12 +191,15 @@ pub async fn handle_verify_unified( VerifyTarget::ArtifactFile(artifact_path) => { handle_artifact_verify( &artifact_path, - cmd.signature, - cmd.identity_bundle, - cmd.witness_receipts, - &cmd.witness_keys, - cmd.witness_threshold, - false, + crate::commands::artifact::verify::VerifyArtifactArgs { + signature: cmd.signature, + identity_bundle: cmd.identity_bundle, + witness_receipts: cmd.witness_receipts, + witness_keys: cmd.witness_keys, + witness_threshold: cmd.witness_threshold, + verify_commit: false, + oidc_policy: None, + }, ) .await } diff --git a/crates/auths-id/src/attestation/create.rs b/crates/auths-id/src/attestation/create.rs index c21c0b68..7717fa45 100644 --- a/crates/auths-id/src/attestation/create.rs +++ b/crates/auths-id/src/attestation/create.rs @@ -68,6 +68,10 @@ pub struct AttestationInput<'a> { pub commit_sha: Option, /// Signer type (machine, human, etc.). pub signer_type: Option, + /// Verified OIDC workload binding, if the signer presented one. + /// Included in the signed envelope so verify-time policy joins can + /// trust the claims. + pub oidc_binding: Option, } /// Creates a signed attestation by signing internally using the provided SecureSigner. @@ -105,6 +109,7 @@ pub fn create_signed_attestation( delegated_by, commit_sha, signer_type, + oidc_binding, } = input; // Length must match the declared curve. No length dispatch — the curve // came in-band from the caller, so this is pure validation. @@ -161,7 +166,7 @@ pub fn create_signed_attestation( commit_sha, commit_message: None, author: None, - oidc_binding: None, + oidc_binding, }; // Canonicalize using single source of truth diff --git a/crates/auths-id/tests/cases/attestation_input_golden.rs b/crates/auths-id/tests/cases/attestation_input_golden.rs index 12e79f80..b49bac83 100644 --- a/crates/auths-id/tests/cases/attestation_input_golden.rs +++ b/crates/auths-id/tests/cases/attestation_input_golden.rs @@ -61,6 +61,7 @@ fn canonical_bytes_are_byte_stable_under_fixed_seed() { delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }; // Build the attestation body we would sign over without actually @@ -130,6 +131,6 @@ fn test_build_attestation(input: &AttestationInput<'_>) -> auths_verifier::core: commit_sha: input.commit_sha.clone(), commit_message: None, author: None, - oidc_binding: None, + oidc_binding: input.oidc_binding.clone(), } } diff --git a/crates/auths-id/tests/cases/lifecycle.rs b/crates/auths-id/tests/cases/lifecycle.rs index 97e1c2a4..201969f7 100644 --- a/crates/auths-id/tests/cases/lifecycle.rs +++ b/crates/auths-id/tests/cases/lifecycle.rs @@ -131,6 +131,7 @@ fn create_test_attestation( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, &provider, diff --git a/crates/auths-infra-http/Cargo.toml b/crates/auths-infra-http/Cargo.toml index 87aa3c7d..feb4d373 100644 --- a/crates/auths-infra-http/Cargo.toml +++ b/crates/auths-infra-http/Cargo.toml @@ -18,7 +18,10 @@ auths-crypto = { workspace = true } auths-oidc-port = { path = "../auths-oidc-port", version = "0.1.3" } auths-verifier = { workspace = true, features = ["native"] } futures-util = "0.3" -jsonwebtoken = { version = "10.3.0", features = ["use_pem"] } +# rust_crypto: the validation backend. Declared HERE because this crate is the +# one calling `decode()` — without it, any build graph that does not happen to +# unify the feature in from another consumer panics at runtime. +jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"] } reqwest = { version = "0.13.2", features = ["json", "form"] } thiserror.workspace = true tokio.workspace = true diff --git a/crates/auths-infra-http/src/lib.rs b/crates/auths-infra-http/src/lib.rs index 18740cf8..84ebf4c2 100644 --- a/crates/auths-infra-http/src/lib.rs +++ b/crates/auths-infra-http/src/lib.rs @@ -43,7 +43,7 @@ pub use oidc_platforms::{ circleci_oidc_token, github_actions_oidc_token, gitlab_ci_oidc_token, normalize_workload_claims, }; pub use oidc_tsa_client::HttpTimestampClient; -pub use oidc_validator::{HttpJwksClient, HttpJwtValidator, OidcTokenClaims}; +pub use oidc_validator::{HttpJwksClient, HttpJwtValidator, OidcTokenClaims, PinnedJwksClient}; pub use oobi_resolver::{HttpOobiError, HttpOobiResolver, MAX_OOBI_BYTES}; pub use pairing_client::HttpPairingRelayClient; pub use platform_context::resolve_verified_platform_context; diff --git a/crates/auths-infra-http/src/oidc_platforms.rs b/crates/auths-infra-http/src/oidc_platforms.rs index 24faf850..4f61f3e9 100644 --- a/crates/auths-infra-http/src/oidc_platforms.rs +++ b/crates/auths-infra-http/src/oidc_platforms.rs @@ -100,6 +100,12 @@ pub fn normalize_workload_claims( serde_json::Value::String(workflow.to_string()), ); } + if let Some(workflow_ref) = claims.get("workflow_ref").and_then(|v| v.as_str()) { + normalized.insert( + "workflow_ref".to_string(), + serde_json::Value::String(workflow_ref.to_string()), + ); + } if let Some(job_workflow_ref) = claims.get("job_workflow_ref").and_then(|v| v.as_str()) { normalized.insert( @@ -209,6 +215,7 @@ mod tests { "repository": "owner/repo", "actor": "github-user", "workflow": "test.yml", + "workflow_ref": "owner/repo/.github/workflows/test.yml@refs/heads/main", "job_workflow_ref": "owner/repo/.github/workflows/test.yml@main", "run_id": "12345", "run_number": "1" @@ -225,6 +232,11 @@ mod tests { normalized.get("actor").and_then(|v| v.as_str()), Some("github-user") ); + assert_eq!( + normalized.get("workflow_ref").and_then(|v| v.as_str()), + Some("owner/repo/.github/workflows/test.yml@refs/heads/main"), + "workflow_ref must survive normalization — the policy join pins it" + ); } #[test] diff --git a/crates/auths-infra-http/src/oidc_validator.rs b/crates/auths-infra-http/src/oidc_validator.rs index ba571f8d..e76be84a 100644 --- a/crates/auths-infra-http/src/oidc_validator.rs +++ b/crates/auths-infra-http/src/oidc_validator.rs @@ -290,6 +290,34 @@ impl JwksClient for HttpJwksClient { } } +/// A JWKS client pinned to a fixed key set — no network, ever. +/// +/// The adapter for air-gapped and policy-pinned deployments: the verifier +/// operator ships the issuer's JWKS alongside the policy (exactly like +/// pinned trust roots) and token validation runs against those keys only. +/// Also the honest adapter for hermetic tests. +pub struct PinnedJwksClient { + jwks: serde_json::Value, +} + +impl PinnedJwksClient { + /// Pin a JWKS document (an object with a `keys` array). + /// + /// # Args + /// + /// * `jwks`: The JWKS to serve for every issuer query. + pub fn new(jwks: serde_json::Value) -> Self { + Self { jwks } + } +} + +#[async_trait] +impl JwksClient for PinnedJwksClient { + async fn fetch_jwks(&self, _issuer_url: &str) -> Result { + Ok(self.jwks.clone()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/auths-keri/src/events.rs b/crates/auths-keri/src/events.rs index 7768abe8..ff636261 100644 --- a/crates/auths-keri/src/events.rs +++ b/crates/auths-keri/src/events.rs @@ -1270,6 +1270,109 @@ pub fn parse_delegated_attachment( Ok((sigs, couples)) } +/// A device-signed delegated inception serialized for a single wire field. +/// +/// Carried as base64url-no-pad(JSON) wherever a transport needs a signed `dip` +/// in one string (e.g. a pairing response's `responder_inception_event`). The +/// CESR signature attachment travels alongside the event so the receiver can +/// replay the signed dip exactly as the device emitted it. +/// +/// This is the ONE wire form for a signed dip — every producer (CLI joiner, +/// mobile FFI) and consumer (the anchoring initiator) goes through +/// [`encode_signed_dip`] / [`decode_signed_dip`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireSignedDip { + /// The device's delegated-inception event. + pub event: DipEvent, + /// base64url-no-pad of the device's CESR signature attachment over the dip. + pub attachment_b64: String, +} + +/// Encode a device-signed dip into its single-string wire form. +/// +/// Args: +/// * `dip`: The finalized, device-signed delegated-inception event. +/// * `attachment`: The device's CESR signature attachment bytes (from +/// [`serialize_attachment`]). +pub fn encode_signed_dip(dip: &DipEvent, attachment: &[u8]) -> Result { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let wire = WireSignedDip { + event: dip.clone(), + attachment_b64: URL_SAFE_NO_PAD.encode(attachment), + }; + let json = + serde_json::to_vec(&wire).map_err(|e| AttachmentError::Encode(format!("dip json: {e}")))?; + Ok(URL_SAFE_NO_PAD.encode(json)) +} + +/// Decode a single-string wire form back into the dip and its attachment bytes. +/// Inverse of [`encode_signed_dip`]. +/// +/// Args: +/// * `encoded`: The base64url-no-pad(JSON) wire string. +pub fn decode_signed_dip(encoded: &str) -> Result<(DipEvent, Vec), AttachmentError> { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let json = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|e| AttachmentError::Decode(format!("dip envelope: {e}")))?; + let wire: WireSignedDip = serde_json::from_slice(&json) + .map_err(|e| AttachmentError::Decode(format!("dip json: {e}")))?; + let attachment = URL_SAFE_NO_PAD + .decode(&wire.attachment_b64) + .map_err(|e| AttachmentError::Decode(format!("dip attachment: {e}")))?; + Ok((wire.event, attachment)) +} + +/// Single-string wire form of a signed rotation: base64url-no-pad of the +/// JSON `{event, attachment_b64}` pair. +/// +/// This is the ONE wire form for a signed `rot` in transit — every producer +/// (the mobile FFI's shared-KEL rotation assembler) and consumer (the daemon +/// endpoint that receives a co-authored rotation) goes through +/// [`encode_signed_rot`] / [`decode_signed_rot`]. Mirrors [`WireSignedDip`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireSignedRot { + /// The rotation event. + pub event: RotEvent, + /// base64url-no-pad of the CESR indexed-signature attachment over the rot. + pub attachment_b64: String, +} + +/// Encode a signed rot into its single-string wire form. +/// +/// Args: +/// * `rot`: The finalized, signed rotation event. +/// * `attachment`: The CESR indexed-signature attachment bytes (from +/// [`serialize_attachment`]). +pub fn encode_signed_rot(rot: &RotEvent, attachment: &[u8]) -> Result { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let wire = WireSignedRot { + event: rot.clone(), + attachment_b64: URL_SAFE_NO_PAD.encode(attachment), + }; + let json = + serde_json::to_vec(&wire).map_err(|e| AttachmentError::Encode(format!("rot json: {e}")))?; + Ok(URL_SAFE_NO_PAD.encode(json)) +} + +/// Decode a single-string wire form back into the rot and its attachment bytes. +/// Inverse of [`encode_signed_rot`]. +/// +/// Args: +/// * `encoded`: The base64url-no-pad(JSON) wire string. +pub fn decode_signed_rot(encoded: &str) -> Result<(RotEvent, Vec), AttachmentError> { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let json = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|e| AttachmentError::Decode(format!("rot envelope: {e}")))?; + let wire: WireSignedRot = serde_json::from_slice(&json) + .map_err(|e| AttachmentError::Decode(format!("rot json: {e}")))?; + let attachment = URL_SAFE_NO_PAD + .decode(&wire.attachment_b64) + .map_err(|e| AttachmentError::Decode(format!("rot attachment: {e}")))?; + Ok((wire.event, attachment)) +} + /// CESR `Seqner` qb64 width: `0A` hard code (2) + 22 base64 chars = 24. const SEQNER_QB64_LEN: usize = 24; diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 8fc1756a..41ad39a6 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -84,7 +84,8 @@ pub use error::{KeriTranslationError, TelError}; pub use events::{ AgentScope, DipEvent, DipEventInit, DrtEvent, DrtEventInit, Event, IcpEvent, IcpEventInit, IndexedSignature, IxnEvent, KERI_VERSION_PREFIX, KeriSequence, RotEvent, RotEventInit, Seal, - SignedEvent, SourceSeal, decode_agent_scope, encode_agent_scope, parse_attachment, + SignedEvent, SourceSeal, WireSignedDip, WireSignedRot, decode_agent_scope, decode_signed_dip, + decode_signed_rot, encode_agent_scope, encode_signed_dip, encode_signed_rot, parse_attachment, parse_delegated_attachment, parse_source_seal_couples, serialize_attachment, serialize_source_seal_couples, }; diff --git a/crates/auths-mcp-server/Cargo.toml b/crates/auths-mcp-server/Cargo.toml index 9e887190..a064817c 100644 --- a/crates/auths-mcp-server/Cargo.toml +++ b/crates/auths-mcp-server/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition = "2024" rust-version = "1.93" authors = ["Auths Contributors"] -description = "Reference MCP tool server with Auths-backed JWT authorization" +description = "Reference MCP tool server with Auths-backed JWT and KERI-presentation authorization" license.workspace = true repository.workspace = true homepage.workspace = true @@ -20,6 +20,7 @@ name = "auths_mcp_server" path = "src/lib.rs" [dependencies] +auths-api = { path = "../auths-api", version = "0.1.3" } auths-jwt.workspace = true auths-telemetry.workspace = true auths-verifier = { workspace = true, features = ["native"] } diff --git a/crates/auths-mcp-server/src/config.rs b/crates/auths-mcp-server/src/config.rs index 17785710..776114df 100644 --- a/crates/auths-mcp-server/src/config.rs +++ b/crates/auths-mcp-server/src/config.rs @@ -2,6 +2,13 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::path::PathBuf; + +use auths_rp::{Audience, DEFAULT_CHALLENGE_TTL_SECS}; +use auths_sdk::keychain::KeyAlias; + +/// Default bound on live single-use challenges (DoS hygiene). +pub const DEFAULT_MAX_LIVE_CHALLENGES: usize = 10_000; /// Configuration for the MCP tool server. #[derive(Debug, Clone)] @@ -113,3 +120,58 @@ impl McpServerConfig { self } } + +/// Settings for the KERI presentation (no-issuer) authentication path. +/// +/// Carries only parsed types — an empty audience or alias is unrepresentable here, +/// so the router never re-checks them. Building the server state +/// [`with_keri_presentation`](crate::state::McpServerState::with_keri_presentation) +/// from these settings is what mounts the `Auths-Presentation` scheme and the +/// `/v1/auth/challenge` mint route alongside the JWT path. +#[derive(Debug, Clone)] +pub struct KeriPresentationConfig { + /// Path to the Auths registry the verifier resolves KELs/TELs/credentials from. + pub registry_path: PathBuf, + + /// Keychain alias of the pinned issuer whose namespace holds presented credentials. + pub issuer_alias: KeyAlias, + + /// This server's own audience — the trust source, never the wire header. + pub audience: Audience, + + /// TTL of a minted single-use challenge, in seconds. + pub challenge_ttl_secs: i64, + + /// Bound on live challenges (the mint route answers 503 at capacity). + pub max_live_challenges: usize, +} + +impl KeriPresentationConfig { + /// Presentation settings with the default challenge TTL and capacity bound. + /// + /// Args: + /// * `registry_path`: Path to the Auths registry repository. + /// * `issuer_alias`: Keychain alias of the pinned credential issuer. + /// * `audience`: This server's canonical audience. + pub fn new(registry_path: PathBuf, issuer_alias: KeyAlias, audience: Audience) -> Self { + Self { + registry_path, + issuer_alias, + audience, + challenge_ttl_secs: DEFAULT_CHALLENGE_TTL_SECS, + max_live_challenges: DEFAULT_MAX_LIVE_CHALLENGES, + } + } + + /// Set the challenge TTL in seconds. + pub fn with_challenge_ttl_secs(mut self, secs: i64) -> Self { + self.challenge_ttl_secs = secs; + self + } + + /// Set the bound on live challenges. + pub fn with_max_live_challenges(mut self, max: usize) -> Self { + self.max_live_challenges = max; + self + } +} diff --git a/crates/auths-mcp-server/src/keri_auth.rs b/crates/auths-mcp-server/src/keri_auth.rs index 805e4960..88632e5c 100644 --- a/crates/auths-mcp-server/src/keri_auth.rs +++ b/crates/auths-mcp-server/src/keri_auth.rs @@ -1,11 +1,12 @@ //! KERI-native MCP tool authorization (dual-mode, alongside the JWT path). //! //! The JWT path (`auth.rs`) validates an OIDC-bridge-minted token against a JWKS endpoint — -//! an issuer in the trust path. This module adds the no-issuer alternative: an agent presents +//! an issuer in the trust path. This module is the no-issuer alternative: an agent presents //! proof-of-control of a delegated KERI credential (an `Auths-Presentation`), the relying //! party verifies it offline via `auths_sdk::authenticate_presentation`, and the **same** -//! per-tool capability gate produces a [`VerifiedAgent`]. Both modes can be served together, -//! so OAuth/JWT clients keep working while new agents adopt the no-issuer passport. +//! per-tool capability gate produces a [`VerifiedAgent`]. The router serves both modes +//! together when built with a [`KeriPresentationConfig`], so OAuth/JWT clients keep working +//! while new agents adopt the no-issuer passport. //! //! No verification logic is duplicated: the full verify flow lives in `auths-sdk`, and the //! capability gate ([`gate`]) is a free function so it is unit-testable without a registry. @@ -13,13 +14,16 @@ use std::collections::HashMap; use std::sync::Arc; -use auths_rp::{Audience, ChallengeStore, VerifiedPrincipal, WirePresentation}; +use auths_rp::{ + Audience, ChallengeStore, InMemoryChallengeStore, VerifiedPrincipal, WirePresentation, +}; use auths_sdk::context::AuthsContext; use auths_sdk::domains::credentials::authenticate_presentation; use auths_sdk::keychain::KeyAlias; use auths_verifier::Capability; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; +use crate::config::KeriPresentationConfig; use crate::error::McpServerError; use crate::types::VerifiedAgent; @@ -63,11 +67,55 @@ impl KeriToolAuth { } } + /// Build a KERI tool authorizer from parsed presentation settings. + /// + /// Owns the shipped bounded in-memory challenge store; use [`KeriToolAuth::new`] + /// to supply a custom [`ChallengeStore`] backend (e.g. shared across nodes). + /// + /// Args: + /// * `ctx`: Auths context over the registry at `config.registry_path`. + /// * `config`: Parsed presentation settings (issuer, audience, TTL, capacity). + /// * `tool_capabilities`: Map of tool name to the required capability string. + pub fn from_config( + ctx: Arc, + config: &KeriPresentationConfig, + tool_capabilities: HashMap, + ) -> Self { + let challenges: Arc = Arc::new(InMemoryChallengeStore::with_ttl( + config.max_live_challenges, + Duration::seconds(config.challenge_ttl_secs), + )); + Self::new( + ctx, + config.issuer_alias.clone(), + challenges, + config.audience.clone(), + tool_capabilities, + ) + } + + /// Authenticate an `Auths-Presentation` into a [`VerifiedAgent`] — no per-tool gate. + /// + /// The route layer gates per tool afterwards, exactly as it does for the JWT + /// path, so one capability check governs both schemes. + /// + /// Args: + /// * `presentation`: The `WirePresentation` parsed from the `Authorization` header. + /// * `now`: The current time (read at the server boundary). + pub async fn authenticate( + &self, + presentation: WirePresentation, + now: DateTime, + ) -> Result { + Ok(verified_agent(&self.verify(presentation, now).await?)) + } + /// Authorize a tool call from an `Auths-Presentation` (no JWT, no issuer in the path). /// /// Verifies the presentation offline (single-use challenge, audience-binding, revocation) /// via `auths_sdk::authenticate_presentation`, then checks the per-tool capability — - /// yielding the same [`VerifiedAgent`] shape the JWT path produces. + /// yielding the same [`VerifiedAgent`] shape the JWT path produces. The one-call + /// embedding API; the Axum middleware authenticates here and gates in the route layer. /// /// Args: /// * `presentation`: The `WirePresentation` parsed from the `Authorization` header. @@ -79,7 +127,17 @@ impl KeriToolAuth { tool_name: &str, now: DateTime, ) -> Result { - let principal = authenticate_presentation( + let principal = self.verify(presentation, now).await?; + gate(&self.tool_capabilities, &principal, tool_name) + } + + /// Verify the presentation offline, consuming its single-use challenge. + async fn verify( + &self, + presentation: WirePresentation, + now: DateTime, + ) -> Result { + authenticate_presentation( &self.ctx, &self.issuer_alias, &*self.challenges, @@ -88,8 +146,17 @@ impl KeriToolAuth { now, ) .await - .map_err(|e| McpServerError::Unauthorized(e.to_string()))?; - gate(&self.tool_capabilities, &principal, tool_name) + .map_err(|e| McpServerError::Unauthorized(e.to_string())) + } + + /// The single-use challenge store (shared with the `/v1/auth/challenge` mint route). + pub fn challenges(&self) -> Arc { + Arc::clone(&self.challenges) + } + + /// The audience presentations must bind to. + pub fn audience(&self) -> &Audience { + &self.audience } /// The tool-to-capability map. diff --git a/crates/auths-mcp-server/src/lib.rs b/crates/auths-mcp-server/src/lib.rs index 001304c7..f92a16f2 100644 --- a/crates/auths-mcp-server/src/lib.rs +++ b/crates/auths-mcp-server/src/lib.rs @@ -2,15 +2,32 @@ #![allow(clippy::disallowed_methods)] //! # auths-mcp-server //! -//! Reference MCP tool server that validates Auths-backed JWTs for tool authorization. +//! Reference MCP tool server that authorizes tool calls with either Auths-backed +//! JWTs or KERI `Auths-Presentation`s (the no-issuer passport). //! -//! ## How it works +//! ## The JWT mode (an issuer in the path) //! //! 1. Agent acquires a JWT from the OIDC bridge (via attestation chain exchange) //! 2. Agent calls MCP tool endpoints with `Authorization: Bearer ` //! 3. MCP server validates the JWT against the bridge's JWKS endpoint //! 4. MCP server checks that the JWT's capabilities match the requested tool //! 5. Tool executes if authorized, returns 401/403 otherwise +//! +//! ## The KERI presentation mode (no issuer in the path) +//! +//! Enabled by building the state with +//! [`McpServerState::with_keri_presentation`] (the binary switches it on via +//! `AUTHS_MCP_REGISTRY`): +//! +//! 1. Agent mints a single-use nonce at `GET /v1/auth/challenge` +//! 2. Agent signs a presentation of its delegated credential over the nonce +//! 3. Agent calls tools with `Authorization: Auths-Presentation ` +//! 4. MCP server verifies the presentation offline against the KERI registry +//! (challenge consume, audience binding, revocation) — no token service called +//! 5. The same per-tool capability gate authorizes the call +//! +//! Both modes share one capability gate, so a tool grants the same way no matter +//! how the agent authenticated. pub mod auth; pub mod config; @@ -24,7 +41,7 @@ pub mod tools; pub mod types; pub use auth::AuthsToolAuth; -pub use config::McpServerConfig; +pub use config::{KeriPresentationConfig, McpServerConfig}; pub use error::{McpServerError, McpServerResult}; pub use keri_auth::KeriToolAuth; pub use routes::router; diff --git a/crates/auths-mcp-server/src/main.rs b/crates/auths-mcp-server/src/main.rs index 15520bf2..b3c549e1 100644 --- a/crates/auths-mcp-server/src/main.rs +++ b/crates/auths-mcp-server/src/main.rs @@ -12,10 +12,33 @@ //! - `AUTHS_MCP_CORS` - Enable CORS (set to "1" or "true") //! - `AUTHS_MCP_LOG_LEVEL` - Log level (default: info) //! - `PORT` - Alternative port binding (cloud platform support) +//! +//! KERI presentation auth (`Authorization: Auths-Presentation`, no issuer in the +//! path) is switched on by `AUTHS_MCP_REGISTRY`; the rest are then required or +//! defaulted: +//! +//! - `AUTHS_MCP_REGISTRY` - Path to the Auths registry repository (enables the mode) +//! - `AUTHS_MCP_ISSUER_ALIAS` - Keychain alias of the pinned credential issuer +//! - `AUTHS_MCP_PRESENTATION_AUDIENCE` - This server's audience for presentations +//! - `AUTHS_MCP_CHALLENGE_TTL_SECS` - Single-use challenge TTL (default: 120) +//! - `AUTHS_MCP_MAX_LIVE_CHALLENGES` - Bound on live challenges (default: 10000) use std::env; - -use auths_mcp_server::{McpServerConfig, McpServerState}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; + +use auths_mcp_server::{KeriPresentationConfig, KeriToolAuth, McpServerConfig, McpServerState}; +use auths_rp::Audience; +use auths_sdk::attestation::AttestationSink; +use auths_sdk::context::AuthsContext; +use auths_sdk::core_config::EnvironmentConfig; +use auths_sdk::keychain::{KeyAlias, get_platform_keychain_with_config}; +use auths_sdk::ports::{AttestationSource, IdentityStorage, RegistryBackend, SystemClock}; +use auths_sdk::storage::{ + GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, +}; use auths_telemetry::init_tracing; use auths_telemetry::sinks::stdout::new_stdout_sink; @@ -58,6 +81,8 @@ fn main() -> anyhow::Result<()> { config = config.with_log_level(level); } + let keri_config = keri_presentation_config_from_env()?; + init_tracing(&config.log_level, false); let telemetry = @@ -68,7 +93,23 @@ fn main() -> anyhow::Result<()> { tracing::info!("Expected issuer: {}", config.expected_issuer); tracing::info!("Bind address: {}", config.bind_addr); - let state = McpServerState::new(config.clone()); + let state = match &keri_config { + Some(keri_cfg) => { + let ctx = build_registry_context(&keri_cfg.registry_path)?; + tracing::info!( + "Auths-Presentation auth enabled (registry: {}, audience: {})", + keri_cfg.registry_path.display(), + keri_cfg.audience.as_str() + ); + let keri = Arc::new(KeriToolAuth::from_config( + ctx, + keri_cfg, + config.tool_capabilities.clone(), + )); + McpServerState::with_keri_presentation(config.clone(), keri) + } + None => McpServerState::new(config.clone()), + }; let app = auths_mcp_server::router(state, &config); tracing::info!("Starting MCP server on {}", config.bind_addr); @@ -86,3 +127,72 @@ fn main() -> anyhow::Result<()> { Ok(()) } + +/// Parse the optional KERI presentation settings from the environment. +/// +/// `AUTHS_MCP_REGISTRY` switches the mode on; the audience and issuer alias are +/// then required — a partial configuration fails loudly at boot instead of +/// silently serving without the presentation path. +fn keri_presentation_config_from_env() -> anyhow::Result> { + let Ok(registry) = env::var("AUTHS_MCP_REGISTRY") else { + return Ok(None); + }; + let audience_raw = env::var("AUTHS_MCP_PRESENTATION_AUDIENCE") + .context("AUTHS_MCP_PRESENTATION_AUDIENCE must be set when AUTHS_MCP_REGISTRY is")?; + let audience = Audience::parse(&audience_raw).map_err(|e| { + anyhow::anyhow!("invalid AUTHS_MCP_PRESENTATION_AUDIENCE '{audience_raw}': {e}") + })?; + let issuer_alias = env::var("AUTHS_MCP_ISSUER_ALIAS") + .context("AUTHS_MCP_ISSUER_ALIAS must be set when AUTHS_MCP_REGISTRY is")?; + + let mut keri_cfg = KeriPresentationConfig::new( + PathBuf::from(registry), + KeyAlias::new_unchecked(issuer_alias), + audience, + ); + if let Ok(ttl) = env::var("AUTHS_MCP_CHALLENGE_TTL_SECS") { + keri_cfg = keri_cfg.with_challenge_ttl_secs( + ttl.parse() + .context("AUTHS_MCP_CHALLENGE_TTL_SECS must be an integer")?, + ); + } + if let Ok(max) = env::var("AUTHS_MCP_MAX_LIVE_CHALLENGES") { + keri_cfg = keri_cfg.with_max_live_challenges( + max.parse() + .context("AUTHS_MCP_MAX_LIVE_CHALLENGES must be an integer")?, + ); + } + Ok(Some(keri_cfg)) +} + +/// Assemble the registry-backed verification context — the binary's composition +/// boundary, mirroring the platform's other server binaries. The keychain backend +/// is selected from the environment (`EnvironmentConfig::from_env`), so the same +/// keychain the issuing CLI writes is the one this server resolves the pinned +/// issuer from. +fn build_registry_context(registry_path: &Path) -> anyhow::Result> { + let env_config = EnvironmentConfig::from_env(); + let registry: Arc = Arc::new( + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(registry_path)), + ); + let identity_storage: Arc = + Arc::new(RegistryIdentityStorage::new(registry_path.to_path_buf())); + let attestation_store = Arc::new(RegistryAttestationStorage::new(registry_path)); + let attestation_sink: Arc = + Arc::clone(&attestation_store) as Arc; + let attestation_source: Arc = + attestation_store as Arc; + let key_storage = get_platform_keychain_with_config(&env_config) + .map_err(|e| anyhow::anyhow!("failed to initialize keychain: {e}"))?; + + Ok(Arc::new( + AuthsContext::builder() + .registry(registry) + .key_storage(Arc::from(key_storage)) + .clock(Arc::new(SystemClock)) + .identity_storage(identity_storage) + .attestation_sink(attestation_sink) + .attestation_source(attestation_source) + .build(), + )) +} diff --git a/crates/auths-mcp-server/src/middleware.rs b/crates/auths-mcp-server/src/middleware.rs index fd277ae7..361bba5a 100644 --- a/crates/auths-mcp-server/src/middleware.rs +++ b/crates/auths-mcp-server/src/middleware.rs @@ -1,4 +1,4 @@ -//! Axum middleware for JWT authentication and tool authorization. +//! Axum authentication middleware: Bearer JWTs and KERI `Auths-Presentation`s. use axum::{ extract::{Request, State}, @@ -7,64 +7,95 @@ use axum::{ response::Response, }; +use auths_rp::{AUTHS_PRESENTATION_SCHEME, parse_presentation_header}; + use crate::error::McpServerError; use crate::state::McpServerState; use crate::types::VerifiedAgent; -/// Extracts the Bearer token from the Authorization header. -pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) -} - -/// Axum middleware that validates the JWT and inserts claims into request extensions. +/// Axum middleware that authenticates the request and inserts a [`VerifiedAgent`] +/// into request extensions. +/// +/// Dispatches on the `Authorization` scheme: +/// * `Bearer ` — validated against the OIDC bridge's JWKS (an issuer in the path). +/// * `Auths-Presentation ` — verified offline against the KERI registry (no +/// issuer in the path); requires the state to be built with +/// [`McpServerState::with_keri_presentation`]. /// -/// Args: -/// * `state`: The shared MCP server state containing the AuthsToolAuth instance. -/// * `headers`: Request headers (for Authorization extraction). -/// * `request`: The incoming request. -/// * `next`: The next middleware/handler in the chain. +/// Per-tool capability gating happens in the route handler, identically for both schemes. /// /// Usage: /// ```ignore /// let app = Router::new() /// .route("/mcp/tools/:tool", post(handle_tool)) -/// .route_layer(middleware::from_fn_with_state(state.clone(), jwt_auth_middleware)); +/// .route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); /// ``` -pub async fn jwt_auth_middleware( +pub async fn auth_middleware( State(state): State, headers: HeaderMap, mut request: Request, next: Next, ) -> Result { - let token = match extract_bearer_token(&headers) { - Some(t) => t, - None => { - emit_auth_failure("mcp:auth"); - return Err(McpServerError::Unauthorized( - "missing Authorization header".to_string(), - )); - } + let Some(header) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + else { + emit_auth_failure("mcp:auth"); + return Err(McpServerError::Unauthorized( + "missing Authorization header".to_string(), + )); }; - let claims = match state.auth().validate_jwt(token).await { - Ok(c) => c, + let authenticated = if let Some(token) = header.strip_prefix("Bearer ") { + authenticate_jwt(&state, token).await + } else if header.starts_with(AUTHS_PRESENTATION_SCHEME) { + authenticate_presentation_header(&state, header).await + } else { + Err(McpServerError::Unauthorized( + "unsupported authorization scheme (expected 'Bearer' or 'Auths-Presentation')" + .to_string(), + )) + }; + + let agent = match authenticated { + Ok(agent) => agent, Err(e) => { emit_auth_failure("mcp:auth"); return Err(e); } }; - let agent = VerifiedAgent { + request.extensions_mut().insert(agent); + Ok(next.run(request).await) +} + +/// Validate a Bearer JWT into a [`VerifiedAgent`]. +async fn authenticate_jwt( + state: &McpServerState, + token: &str, +) -> Result { + let claims = state.auth().validate_jwt(token).await?; + Ok(VerifiedAgent { did: claims.sub, keri_prefix: claims.keri_prefix, capabilities: claims.capabilities, - }; + }) +} - request.extensions_mut().insert(agent); - Ok(next.run(request).await) +/// Verify an `Auths-Presentation` header into a [`VerifiedAgent`] (no issuer in the path). +async fn authenticate_presentation_header( + state: &McpServerState, + header: &str, +) -> Result { + let Some(keri) = state.keri() else { + return Err(McpServerError::Unauthorized( + "Auths-Presentation authentication is not enabled on this server".to_string(), + )); + }; + // Parse only the wire shape here; never log the header (nonce/signature). + let wire = parse_presentation_header(header) + .map_err(|e| McpServerError::Unauthorized(format!("malformed presentation: {e}")))?; + keri.authenticate(wire, chrono::Utc::now()).await } fn emit_auth_failure(action: &str) { diff --git a/crates/auths-mcp-server/src/routes.rs b/crates/auths-mcp-server/src/routes.rs index 077388ae..239bd436 100644 --- a/crates/auths-mcp-server/src/routes.rs +++ b/crates/auths-mcp-server/src/routes.rs @@ -1,5 +1,6 @@ //! Axum route handlers. +use auths_api::rp_auth::{ChallengeMintState, challenge_handler}; use axum::{ Extension, Json, Router, extract::{Path, State}, @@ -13,7 +14,7 @@ use tower_http::trace::TraceLayer; use crate::config::McpServerConfig; use crate::error::McpServerError; -use crate::middleware::jwt_auth_middleware; +use crate::middleware::auth_middleware; use crate::state::McpServerState; use crate::tools::{ DeployRequest, ReadFileRequest, ToolResponse, WriteFileRequest, execute_deploy, @@ -23,6 +24,12 @@ use crate::types::VerifiedAgent; /// Build the application router. /// +/// Tool routes accept both `Bearer` JWTs and KERI `Auths-Presentation`s (see +/// [`auth_middleware`]). When the state carries a KERI authorizer, the +/// `/v1/auth/challenge` mint route is mounted too — the platform's reference +/// `challenge_handler` over the authorizer's own single-use challenge store — +/// so a relying party gets the full no-issuer presentation flow out of the box. +/// /// Args: /// * `state`: The shared MCP server state. /// * `config`: The server configuration. @@ -31,7 +38,7 @@ pub fn router(state: McpServerState, config: &McpServerConfig) -> Router { .route("/mcp/tools/{tool_name}", post(handle_tool_call)) .route_layer(middleware::from_fn_with_state( state.clone(), - jwt_auth_middleware, + auth_middleware, )); let public_routes = Router::new() @@ -42,10 +49,22 @@ pub fn router(state: McpServerState, config: &McpServerConfig) -> Router { .route("/mcp/tools", get(list_tools)) .route("/health", get(health)); - let app = public_routes - .merge(protected_routes) - .with_state(state) - .layer(TraceLayer::new_for_http()); + // KERI presentation path: agents mint a single-use challenge here, sign over + // it, and call tools with `Authorization: Auths-Presentation `. + let challenge_mint = state.keri().map(|keri| { + Router::new() + .route("/v1/auth/challenge", get(challenge_handler)) + .with_state(ChallengeMintState::new( + keri.challenges(), + keri.audience().clone(), + )) + }); + + let mut app = public_routes.merge(protected_routes).with_state(state); + if let Some(mint) = challenge_mint { + app = app.merge(mint); + } + let app = app.layer(TraceLayer::new_for_http()); if config.enable_cors { app.layer( diff --git a/crates/auths-mcp-server/src/state.rs b/crates/auths-mcp-server/src/state.rs index 58dfcffa..19063683 100644 --- a/crates/auths-mcp-server/src/state.rs +++ b/crates/auths-mcp-server/src/state.rs @@ -4,8 +4,13 @@ use std::sync::Arc; use crate::auth::AuthsToolAuth; use crate::config::McpServerConfig; +use crate::keri_auth::KeriToolAuth; /// Shared state for the MCP server, wrapped in Arc for Axum handlers. +/// +/// The JWT authorizer is always present; the KERI presentation authorizer is the +/// optional second mode — its presence here is the single switch the router and +/// middleware read, so a server built without it has no presentation path at all. #[derive(Clone)] pub struct McpServerState { inner: Arc, @@ -13,12 +18,30 @@ pub struct McpServerState { struct McpServerStateInner { auth: AuthsToolAuth, + keri: Option>, config: McpServerConfig, } impl McpServerState { - /// Create a new MCP server state from configuration. + /// Create a JWT-only MCP server state from configuration. pub fn new(config: McpServerConfig) -> Self { + Self::assemble(config, None) + } + + /// Create a dual-mode state: Bearer JWTs plus the KERI presentation path. + /// + /// Agents may then also authenticate with `Authorization: Auths-Presentation + /// ` signed over a nonce minted at `/v1/auth/challenge` — no issuer in + /// the trust path. + /// + /// Args: + /// * `config`: The server configuration (JWT settings, tool capabilities). + /// * `keri`: The KERI presentation authorizer (see [`KeriToolAuth::from_config`]). + pub fn with_keri_presentation(config: McpServerConfig, keri: Arc) -> Self { + Self::assemble(config, Some(keri)) + } + + fn assemble(config: McpServerConfig, keri: Option>) -> Self { let auth = AuthsToolAuth::with_options( &config.jwks_url, &config.expected_issuer, @@ -29,7 +52,7 @@ impl McpServerState { ); Self { - inner: Arc::new(McpServerStateInner { auth, config }), + inner: Arc::new(McpServerStateInner { auth, keri, config }), } } @@ -38,6 +61,11 @@ impl McpServerState { &self.inner.auth } + /// The KERI presentation authorizer, if this server mounts the presentation path. + pub fn keri(&self) -> Option<&Arc> { + self.inner.keri.as_ref() + } + /// Get a reference to the server config. pub fn config(&self) -> &McpServerConfig { &self.inner.config diff --git a/crates/auths-mcp-server/tests/cases/routes.rs b/crates/auths-mcp-server/tests/cases/routes.rs index 37fce38b..47b62685 100644 --- a/crates/auths-mcp-server/tests/cases/routes.rs +++ b/crates/auths-mcp-server/tests/cases/routes.rs @@ -257,3 +257,66 @@ async fn unknown_tool_returns_404() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn unsupported_authorization_scheme_returns_401() { + let (base_url, _handle) = start_mock_jwks_server().await; + let app = test_router(&base_url); + + let resp = app + .oneshot( + Request::post("/mcp/tools/read_file") + .header("content-type", "application/json") + .header("authorization", "Basic dXNlcjpwYXNz") + .body(Body::from(r#"{"path":"/tmp/test.txt"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn presentation_scheme_without_keri_authorizer_returns_401() { + let (base_url, _handle) = start_mock_jwks_server().await; + let app = test_router(&base_url); + + let resp = app + .oneshot( + Request::post("/mcp/tools/read_file") + .header("content-type", "application/json") + .header("authorization", "Auths-Presentation eyJub3QiOiJyZWFsIn0") + .body(Body::from(r#"{"path":"/tmp/test.txt"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + json["error"] + .as_str() + .unwrap() + .contains("not enabled on this server") + ); +} + +#[tokio::test] +async fn challenge_route_absent_without_keri_authorizer() { + let (base_url, _handle) = start_mock_jwks_server().await; + let app = test_router(&base_url); + + let resp = app + .oneshot( + Request::get("/v1/auth/challenge") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/auths-mobile-ffi/Cargo.lock b/crates/auths-mobile-ffi/Cargo.lock index 4dcc1a5d..8e4b3298 100644 --- a/crates/auths-mobile-ffi/Cargo.lock +++ b/crates/auths-mobile-ffi/Cargo.lock @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "auths-crypto" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "base64", @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "auths-keri" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "auths-crypto", @@ -163,7 +163,7 @@ dependencies = [ [[package]] name = "auths-mobile-ffi" -version = "0.1.2" +version = "0.1.3" dependencies = [ "auths-keri", "auths-pairing-protocol", @@ -185,7 +185,7 @@ dependencies = [ [[package]] name = "auths-pairing-protocol" -version = "0.1.2" +version = "0.1.3" dependencies = [ "auths-crypto", "auths-keri", @@ -2414,11 +2414,3 @@ name = "zmij" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" - -[[patch.unused]] -name = "radicle-core" -version = "0.1.0" - -[[patch.unused]] -name = "radicle-crypto" -version = "0.14.0" diff --git a/crates/auths-mobile-ffi/src/delegated_inception_context.rs b/crates/auths-mobile-ffi/src/delegated_inception_context.rs new file mode 100644 index 00000000..571e5466 --- /dev/null +++ b/crates/auths-mobile-ffi/src/delegated_inception_context.rs @@ -0,0 +1,413 @@ +//! Signature-injection FFI for KERI delegated inception (`dip`). +//! +//! Mirrors the build/assemble pattern of `identity_context`, but incepts the +//! device as a **delegated identifier** of an existing root identity: the +//! event is a `dip` naming the root as delegator (`di`), self-signed by the +//! device's own key. The initiator (root) anchors it during pairing, which is +//! what makes the phone a true delegated device (`auths device list` shows +//! it) rather than a recorded-but-unanchored gadget. +//! +//! As everywhere in this crate, private key material never crosses the FFI: +//! the caller supplies the device's current + next public keys (Secure +//! Enclave on iOS, StrongBox / TEE on Android), signs the canonical payload +//! externally, and hands the signature back. +//! +//! Unlike the local-only `identity_context` builders, the dip must be +//! anchored and later rotated by the *platform's* validators — so this module +//! builds the real [`auths_keri::DipEvent`] through the platform's own +//! finalize/serialize/commitment functions instead of a local JSON mirror. +//! What the host anchors is byte-identical to what a CLI joiner produces. + +use std::sync::Arc; + +use p256::ecdsa::signature::Verifier; + +use auths_keri::{ + DipEvent, DipEventInit, Event, IndexedSignature, KeriPublicKey, KeriSequence, Prefix, Said, + Threshold, VersionString, compute_next_commitment, encode_signed_dip, finalize_dip_event, + serialize_attachment, serialize_for_signing, +}; + +use crate::MobileError; +use crate::pairing_context::normalize_p256_signature_to_raw_public; + +/// Parse a `did:keri:` string (or a bare prefix) into a validated [`Prefix`]. +fn parse_delegator_prefix(did: &str) -> Result { + let bare = did.strip_prefix("did:keri:").unwrap_or(did); + Prefix::new(bare.to_string()) + .map_err(|e| MobileError::InvalidKeyData(format!("delegator did:keri prefix invalid: {e}"))) +} + +/// Build a transferable P-256 [`KeriPublicKey`] from caller-supplied bytes. +/// +/// Accepts the same three forms as every other builder in this crate +/// (33 B compressed SEC1, 65 B uncompressed SEC1, SPKI DER). Transferable +/// (`1AAJ`) because a delegated device's key must be rotatable later. +fn parse_p256_verkey(bytes: &[u8]) -> Result { + let compressed = crate::pairing_context::normalize_p256_pubkey_to_compressed_public(bytes)?; + Ok(KeriPublicKey::P256 { + key: compressed, + transferable: true, + }) +} + +// --------------------------------------------------------------------------- +// Opaque UniFFI Objects round-tripped through the caller +// --------------------------------------------------------------------------- + +/// Per-dip state assembled by [`build_p256_delegated_inception_payload`] and +/// consumed by [`assemble_p256_delegated_inception`]. +/// +/// Opaque (`Arc`) so the canonical signing bytes and the unsigned event +/// cannot be tampered with between the two FFI calls. +#[derive(Debug, uniffi::Object)] +pub struct P256DelegatedInceptionContext { + /// Canonical bytes the Secure Enclave must sign (the finalized dip + /// serialized exactly as the wire will carry it). + signing_payload: Vec, + + /// The finalized (SAID-computed, unsigned) dip event. + unsigned_dip: DipEvent, + + /// Device's current P-256 signing pubkey, 33-byte compressed SEC1 — + /// the key committed in the dip's `k[0]` and the only key whose + /// signature the assembler accepts. + device_signing_pubkey_compressed: [u8; 33], +} + +#[uniffi::export] +impl P256DelegatedInceptionContext { + /// The exact bytes the Secure Enclave must sign. + pub fn signing_payload(&self) -> Vec { + self.signing_payload.clone() + } + + /// The device's delegated KEL prefix (self-addressing — the dip's SAID). + pub fn prefix(&self) -> String { + self.unsigned_dip.i.as_str().to_string() + } + + /// The device's full delegated DID: `did:keri:{prefix}`. + pub fn did(&self) -> String { + format!("did:keri:{}", self.unsigned_dip.i) + } + + /// The delegating root's prefix (the dip's `di` field). + pub fn delegator_prefix(&self) -> String { + self.unsigned_dip.di.as_str().to_string() + } +} + +/// A device-signed, locally-verified delegated inception, ready for the +/// pairing wire. +/// +/// Produced by [`assemble_p256_delegated_inception`]; consumed by +/// [`crate::assemble_pairing_response_body`], which embeds the wire envelope +/// in `responder_inception_event` after cross-checking that the dip's key is +/// the same key that signed the pairing binding (the custody invariant the +/// SAS ceremony proves). +#[derive(Debug, uniffi::Object)] +pub struct SignedDelegatedInception { + /// The single-string wire form (`auths_keri::encode_signed_dip`). + wire_envelope: String, + + /// The device's delegated KEL prefix. + device_prefix: String, + + /// The delegating root's prefix. + delegator_prefix: String, + + /// The dip's committed signing pubkey (compressed SEC1) — exposed to the + /// pairing assembler for the same-key cross-check. + pub(crate) device_signing_pubkey_compressed: [u8; 33], +} + +#[uniffi::export] +impl SignedDelegatedInception { + /// The wire envelope for `responder_inception_event` (base64url JSON of + /// the signed dip + its CESR attachment). + pub fn wire_envelope(&self) -> String { + self.wire_envelope.clone() + } + + /// The device's delegated KEL prefix. + pub fn prefix(&self) -> String { + self.device_prefix.clone() + } + + /// The device's full delegated DID: `did:keri:{prefix}`. + pub fn did(&self) -> String { + format!("did:keri:{}", self.device_prefix) + } + + /// The delegating root's prefix (the dip's `di` field). + pub fn delegator_prefix(&self) -> String { + self.delegator_prefix.clone() + } +} + +// --------------------------------------------------------------------------- +// Public FFI surface +// --------------------------------------------------------------------------- + +/// Build the signing payload for a delegated inception (`dip`). +/// +/// The dip names `delegator_did`'s prefix as delegator (`di`), commits the +/// device's current key in `k[0]` and the next-rotation key in `n[0]`, and is +/// finalized through the platform's own SAID computation — the device's +/// delegated prefix is the dip's SAID. +/// +/// Args: +/// * `delegator_did`: The delegating root identity — `did:keri:E…` (as carried +/// in a pairing URI's `d` field) or the bare prefix. +/// * `current_pubkey_der`: Device's current signing pubkey. 33 B compressed +/// SEC1, 65 B uncompressed SEC1, or SPKI DER. +/// * `next_pubkey_der`: Next-rotation commitment pubkey, same formats. +/// +/// Usage: +/// ```ignore +/// // iOS Swift +/// let ctx = try buildP256DelegatedInceptionPayload( +/// delegatorDid: pairingCtx.controllerDid(), +/// currentPubkeyDer: deviceKey.publicKeyDER, +/// nextPubkeyDer: deviceKey.nextPublicKeyDER +/// ) +/// let sig = try deviceKey.sign(ctx.signingPayload()) +/// let dip = try assembleP256DelegatedInception(context: ctx, signature: sig) +/// ``` +#[uniffi::export] +pub fn build_p256_delegated_inception_payload( + delegator_did: String, + current_pubkey_der: Vec, + next_pubkey_der: Vec, +) -> Result, MobileError> { + let delegator = parse_delegator_prefix(&delegator_did)?; + let current = parse_p256_verkey(¤t_pubkey_der)?; + let next = parse_p256_verkey(&next_pubkey_der)?; + + let current_compressed: [u8; 33] = current + .as_bytes() + .try_into() + .map_err(|_| MobileError::InvalidKeyData("P-256 verkey must be 33 bytes".to_string()))?; + let current_qb64 = current + .to_qb64() + .map_err(|e| MobileError::InvalidKeyData(format!("P-256 verkey CESR encode: {e}")))?; + + let dip = finalize_dip_event(DipEvent::new(DipEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![auths_keri::CesrKey::new_unchecked(current_qb64)], + nt: Threshold::Simple(1), + n: vec![compute_next_commitment(&next)], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + di: delegator, + })) + .map_err(|e| MobileError::Serialization(format!("dip finalize: {e}")))?; + + let signing_payload = serialize_for_signing(&Event::Dip(dip.clone())) + .map_err(|e| MobileError::Serialization(format!("dip serialize: {e}")))?; + + Ok(Arc::new(P256DelegatedInceptionContext { + signing_payload, + unsigned_dip: dip, + device_signing_pubkey_compressed: current_compressed, + })) +} + +/// Assemble the device-signed dip into its pairing-wire form. +/// +/// The signature is verified locally against the dip's committed key before +/// the envelope is emitted — a Secure-Enclave misconfiguration surfaces at +/// the FFI boundary, not at the anchoring host. +/// +/// Args: +/// * `context`: Opaque handle from [`build_p256_delegated_inception_payload`]. +/// * `signature`: P-256 ECDSA signature over `signing_payload()`. Accepts +/// X9.62 DER (what iOS SE emits) or raw r‖s (64 bytes). +#[uniffi::export] +pub fn assemble_p256_delegated_inception( + context: Arc, + signature: Vec, +) -> Result, MobileError> { + let sig_raw = normalize_p256_signature_to_raw_public(&signature)?; + + let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&context.device_signing_pubkey_compressed) + .map_err(|e| MobileError::InvalidKeyData(format!("dip pubkey unusable: {e}")))?; + let sig = p256::ecdsa::Signature::from_slice(&sig_raw) + .map_err(|e| MobileError::InvalidKeyData(format!("P-256 signature parse failed: {e}")))?; + vk.verify(&context.signing_payload, &sig).map_err(|e| { + MobileError::PairingFailed(format!( + "signature does not verify against the dip's committed key — likely SE misconfiguration: {e}" + )) + })?; + + let attachment = serialize_attachment(&[IndexedSignature { + index: 0, + prior_index: None, + sig: sig_raw.to_vec(), + }]) + .map_err(|e| MobileError::Serialization(format!("dip attachment: {e}")))?; + + let wire_envelope = encode_signed_dip(&context.unsigned_dip, &attachment) + .map_err(|e| MobileError::Serialization(format!("dip envelope: {e}")))?; + + Ok(Arc::new(SignedDelegatedInception { + wire_envelope, + device_prefix: context.unsigned_dip.i.as_str().to_string(), + delegator_prefix: context.unsigned_dip.di.as_str().to_string(), + device_signing_pubkey_compressed: context.device_signing_pubkey_compressed, + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{SigningKey, signature::Signer}; + use p256::elliptic_curve::rand_core::OsRng; + + const DELEGATOR: &str = "did:keri:EHOMEBASEROOTPREFIXxxxxxxxxxxxxxxxxxxxxx90AB"; + + fn fresh_p256_key() -> (SigningKey, Vec) { + let sk = SigningKey::random(&mut OsRng); + let compressed = sk + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec(); + (sk, compressed) + } + + #[test] + fn happy_path_round_trip() { + let (current_sk, current_pub) = fresh_p256_key(); + let (_next_sk, next_pub) = fresh_p256_key(); + + let ctx = build_p256_delegated_inception_payload(DELEGATOR.into(), current_pub, next_pub) + .unwrap(); + assert!( + ctx.prefix().starts_with('E'), + "delegated AID is self-addressing" + ); + assert_eq!(ctx.did(), format!("did:keri:{}", ctx.prefix())); + assert_eq!(format!("did:keri:{}", ctx.delegator_prefix()), DELEGATOR); + + let sig: p256::ecdsa::Signature = current_sk.sign(&ctx.signing_payload()); + let signed = + assemble_p256_delegated_inception(ctx.clone(), sig.to_der().as_bytes().to_vec()) + .unwrap(); + assert_eq!(signed.prefix(), ctx.prefix()); + + // The envelope decodes through the SAME platform decoder the anchoring + // host uses, and the signed bytes verify against the committed key. + let (dip, attachment) = auths_keri::decode_signed_dip(&signed.wire_envelope()).unwrap(); + assert_eq!(dip.i.as_str(), signed.prefix()); + assert_eq!(format!("did:keri:{}", dip.di), DELEGATOR); + let sigs = auths_keri::parse_attachment(&attachment).unwrap(); + assert_eq!(sigs.len(), 1); + let canonical = serialize_for_signing(&Event::Dip(dip.clone())).unwrap(); + let key = dip.k[0].parse().unwrap(); + key.verify_signature(&canonical, &sigs[0].sig) + .expect("device signature must verify through the platform validator"); + } + + #[test] + fn dip_commits_transferable_p256_key_and_next_commitment() { + let (_sk, current_pub) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + + let ctx = build_p256_delegated_inception_payload( + DELEGATOR.into(), + current_pub.clone(), + next_pub.clone(), + ) + .unwrap(); + + let event: serde_json::Value = serde_json::from_slice(&ctx.signing_payload()).unwrap(); + assert_eq!(event["t"], "dip"); + assert_eq!(event["s"], "0"); + // Transferable P-256 verkey code — the key must be rotatable later. + assert!(event["k"][0].as_str().unwrap().starts_with("1AAJ")); + assert!(event["n"][0].as_str().unwrap().starts_with('E')); + assert_eq!(event["di"], DELEGATOR.trim_start_matches("did:keri:")); + + // Commitment matches the platform's convention (digest of the qb64 + // text), so a future rotation reveal will verify. + let next_key = parse_p256_verkey(&next_pub).unwrap(); + let expected = compute_next_commitment(&next_key); + assert_eq!(event["n"][0], expected.as_str()); + } + + #[test] + fn bare_prefix_delegator_accepted() { + let (_sk, current_pub) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + let bare = DELEGATOR.trim_start_matches("did:keri:"); + let ctx = + build_p256_delegated_inception_payload(bare.into(), current_pub, next_pub).unwrap(); + assert_eq!(ctx.delegator_prefix(), bare); + } + + #[test] + fn invalid_delegator_rejected() { + let (_sk, current_pub) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + let err = build_p256_delegated_inception_payload( + "did:keri:!!not-a-prefix".into(), + current_pub, + next_pub, + ) + .unwrap_err(); + assert!(matches!(err, MobileError::InvalidKeyData(_))); + } + + #[test] + fn wrong_key_signature_rejected() { + let (_current_sk, current_pub) = fresh_p256_key(); + let (attacker_sk, _) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + + let ctx = build_p256_delegated_inception_payload(DELEGATOR.into(), current_pub, next_pub) + .unwrap(); + let bad: p256::ecdsa::Signature = attacker_sk.sign(&ctx.signing_payload()); + let err = + assemble_p256_delegated_inception(ctx, bad.to_der().as_bytes().to_vec()).unwrap_err(); + assert!(matches!(err, MobileError::PairingFailed(_))); + } + + #[test] + fn raw_signature_accepted() { + let (current_sk, current_pub) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + let ctx = build_p256_delegated_inception_payload(DELEGATOR.into(), current_pub, next_pub) + .unwrap(); + let sig: p256::ecdsa::Signature = current_sk.sign(&ctx.signing_payload()); + let raw: [u8; 64] = sig.to_bytes().into(); + let _ = assemble_p256_delegated_inception(ctx, raw.to_vec()).unwrap(); + } + + #[test] + fn signing_payload_length_matches_version_string() { + // A spec verifier frames the body by the length in `v` — the signed + // bytes must be exactly that long. + let (_sk, current_pub) = fresh_p256_key(); + let (_nsk, next_pub) = fresh_p256_key(); + let ctx = build_p256_delegated_inception_payload(DELEGATOR.into(), current_pub, next_pub) + .unwrap(); + let payload = ctx.signing_payload(); + let event: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + let v = event["v"].as_str().unwrap(); + // KERI10JSON{6 hex digits}_ + let size = usize::from_str_radix(&v["KERI10JSON".len()..v.len() - 1], 16).unwrap(); + assert_eq!(size, payload.len()); + } +} diff --git a/crates/auths-mobile-ffi/src/lib.rs b/crates/auths-mobile-ffi/src/lib.rs index 7154ec3d..c70be993 100644 --- a/crates/auths-mobile-ffi/src/lib.rs +++ b/crates/auths-mobile-ffi/src/lib.rs @@ -20,6 +20,7 @@ uniffi::setup_scaffolding!(); // the Secure Enclave / StrongBox / TEE; the FFI only ever sees pubkeys // + signatures. pub mod auth_challenge_context; +pub mod delegated_inception_context; pub mod device_kel_rotation; pub mod identity_context; pub mod pairing_context; @@ -31,13 +32,17 @@ pub use device_kel_rotation::{ build_p256_device_kel_rot_payload, }; pub use shared_kel_context::{ - P256SharedKelRotationContext, SharedKelChangeRequest, assemble_shared_kel_rot, - build_shared_kel_rot_payload, + P256SharedKelRotationContext, SharedKelChangeRequest, SharedKelRotIndexedResult, + assemble_shared_kel_rot_indexed, build_shared_kel_rot_payload, }; pub use auth_challenge_context::{ AuthChallengeContext, assemble_auth_challenge_response, build_auth_challenge_signing_payload, }; +pub use delegated_inception_context::{ + P256DelegatedInceptionContext, SignedDelegatedInception, + assemble_p256_delegated_inception, build_p256_delegated_inception_payload, +}; pub use identity_context::{ P256IdentityInceptionContext, assemble_p256_identity, build_p256_identity_inception_payload, }; diff --git a/crates/auths-mobile-ffi/src/pairing_context.rs b/crates/auths-mobile-ffi/src/pairing_context.rs index 48665b73..7f19dfff 100644 --- a/crates/auths-mobile-ffi/src/pairing_context.rs +++ b/crates/auths-mobile-ffi/src/pairing_context.rs @@ -287,14 +287,22 @@ pub fn build_pairing_binding_message( /// * `signature`: The P-256 ECDSA signature produced by the device (SE). Accepts /// either X9.62 DER (what `SecKeyCreateSignature` emits) or raw r‖s (64 B). /// * `device_name`: Friendly name to embed in the response body. +/// * `delegated_inception`: The device's signed `dip` (from +/// [`crate::assemble_p256_delegated_inception`]). Embedded as +/// `responder_inception_event` so the initiator can anchor the phone as a +/// true delegated device. Required: a response without a dip cannot be +/// anchored, only recorded — that path does not exist on mobile. /// /// The signature is verified locally against the stored binding message /// before the body is emitted — catches mobile-side SE misconfiguration -/// at the FFI boundary rather than at the daemon. +/// at the FFI boundary rather than at the daemon. The dip is cross-checked +/// against this session: it must be signed by the SAME key that signed the +/// binding message (so the SAS ceremony proves custody of exactly the key in +/// the dip) and must delegate to the controller named in the pairing URI. /// /// Usage: /// ```ignore -/// let body = assemble_pairing_response_body(ctx, sig_der, "iPhone".into())?; +/// let body = assemble_pairing_response_body(ctx, sig_der, "iPhone".into(), dip)?; /// http_post(ctx.endpoint(), body).await?; /// ``` #[uniffi::export] @@ -302,9 +310,32 @@ pub fn assemble_pairing_response_body( context: Arc, signature: Vec, device_name: String, + delegated_inception: Arc, ) -> Result, MobileError> { let sig_raw: [u8; 64] = normalize_p256_signature_to_raw(&signature)?; + // The dip must commit the same key that signs this pairing response — + // that identity of keys is what lets the SAS ceremony vouch for the dip. + if delegated_inception.device_signing_pubkey_compressed.as_slice() + != context.device_signing_pubkey_compressed.as_slice() + { + return Err(MobileError::PairingFailed( + "delegated inception commits a different key than this pairing session".to_string(), + )); + } + // And it must delegate to the controller this session pairs with. + let expected_delegator = context + .controller_did + .strip_prefix("did:keri:") + .unwrap_or(&context.controller_did); + if delegated_inception.delegator_prefix() != expected_delegator { + return Err(MobileError::PairingFailed(format!( + "delegated inception names delegator {} but this session pairs with {}", + delegated_inception.delegator_prefix(), + context.controller_did + ))); + } + // Local verification — cheap, catches SE misconfiguration before we // ship a bad body to the daemon. let pubkey_bytes: &[u8] = &context.device_signing_pubkey_compressed; @@ -332,7 +363,7 @@ pub fn assemble_pairing_response_body( device_did: derive_device_did(&context.device_signing_pubkey_compressed)?, signature: signature_b64, device_name, - responder_inception_event: String::new(), + responder_inception_event: delegated_inception.wire_envelope(), }; serde_json::to_vec(&payload).map_err(|e| MobileError::Serialization(e.to_string())) @@ -459,6 +490,34 @@ mod tests { use super::*; use p256::ecdsa::{SigningKey, signature::Signer}; + /// Build a signed dip for `signing_key` delegating to `delegator_did` — + /// the companion object `assemble_pairing_response_body` now requires. + fn make_signed_dip( + signing_key: &SigningKey, + delegator_did: &str, + ) -> Arc { + let current = signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec(); + let next_sk = SigningKey::random(&mut OsRng); + let next = next_sk + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec(); + let ctx = crate::build_p256_delegated_inception_payload( + delegator_did.to_string(), + current, + next, + ) + .expect("dip build must succeed"); + let sig: p256::ecdsa::Signature = signing_key.sign(&ctx.signing_payload()); + crate::assemble_p256_delegated_inception(ctx, sig.to_der().as_bytes().to_vec()) + .expect("dip assemble must succeed") + } + /// Minimal valid pairing URI helper. fn make_uri(expires_in_secs: i64) -> String { let now = std::time::SystemTime::now() @@ -544,12 +603,19 @@ mod tests { let sig: p256::ecdsa::Signature = signing_key.sign(&ctx.binding_message); let raw: [u8; 64] = sig.to_bytes().into(); + let dip = make_signed_dip(&signing_key, "did:keri:EABC"); let body = - assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone-15".into()).unwrap(); + assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone-15".into(), dip.clone()) + .unwrap(); let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(parsed["curve"], "p256"); assert_eq!(parsed["device_name"], "iPhone-15"); assert!(parsed["device_did"].as_str().unwrap().starts_with("did:key:zDna")); + // The wire body carries the signed dip — the host can anchor this phone. + assert_eq!( + parsed["responder_inception_event"].as_str().unwrap(), + dip.wire_envelope() + ); } #[test] @@ -564,8 +630,9 @@ mod tests { let der_bytes = sig.to_der().as_bytes().to_vec(); assert_ne!(der_bytes.len(), 64, "DER encoding must not be raw 64B"); + let dip = make_signed_dip(&signing_key, "did:keri:EABC"); let body = - assemble_pairing_response_body(ctx, der_bytes, "iPhone-15".into()).unwrap(); + assemble_pairing_response_body(ctx, der_bytes, "iPhone-15".into(), dip).unwrap(); let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(parsed["curve"], "p256"); } @@ -583,7 +650,8 @@ mod tests { let wrong_sig: p256::ecdsa::Signature = signing_key.sign(b"totally different bytes"); let raw: [u8; 64] = wrong_sig.to_bytes().into(); - let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into()) + let dip = make_signed_dip(&signing_key, "did:keri:EABC"); + let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into(), dip) .expect_err("wrong-message signature must be rejected"); assert!(matches!(err, MobileError::PairingFailed(_))); } @@ -600,11 +668,49 @@ mod tests { let sig: p256::ecdsa::Signature = key_b.sign(&ctx.binding_message); let raw: [u8; 64] = sig.to_bytes().into(); - let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into()) + let dip = make_signed_dip(&key_a, "did:keri:EABC"); + let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into(), dip) .expect_err("signature under wrong key must be rejected"); assert!(matches!(err, MobileError::PairingFailed(_))); } + #[test] + fn assembler_rejects_dip_under_different_key() { + let signing_key = SigningKey::random(&mut OsRng); + let other_key = SigningKey::random(&mut OsRng); + let compressed = signing_key.verifying_key().to_encoded_point(true); + let ctx = build_pairing_binding_message(make_uri(300), compressed.as_bytes().to_vec()) + .unwrap(); + + let sig: p256::ecdsa::Signature = signing_key.sign(&ctx.binding_message); + let raw: [u8; 64] = sig.to_bytes().into(); + + // The dip commits a DIFFERENT key than the one signing this session — + // the SAS ceremony could not vouch for it. + let dip = make_signed_dip(&other_key, "did:keri:EABC"); + let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into(), dip) + .expect_err("dip under a different key must be rejected"); + assert!(matches!(err, MobileError::PairingFailed(_))); + } + + #[test] + fn assembler_rejects_dip_for_different_delegator() { + let signing_key = SigningKey::random(&mut OsRng); + let compressed = signing_key.verifying_key().to_encoded_point(true); + let ctx = build_pairing_binding_message(make_uri(300), compressed.as_bytes().to_vec()) + .unwrap(); + + let sig: p256::ecdsa::Signature = signing_key.sign(&ctx.binding_message); + let raw: [u8; 64] = sig.to_bytes().into(); + + // Right key, wrong root: the dip delegates to someone other than the + // controller this session pairs with. + let dip = make_signed_dip(&signing_key, "did:keri:ESOMEONEELSE"); + let err = assemble_pairing_response_body(ctx, raw.to_vec(), "iPhone".into(), dip) + .expect_err("dip naming a different delegator must be rejected"); + assert!(matches!(err, MobileError::PairingFailed(_))); + } + #[test] fn builder_context_exposes_uri_fields() { let signing_key = SigningKey::random(&mut OsRng); diff --git a/crates/auths-mobile-ffi/src/shared_kel_context.rs b/crates/auths-mobile-ffi/src/shared_kel_context.rs index 95578fc6..0f2ccbc1 100644 --- a/crates/auths-mobile-ffi/src/shared_kel_context.rs +++ b/crates/auths-mobile-ffi/src/shared_kel_context.rs @@ -1,245 +1,681 @@ -//! FFI entry points for signing shared-KEL events externally via the -//! Secure Enclave. +//! Signature-injection FFI for co-authoring shared-KEL rotations from a +//! device whose key lives in the Secure Enclave. //! //! Two-step authorship pattern mirrors `identity_context.rs`: //! -//! 1. `build_shared_kel_rot_payload(...)` constructs an unsigned `rot` -//! payload + returns the exact bytes the SE must sign. +//! 1. `build_shared_kel_rot_payload(...)` parses the prior key state, +//! verifies the caller's revealed pre-committed key against its prior +//! `n[]` slot, applies the requested controller change, and finalizes a +//! real [`auths_keri::RotEvent`] through the platform's own SAID +//! machinery — returning the exact canonical bytes the SE must sign. //! 2. The iOS/macOS side calls `SecKeyCreateSignature` with the caller's //! biometric-gated key. -//! 3. `assemble_shared_kel_rot(ctx, signature_der)` verifies the signature, -//! normalizes DER → raw r‖s via the canonical helper, and returns the -//! final signed event JSON ready to POST to the daemon / replicate. +//! 3. `assemble_shared_kel_rot_indexed(ctx, signature)` verifies the +//! signature, wraps it in a CESR [`IndexedSignature`] carrying both the +//! signer's new key index and the prior next-commitment index it reveals +//! (the dual-index siger the validator binds shrink rotations with), +//! re-validates the whole signed event through +//! [`auths_keri::validate_signed_event`], and emits the single-string +//! wire envelope ([`auths_keri::encode_signed_rot`]) ready to POST to +//! the daemon's shared-rot endpoint. //! -//! **Status**: scaffolding only. End-to-end shared-KEL rotation is -//! blocked on CESR indexed-signature support in `auths-keri::validate` -//! (the validator rejects asymmetric rotations today). Callers that -//! invoke `build_shared_kel_rot_payload` during Stage-1 development -//! receive `MobileError::PairingFailed(…)` with a clear message; the -//! entry point is in place so the Swift side can wire against it -//! before the crypto blocker clears. +//! As everywhere in this crate, private key material never crosses the FFI: +//! the caller supplies public keys, signs externally, and hands the +//! signature back. +//! +//! Threshold note: the assembler emits only events the platform validator +//! accepts with the single signature it holds. Under the current `kt=1` +//! shared-KEL model that is exactly one co-author; a future `kt≥2` state +//! makes the assemble step fail loudly rather than emit an under-signed +//! rotation for someone else to "fix up". use std::sync::Arc; +use p256::ecdsa::signature::Verifier; + +use auths_keri::{ + CesrKey, Event, IndexedSignature, KeriPublicKey, KeriSequence, KeyState, RotEvent, + RotEventInit, Said, SignedEvent, VersionString, compute_next_commitment, encode_signed_rot, + finalize_rot_event, serialize_attachment, serialize_for_signing, validate_signed_event, + verify_commitment, +}; + use crate::MobileError; +use crate::pairing_context::{ + normalize_p256_pubkey_to_compressed_public, normalize_p256_signature_to_raw_public, +}; -/// FFI-safe mirror of `auths_id::keri::shared_kel::SharedKelChange`. +/// Build a transferable P-256 [`KeriPublicKey`] from caller-supplied bytes. /// -/// UniFFI can't cross the crate boundary to `auths-id`, so we mirror -/// the discriminator + DID strings here. The assemble step resolves -/// these into the real typed change by parsing the DIDs. +/// Accepts the same three forms as every other builder in this crate +/// (33 B compressed SEC1, 65 B uncompressed SEC1, SPKI DER). Transferable +/// (`1AAJ`) because a shared-KEL controller must be rotatable — its next +/// key is committed in `n[]`, which is meaningless for a non-transferable +/// key. +fn parse_p256_verkey(bytes: &[u8]) -> Result { + let compressed = normalize_p256_pubkey_to_compressed_public(bytes)?; + Ok(KeriPublicKey::P256 { + key: compressed, + transferable: true, + }) +} + +/// CESR-encode a verkey for a `k[]` slot. +fn verkey_to_cesr(key: &KeriPublicKey) -> Result { + key.to_qb64() + .map(CesrKey::new_unchecked) + .map_err(|e| MobileError::InvalidKeyData(format!("verkey CESR encode: {e}"))) +} + +/// Find the `k[]` slot holding `compressed`, comparing decoded key bytes +/// (curve/transferability-agnostic — a controller is its key, not its +/// CESR spelling). +fn slot_of_key(keys: &[CesrKey], compressed: &[u8; 33]) -> Option { + keys.iter().position(|k| { + k.parse() + .is_ok_and(|pk| pk.as_bytes() == compressed.as_slice()) + }) +} + +/// Controller-set change co-authored by this device. +/// +/// Controllers are identified by their *current* verkey bytes (the shared +/// KEL's `k[]` holds keys, not names); the builder resolves them to slots +/// internally — slot indices shift across rotations and would be a footgun +/// in a public API. #[derive(Debug, Clone, uniffi::Enum)] pub enum SharedKelChangeRequest { - /// Add a new controller to the shared KEL. + /// Add a new controller: its current verkey plus the next-rotation key + /// whose digest becomes the new controller's `n[]` commitment. AddController { - /// `did:keri:E…` of the new controller. - new_did: String, - /// Compressed SEC1 P-256 verkey bytes (33 B). - new_verkey_compressed: Vec, + /// New controller's current P-256 pubkey (compressed/uncompressed + /// SEC1 or SPKI DER). + new_verkey_der: Vec, + /// New controller's next-rotation pubkey, same formats. + new_next_verkey_der: Vec, }, - /// Remove a controller by DID. + /// Remove the controller whose current verkey matches. The authoring + /// device cannot remove itself (the rotation's signer must survive to + /// authorize it). RemoveController { - /// `did:keri:E…` of the controller to drop. - target_did: String, + /// Target controller's current P-256 pubkey. + target_verkey_der: Vec, }, - /// Atomically swap an old controller for a new one. + /// Stolen-laptop recovery: atomically replace one controller with + /// another in a single rotation — a verifier never observes an + /// intermediate state with fewer controllers. SwapController { - /// `did:keri:E…` of the controller to drop. - old_did: String, - /// `did:keri:E…` of the replacement controller. - new_did: String, - /// Compressed SEC1 P-256 verkey bytes for the new controller. - new_verkey_compressed: Vec, + /// Outgoing controller's current P-256 pubkey. + old_verkey_der: Vec, + /// Replacement controller's current P-256 pubkey. + new_verkey_der: Vec, + /// Replacement controller's next-rotation pubkey. + new_next_verkey_der: Vec, }, } -/// Opaque handle from `build_shared_kel_rot_payload`. -#[derive(uniffi::Object)] +/// Opaque handle from [`build_shared_kel_rot_payload`], consumed by +/// [`assemble_shared_kel_rot_indexed`]. The canonical signing bytes, the +/// finalized unsigned event, and the prior state used for final validation +/// live here so nothing between the two FFI calls can mutate them. +#[derive(Debug, uniffi::Object)] pub struct P256SharedKelRotationContext { - /// Exact bytes the SE must sign. + /// Exact canonical bytes the SE must sign (the finalized rot serialized + /// exactly as the wire carries it). signing_payload: Vec, - /// Serialized unsigned rot event JSON (finalized with the sig in - /// `assemble_shared_kel_rot`). - unsigned_event: Vec, - /// Controller verkey the SE signs with, for local signature - /// verification at the FFI boundary. - signer_verkey_compressed: Vec, + /// The finalized (SAID-computed, unsigned) rot event. + unsigned_rot: RotEvent, + /// Prior key state — re-used by the assemble step to run the full + /// platform validation before the envelope leaves the FFI. + prior_state: KeyState, + /// The revealed pre-committed key the SE signs with, 33-byte compressed + /// SEC1 — the only key whose signature the assembler accepts. + revealed_pubkey_compressed: [u8; 33], + /// The signer's slot in the NEW `k[]`. + signer_index: u32, + /// The prior `n[]` slot the signer's key reveals (the dual-index ondex). + signer_prior_index: u32, + /// Sequence of the rotation event (prior + 1), pre-narrowed to u64. + new_sequence: u64, } #[uniffi::export] impl P256SharedKelRotationContext { - /// The bytes the Secure Enclave must sign. Stable handle for the - /// caller; do not inspect the interior. + /// The exact bytes the Secure Enclave must sign. pub fn signing_payload(&self) -> Vec { self.signing_payload.clone() } + + /// The shared KEL's identity DID (stable across rotations). + pub fn did(&self) -> String { + format!("did:keri:{}", self.unsigned_rot.i) + } + + /// Sequence number of the rot being built (prior + 1). + pub fn new_sequence(&self) -> u64 { + self.new_sequence + } + + /// The signer's slot in the rotation's new key list. + pub fn signer_index(&self) -> u32 { + self.signer_index + } + + /// The prior next-commitment slot the signer's key reveals. + pub fn signer_prior_index(&self) -> u32 { + self.signer_prior_index + } +} + +/// A finalized, locally-validated, indexed-signature shared-KEL rotation. +#[derive(Debug, Clone, uniffi::Record)] +pub struct SharedKelRotIndexedResult { + /// Single-string wire form (`auths_keri::encode_signed_rot`) — the + /// blob the app POSTs to the daemon's shared-rot endpoint. + pub wire_envelope: String, + /// The shared KEL's identity DID. + pub did: String, + /// Sequence number of this rotation event. + pub sequence: u64, + /// SAID (`d`) of the rotation event. + pub event_said: String, + /// The signer's slot in the new key list. + pub signer_index: u32, + /// The prior `n[]` slot the signer revealed (dual-index ondex). + pub signer_prior_index: u32, } -/// Build a shared-KEL rotation payload. +/// Build the canonical signing payload for a co-authored shared-KEL `rot`. +/// +/// The caller proves controllership by *revealing* its pre-committed next +/// key: the builder locates the prior `n[]` slot whose digest the revealed +/// key matches ([`MobileError::CommitmentMismatch`] if none), rotates that +/// slot to the revealed key with a fresh next-commitment, applies the +/// requested controller change to the remaining slots, and finalizes the +/// event through the platform's own SAID computation. /// /// Args: -/// * `prior_state_json`: Serialized shared-KEL state at the prior event -/// (controllers + current sequence number). -/// * `change`: The change being applied. -/// * `next_commitment`: Blake3-256 digest of the new pre-rotation key set. -/// * `signer_verkey_compressed`: The caller's current P-256 verkey, -/// 33-byte compressed SEC1. Embedded in the context so -/// `assemble_shared_kel_rot` can verify the SE signature locally -/// before emitting the body. +/// * `prior_key_state_json`: The shared KEL's current key state — the JSON +/// serialization of the platform's [`auths_keri::KeyState`] (what the +/// host computes by replaying the KEL). Parsed, not re-modeled. +/// * `change`: The controller-set change being co-authored. +/// * `revealed_next_pubkey_der`: The caller's pre-committed next pubkey, +/// now revealed — the key that signs this rotation per KERI. +/// * `new_next_pubkey_der`: Fresh pubkey whose digest becomes the caller's +/// new `n[]` commitment. /// /// Usage: /// ```ignore -/// let ctx = build_shared_kel_rot_payload(prior_state, change, next_commit, vkey)?; -/// let sig = se.sign(ctx.signing_payload().as_slice())?; -/// let body = assemble_shared_kel_rot(ctx, sig)?; +/// // iOS Swift +/// let ctx = try buildSharedKelRotPayload( +/// priorKeyStateJson: stateJson, +/// change: .swapController(oldVerkeyDer: lostMacKey, newVerkeyDer: newMacKey, +/// newNextVerkeyDer: newMacNextKey), +/// revealedNextPubkeyDer: preCommitted.publicKeyDER, +/// newNextPubkeyDer: freshSEKey.publicKeyDER +/// ) +/// let sig = try preCommitted.sign(ctx.signingPayload(), prompt: "Rotate identity") +/// let rot = try assembleSharedKelRotIndexed(context: ctx, signature: sig) /// ``` #[uniffi::export] pub fn build_shared_kel_rot_payload( - prior_state_json: String, + prior_key_state_json: String, change: SharedKelChangeRequest, - next_commitment: Vec, - signer_verkey_compressed: Vec, + revealed_next_pubkey_der: Vec, + new_next_pubkey_der: Vec, ) -> Result, MobileError> { - // Input shape validation — the downstream event authorship is - // blocked on CESR indexed-signature support in the validator; - // emit a typed error so the Swift surface sees the actual - // limitation instead of silently building garbage. - if prior_state_json.is_empty() { + let state: KeyState = serde_json::from_str(&prior_key_state_json) + .map_err(|e| MobileError::Serialization(format!("prior key state parse: {e}")))?; + + if !state.can_rotate() { return Err(MobileError::PairingFailed( - "prior_state_json must describe the current shared-KEL controller set".into(), + "shared KEL cannot rotate (abandoned or non-transferable)".into(), )); } - if next_commitment.len() != 32 { - return Err(MobileError::InvalidKeyData(format!( - "next_commitment must be a 32-byte Blake3-256 digest, got {} bytes", - next_commitment.len() + if state.current_keys.len() != state.next_commitment.len() { + return Err(MobileError::Serialization(format!( + "prior key state is not slot-parallel: {} current keys vs {} next commitments", + state.current_keys.len(), + state.next_commitment.len() ))); } - if signer_verkey_compressed.len() != 33 - || !(signer_verkey_compressed[0] == 0x02 || signer_verkey_compressed[0] == 0x03) - { - return Err(MobileError::InvalidKeyData( - "signer_verkey_compressed must be 33-byte compressed SEC1 P-256".into(), - )); - } + + let revealed = parse_p256_verkey(&revealed_next_pubkey_der)?; + let revealed_compressed = normalize_p256_pubkey_to_compressed_public(&revealed_next_pubkey_der)?; + let new_next = parse_p256_verkey(&new_next_pubkey_der)?; + + // The caller's controllership proof: its revealed key must be the + // pre-image of exactly one prior next-commitment slot. + let signer_slot = state + .next_commitment + .iter() + .position(|c| verify_commitment(&revealed, c)) + .ok_or_else(|| { + MobileError::CommitmentMismatch( + "revealed key matches no prior next-commitment slot — this device is not a \ + committed controller of this shared KEL" + .into(), + ) + })?; + + // Per-slot view of the new establishment state: the signer's slot + // rotates to its revealed key + fresh commitment; every other slot + // carries forward. + let mut slots: Vec<(CesrKey, Said)> = state + .current_keys + .iter() + .cloned() + .zip(state.next_commitment.iter().cloned()) + .collect(); + slots[signer_slot] = (verkey_to_cesr(&revealed)?, compute_next_commitment(&new_next)); + + let mut signer_index = signer_slot; match &change { - SharedKelChangeRequest::AddController { new_did, .. } - | SharedKelChangeRequest::RemoveController { target_did: new_did, .. } => { - if !new_did.starts_with("did:keri:") { - return Err(MobileError::PairingFailed(format!( - "shared-KEL controller DIDs must be did:keri: form: got {new_did}" - ))); + SharedKelChangeRequest::AddController { + new_verkey_der, + new_next_verkey_der, + } => { + let new_key = parse_p256_verkey(new_verkey_der)?; + let new_key_compressed = normalize_p256_pubkey_to_compressed_public(new_verkey_der)?; + if slot_of_key(&state.current_keys, &new_key_compressed).is_some() { + return Err(MobileError::PairingFailed( + "controller to add is already in the shared KEL".into(), + )); + } + let new_key_next = parse_p256_verkey(new_next_verkey_der)?; + slots.push((verkey_to_cesr(&new_key)?, compute_next_commitment(&new_key_next))); + } + SharedKelChangeRequest::RemoveController { target_verkey_der } => { + let target_compressed = normalize_p256_pubkey_to_compressed_public(target_verkey_der)?; + let target_slot = slot_of_key(&state.current_keys, &target_compressed) + .ok_or_else(|| { + MobileError::PairingFailed( + "controller to remove is not in the shared KEL".into(), + ) + })?; + if target_slot == signer_slot { + return Err(MobileError::PairingFailed( + "the authoring device cannot remove itself — the rotation's signer must \ + survive to authorize it" + .into(), + )); + } + slots.remove(target_slot); + if target_slot < signer_slot { + signer_index -= 1; } } SharedKelChangeRequest::SwapController { - old_did, new_did, .. + old_verkey_der, + new_verkey_der, + new_next_verkey_der, } => { - if !old_did.starts_with("did:keri:") || !new_did.starts_with("did:keri:") { + let old_compressed = normalize_p256_pubkey_to_compressed_public(old_verkey_der)?; + let old_slot = slot_of_key(&state.current_keys, &old_compressed).ok_or_else(|| { + MobileError::PairingFailed("controller to swap out is not in the shared KEL".into()) + })?; + if old_slot == signer_slot { return Err(MobileError::PairingFailed( - "shared-KEL controller DIDs must be did:keri: form on both old_did and new_did" + "the authoring device cannot swap itself out — rotate your own key with a \ + plain rotation instead" .into(), )); } + let new_key = parse_p256_verkey(new_verkey_der)?; + let new_key_next = parse_p256_verkey(new_next_verkey_der)?; + slots[old_slot] = (verkey_to_cesr(&new_key)?, compute_next_commitment(&new_key_next)); } } - // Construct the unsigned event + signing payload. The signing - // payload is the canonical serialization the controller's SE - // signs. Shape TODO: once CESR indexed-signature support is in - // the validator, this becomes a real `rot` event per the KERI - // spec. Until then, emit a deterministic placeholder that the - // assemble step can reject cleanly — this keeps the FFI surface - // stable for Swift to wire against without introducing - // unvalidated events into storage. - let mut payload = Vec::with_capacity( - prior_state_json.len() + 32 + signer_verkey_compressed.len() + 64, - ); - payload.extend_from_slice(b"shared-kel-rot-v1|"); - payload.extend_from_slice(prior_state_json.as_bytes()); - payload.extend_from_slice(b"|"); - payload.extend_from_slice(&next_commitment); - payload.extend_from_slice(b"|"); - payload.extend_from_slice(&signer_verkey_compressed); - // Change discriminator bytes so replay across change-types is - // cryptographically impossible even if the rest of the inputs - // collided. - match &change { - SharedKelChangeRequest::AddController { new_did, .. } => { - payload.extend_from_slice(b"|add|"); - payload.extend_from_slice(new_did.as_bytes()); - } - SharedKelChangeRequest::RemoveController { target_did } => { - payload.extend_from_slice(b"|remove|"); - payload.extend_from_slice(target_did.as_bytes()); - } - SharedKelChangeRequest::SwapController { old_did, new_did, .. } => { - payload.extend_from_slice(b"|swap|"); - payload.extend_from_slice(old_did.as_bytes()); - payload.extend_from_slice(b"|"); - payload.extend_from_slice(new_did.as_bytes()); - } - } + let (k, n): (Vec, Vec) = slots.into_iter().unzip(); + + let new_sequence_u128 = state.sequence.checked_add(1).ok_or_else(|| { + MobileError::Serialization("shared-KEL sequence overflow".to_string()) + })?; + let new_sequence = u64::try_from(new_sequence_u128) + .map_err(|_| MobileError::Serialization("shared-KEL sequence exceeds u64".to_string()))?; + + let rot = finalize_rot_event(RotEvent::new(RotEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: state.prefix.clone(), + s: KeriSequence::new(new_sequence_u128), + p: state.last_event_said.clone(), + kt: state.threshold.clone(), + k, + nt: state.next_threshold.clone(), + n, + bt: state.backer_threshold.clone(), + br: vec![], + ba: vec![], + c: vec![], + a: vec![], + })) + .map_err(|e| MobileError::Serialization(format!("rot finalize: {e}")))?; + + let signing_payload = serialize_for_signing(&Event::Rot(rot.clone())) + .map_err(|e| MobileError::Serialization(format!("rot serialize: {e}")))?; + + let signer_index = u32::try_from(signer_index) + .map_err(|_| MobileError::Serialization("controller slot exceeds u32".to_string()))?; + let signer_prior_index = u32::try_from(signer_slot) + .map_err(|_| MobileError::Serialization("controller slot exceeds u32".to_string()))?; Ok(Arc::new(P256SharedKelRotationContext { - signing_payload: payload.clone(), - unsigned_event: payload, - signer_verkey_compressed, + signing_payload, + unsigned_rot: rot, + prior_state: state, + revealed_pubkey_compressed: revealed_compressed, + signer_index, + signer_prior_index, + new_sequence, })) } -/// Assemble the signed shared-KEL rot event body. +/// Assemble the signed, indexed shared-KEL rotation into its wire form. /// -/// Verifies the SE signature locally against the signer's current -/// verkey before emitting the body — catches SE misconfiguration at -/// the FFI boundary rather than at the daemon. +/// Verifies the SE signature against the revealed key, binds it into a +/// dual-index CESR [`IndexedSignature`] (new-key index + revealed prior +/// `n[]` index), and — before anything leaves the FFI — replays the whole +/// signed event through the platform validator against the prior state. +/// An event this function returns is, by construction, one the daemon and +/// the host-side replay will accept. /// /// Args: -/// * `context`: The handle returned by `build_shared_kel_rot_payload`. -/// * `signature`: SE signature. Accepts X9.62 DER or raw r‖s (64 B); -/// normalized to raw via the canonical `crate::signature::ecdsa_p256_der_to_raw`. -/// -/// Usage: -/// ```ignore -/// let body = assemble_shared_kel_rot(ctx, sig)?; -/// daemon.post_shared_kel_event(body).await?; -/// ``` +/// * `context`: Handle from [`build_shared_kel_rot_payload`]. +/// * `signature`: SE signature over `signing_payload()`. Accepts X9.62 DER +/// (what iOS SE emits) or raw r‖s (64 bytes). #[uniffi::export] -pub fn assemble_shared_kel_rot( +pub fn assemble_shared_kel_rot_indexed( context: Arc, signature: Vec, -) -> Result, MobileError> { - use p256::ecdsa::signature::Verifier; - - let sig_raw: [u8; 64] = if signature.len() == 64 { - let mut arr = [0u8; 64]; - arr.copy_from_slice(&signature); - arr - } else { - crate::signature::ecdsa_p256_der_to_raw(&signature)? - }; +) -> Result { + let sig_raw = normalize_p256_signature_to_raw_public(&signature)?; - // Local verification against the signer's current verkey. - let verifier = p256::ecdsa::VerifyingKey::from_sec1_bytes( - &context.signer_verkey_compressed, - ) - .map_err(|e| MobileError::InvalidKeyData(format!("signer verkey parse failed: {e}")))?; + let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&context.revealed_pubkey_compressed) + .map_err(|e| MobileError::InvalidKeyData(format!("revealed pubkey unusable: {e}")))?; let sig = p256::ecdsa::Signature::from_slice(&sig_raw) - .map_err(|e| MobileError::PairingFailed(format!("signature parse failed: {e}")))?; - verifier.verify(&context.signing_payload, &sig).map_err(|e| { + .map_err(|e| MobileError::InvalidKeyData(format!("P-256 signature parse failed: {e}")))?; + vk.verify(&context.signing_payload, &sig).map_err(|e| { MobileError::PairingFailed(format!( - "signature does not match shared-KEL rot payload under supplied verkey: {e}" + "signature does not verify against the revealed key — likely SE misconfiguration: {e}" )) })?; - // Return the pre-serialized unsigned event joined with the - // verified signature. The Swift-side driver ships this blob to - // the daemon which in turn hands it to the Mac-side rot - // authorship code, which interprets this as a real rot event. - // (Multi-device membership is moving to KERI delegation; see - // docs/architecture/device-model.md.) - let mut out = Vec::with_capacity(context.unsigned_event.len() + sig_raw.len() + 3); - out.extend_from_slice(&context.unsigned_event); - out.extend_from_slice(b"||sig="); - out.extend_from_slice(&sig_raw); - Ok(out) + let indexed = IndexedSignature { + index: context.signer_index, + prior_index: Some(context.signer_prior_index), + sig: sig_raw.to_vec(), + }; + + // Full platform validation against the prior state: current threshold + // over the new keys AND prior next-threshold over the revealed + // commitments. This is what makes the emitted envelope a proof, not a + // proposal. + let signed = SignedEvent::new( + Event::Rot(context.unsigned_rot.clone()), + vec![indexed.clone()], + ); + validate_signed_event(&signed, Some(&context.prior_state)).map_err(|e| { + MobileError::PairingFailed(format!( + "co-authored rotation does not satisfy the platform validator: {e}" + )) + })?; + + let attachment = serialize_attachment(&[indexed]) + .map_err(|e| MobileError::Serialization(format!("rot attachment: {e}")))?; + let wire_envelope = encode_signed_rot(&context.unsigned_rot, &attachment) + .map_err(|e| MobileError::Serialization(format!("rot envelope: {e}")))?; + + Ok(SharedKelRotIndexedResult { + wire_envelope, + did: context.did(), + sequence: context.new_sequence, + event_said: context.unsigned_rot.d.as_str().to_string(), + signer_index: context.signer_index, + signer_prior_index: context.signer_prior_index, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use auths_keri::{Prefix, Threshold, decode_signed_rot, parse_attachment}; + use p256::ecdsa::{SigningKey, signature::Signer}; + use p256::elliptic_curve::rand_core::OsRng; + + fn fresh_p256_key() -> (SigningKey, Vec) { + let sk = SigningKey::random(&mut OsRng); + let compressed = sk + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec(); + (sk, compressed) + } + + fn qb64(pub_compressed: &[u8]) -> CesrKey { + verkey_to_cesr(&parse_p256_verkey(pub_compressed).unwrap()).unwrap() + } + + fn commitment(pub_compressed: &[u8]) -> Said { + compute_next_commitment(&parse_p256_verkey(pub_compressed).unwrap()) + } + + /// A 2-controller shared KEL state: slot 0 = mac, slot 1 = phone. + /// Returns (state_json, state, phone_next_sk, phone_next_pub). + fn two_controller_state() -> (String, KeyState, SigningKey, Vec) { + let (_mac_sk, mac_pub) = fresh_p256_key(); + let (_phone_sk, phone_pub) = fresh_p256_key(); + let (_mac_next_sk, mac_next_pub) = fresh_p256_key(); + let (phone_next_sk, phone_next_pub) = fresh_p256_key(); + + let said = commitment(&mac_pub); // any structurally-valid Said + let state = KeyState::from_inception( + Prefix::new_unchecked("EOZZSHAREDKELPREFIXxxxxxxxxxxxxxxxxxxxxxxxxx".into()), + vec![qb64(&mac_pub), qb64(&phone_pub)], + vec![commitment(&mac_next_pub), commitment(&phone_next_pub)], + Threshold::Simple(1), + Threshold::Simple(1), + said, + vec![], + Threshold::Simple(0), + vec![], + ); + let json = serde_json::to_string(&state).unwrap(); + (json, state, phone_next_sk, phone_next_pub) + } + + #[test] + fn swap_controller_round_trips_through_platform_validator() { + let (state_json, state, phone_next_sk, phone_next_pub) = two_controller_state(); + let mac_pub = state.current_keys[0].parse().unwrap().as_bytes().to_vec(); + + let (_nm_sk, new_mac_pub) = fresh_p256_key(); + let (_nmn_sk, new_mac_next_pub) = fresh_p256_key(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let ctx = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::SwapController { + old_verkey_der: mac_pub, + new_verkey_der: new_mac_pub.clone(), + new_next_verkey_der: new_mac_next_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap(); + assert_eq!(ctx.new_sequence(), 1); + assert_eq!(ctx.signer_index(), 1); + assert_eq!(ctx.signer_prior_index(), 1); + + let sig: p256::ecdsa::Signature = phone_next_sk.sign(&ctx.signing_payload()); + let result = + assemble_shared_kel_rot_indexed(ctx, sig.to_der().as_bytes().to_vec()).unwrap(); + assert_eq!(result.sequence, 1); + assert!(result.did.starts_with("did:keri:E")); + + // The envelope decodes through the SAME platform decoder the daemon + // uses, and the full signed event re-validates against prior state. + let (rot, attachment) = decode_signed_rot(&result.wire_envelope).unwrap(); + assert_eq!(rot.k.len(), 2, "swap preserves controller count"); + assert_eq!( + rot.k[0].parse().unwrap().as_bytes(), + new_mac_pub.as_slice(), + "slot 0 swapped in place" + ); + let sigs = parse_attachment(&attachment).unwrap(); + assert_eq!(sigs.len(), 1); + assert_eq!(sigs[0].index, 1); + // index == ondex round-trips through the canonical single-index + // CESR code (`A`), where the ondex is implied by the index. + assert_eq!(sigs[0].prior_index.unwrap_or(sigs[0].index), 1); + validate_signed_event(&SignedEvent::new(Event::Rot(rot), sigs), Some(&state)) + .expect("daemon-side replay must accept the emitted rotation"); + } + + #[test] + fn remove_controller_is_a_dual_index_shrink_the_validator_accepts() { + let (state_json, state, phone_next_sk, phone_next_pub) = two_controller_state(); + let mac_pub = state.current_keys[0].parse().unwrap().as_bytes().to_vec(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let ctx = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::RemoveController { + target_verkey_der: mac_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap(); + // After the shrink the phone is slot 0, but its reveal binds to + // prior n[1] — the dual-index pair the validator requires. + assert_eq!(ctx.signer_index(), 0); + assert_eq!(ctx.signer_prior_index(), 1); + + let sig: p256::ecdsa::Signature = phone_next_sk.sign(&ctx.signing_payload()); + let result = + assemble_shared_kel_rot_indexed(ctx, sig.to_der().as_bytes().to_vec()).unwrap(); + + let (rot, attachment) = decode_signed_rot(&result.wire_envelope).unwrap(); + assert_eq!(rot.k.len(), 1, "shrink dropped the removed controller"); + let sigs = parse_attachment(&attachment).unwrap(); + assert_eq!(sigs[0].index, 0); + assert_eq!(sigs[0].prior_index, Some(1)); + validate_signed_event(&SignedEvent::new(Event::Rot(rot), sigs), Some(&state)) + .expect("asymmetric (shrink) rotation must validate via the dual index"); + } + + #[test] + fn add_controller_appends_key_and_commitment() { + let (state_json, _state, phone_next_sk, phone_next_pub) = two_controller_state(); + let (_t_sk, tablet_pub) = fresh_p256_key(); + let (_tn_sk, tablet_next_pub) = fresh_p256_key(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let ctx = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::AddController { + new_verkey_der: tablet_pub.clone(), + new_next_verkey_der: tablet_next_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap(); + let sig: p256::ecdsa::Signature = phone_next_sk.sign(&ctx.signing_payload()); + let result = + assemble_shared_kel_rot_indexed(ctx, sig.to_der().as_bytes().to_vec()).unwrap(); + + let (rot, _attachment) = decode_signed_rot(&result.wire_envelope).unwrap(); + assert_eq!(rot.k.len(), 3); + assert_eq!(rot.k[2].parse().unwrap().as_bytes(), tablet_pub.as_slice()); + assert_eq!(rot.n.len(), 3); + } + + #[test] + fn unrevealed_key_is_rejected_as_commitment_mismatch() { + let (state_json, _state, _phone_next_sk, _phone_next_pub) = two_controller_state(); + let (_w_sk, wrong_pub) = fresh_p256_key(); + let (_n_sk, next_pub) = fresh_p256_key(); + + let err = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::RemoveController { + target_verkey_der: wrong_pub.clone(), + }, + wrong_pub, + next_pub, + ) + .unwrap_err(); + assert!(matches!(err, MobileError::CommitmentMismatch(_))); + } + + #[test] + fn author_cannot_remove_itself() { + let (state_json, state, _phone_next_sk, phone_next_pub) = two_controller_state(); + let phone_pub = state.current_keys[1].parse().unwrap().as_bytes().to_vec(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let err = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::RemoveController { + target_verkey_der: phone_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap_err(); + assert!(matches!(err, MobileError::PairingFailed(_))); + } + + #[test] + fn wrong_signature_is_rejected_before_any_envelope_is_emitted() { + let (state_json, state, _phone_next_sk, phone_next_pub) = two_controller_state(); + let mac_pub = state.current_keys[0].parse().unwrap().as_bytes().to_vec(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let ctx = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::RemoveController { + target_verkey_der: mac_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap(); + + let (attacker_sk, _) = fresh_p256_key(); + let bad: p256::ecdsa::Signature = attacker_sk.sign(&ctx.signing_payload()); + let err = assemble_shared_kel_rot_indexed(ctx, bad.to_der().as_bytes().to_vec()) + .unwrap_err(); + assert!(matches!(err, MobileError::PairingFailed(_))); + } + + #[test] + fn signing_payload_length_matches_version_string() { + // A spec verifier frames the body by the length in `v` — the signed + // bytes must be exactly that long. + let (state_json, state, _phone_next_sk, phone_next_pub) = two_controller_state(); + let mac_pub = state.current_keys[0].parse().unwrap().as_bytes().to_vec(); + let (_pn_sk, phone_new_next_pub) = fresh_p256_key(); + + let ctx = build_shared_kel_rot_payload( + state_json, + SharedKelChangeRequest::RemoveController { + target_verkey_der: mac_pub, + }, + phone_next_pub, + phone_new_next_pub, + ) + .unwrap(); + let payload = ctx.signing_payload(); + let event: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(event["t"], "rot"); + let v = event["v"].as_str().unwrap(); + let size = usize::from_str_radix(&v["KERI10JSON".len()..v.len() - 1], 16).unwrap(); + assert_eq!(size, payload.len()); + } } diff --git a/crates/auths-pairing-daemon/src/error.rs b/crates/auths-pairing-daemon/src/error.rs index f1abdc8f..a5803c70 100644 --- a/crates/auths-pairing-daemon/src/error.rs +++ b/crates/auths-pairing-daemon/src/error.rs @@ -169,6 +169,13 @@ pub enum DaemonError { /// verify, or a self-referential chain (bootstrap == subkey). #[error("subkey chain verification failed: {reason}")] InvalidSubkeyChain { reason: &'static str }, + + /// A submitted shared-KEL rotation envelope failed to decode or its + /// indexed signatures did not verify against the event's own key list + /// (400). The detail string goes to server logs only — the wire body + /// is a fixed safe template like every other variant. + #[error("shared-KEL rotation envelope invalid: {reason}")] + InvalidSharedKelRot { reason: String }, } // --------------------------------------------------------------------------- @@ -225,6 +232,7 @@ mod http_response { DaemonError::InvalidPubkeyLength { .. } => "invalid-pubkey-length", DaemonError::UnsupportedSubkeyChain => "unsupported-subkey-chain", DaemonError::InvalidSubkeyChain { .. } => "invalid-subkey-chain", + DaemonError::InvalidSharedKelRot { .. } => "invalid-shared-kel-rot", } } @@ -249,7 +257,8 @@ mod http_response { | DaemonError::ClockSkew | DaemonError::InvalidPubkeyLength { .. } | DaemonError::UnsupportedSubkeyChain - | DaemonError::InvalidSubkeyChain { .. } => StatusCode::BAD_REQUEST, + | DaemonError::InvalidSubkeyChain { .. } + | DaemonError::InvalidSharedKelRot { .. } => StatusCode::BAD_REQUEST, DaemonError::SessionExpired => StatusCode::GONE, } } @@ -281,6 +290,7 @@ mod http_response { DaemonError::InvalidPubkeyLength { .. } => "request malformed", DaemonError::UnsupportedSubkeyChain => "unsupported extension", DaemonError::InvalidSubkeyChain { .. } => "request malformed", + DaemonError::InvalidSharedKelRot { .. } => "request malformed", } } diff --git a/crates/auths-pairing-daemon/src/handlers.rs b/crates/auths-pairing-daemon/src/handlers.rs index b4c91b90..a53ae37a 100644 --- a/crates/auths-pairing-daemon/src/handlers.rs +++ b/crates/auths-pairing-daemon/src/handlers.rs @@ -32,7 +32,7 @@ use axum::{ use auths_core::pairing::types::{ GetConfirmationResponse, GetSessionResponse, SubmitConfirmationRequest, SubmitResponseRequest, - SuccessResponse, + SubmitSharedKelRotRequest, SuccessResponse, }; use crate::DaemonState; @@ -276,6 +276,74 @@ pub async fn handle_submit_confirmation( .map_err(|_| DaemonError::Conflict) } +/// Receive a co-authored shared-KEL rotation from the paired device. +/// Requires `Auths-Sig` under the pubkey bound by `submit_response`. +/// +/// The envelope is decoded and its CESR indexed signatures are verified +/// against the rotation's own key list BEFORE it is accepted — the daemon +/// never holds a blob it has not cryptographically checked. Prior-state +/// replay (threshold over the prior next-commitments, chain linkage) is +/// the embedding host's job: only the host has the registry's key state. +pub async fn handle_submit_shared_kel_rot( + Path(id): Path, + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Result, DaemonError> { + if state.is_expired(tokio::time::Instant::now()) { + return Err(DaemonError::SessionExpired); + } + let path = format!("/v1/pairing/sessions/{id}/shared-kel-rot"); + let parsed = extract_auth(&headers, AuthScheme::Sig)?; + let pubkey = state + .pubkey_binding + .current() + .ok_or(DaemonError::UnauthorizedSig)?; + let expected_kid = pubkey_kid(&pubkey); + if parsed.kid != expected_kid { + return Err(DaemonError::UnauthorizedSig); + } + let now = current_unix(); + verify_sig(&parsed, Method::POST.as_str(), &path, &body, &pubkey, now) + .map_err(auth_to_daemon_error)?; + state + .nonce_cache + .check_and_insert(&parsed.kid, &parsed.nonce) + .map_err(auth_to_daemon_error)?; + + let request: SubmitSharedKelRotRequest = parse_json_body(&body)?; + request + .validate() + .map_err(|reason| DaemonError::InvalidSharedKelRot { reason })?; + verify_shared_kel_rot_envelope(&request.rot_envelope)?; + + state + .submit_shared_kel_rot(&id, request) + .await + .map(Json) + .map_err(|_| DaemonError::Conflict) +} + +/// Decode a shared-KEL rotation envelope and verify its indexed signatures +/// against the event's own key list (`kt` over `k[]`). Returns the typed +/// boundary error on any failure; the detail lands in server logs via the +/// `IntoResponse` tracing hook, never on the wire. +fn verify_shared_kel_rot_envelope(envelope: &str) -> Result<(), DaemonError> { + let (rot, attachment) = + auths_keri::decode_signed_rot(envelope).map_err(|e| DaemonError::InvalidSharedKelRot { + reason: format!("envelope decode: {e}"), + })?; + let signatures = auths_keri::parse_attachment(&attachment).map_err(|e| { + DaemonError::InvalidSharedKelRot { + reason: format!("attachment parse: {e}"), + } + })?; + let signed = auths_keri::SignedEvent::new(auths_keri::Event::Rot(rot), signatures); + auths_keri::validate_signed_event(&signed, None).map_err(|e| DaemonError::InvalidSharedKelRot { + reason: format!("signature validation: {e}"), + }) +} + /// Get confirmation state. Requires `Auths-Sig` under the bound pubkey. pub async fn handle_get_confirmation( Path(id): Path, diff --git a/crates/auths-pairing-daemon/src/router.rs b/crates/auths-pairing-daemon/src/router.rs index 8c542065..1c96b5df 100644 --- a/crates/auths-pairing-daemon/src/router.rs +++ b/crates/auths-pairing-daemon/src/router.rs @@ -36,7 +36,7 @@ use tower_http::trace::TraceLayer; use crate::DaemonState; use crate::handlers::{ handle_get_confirmation, handle_get_session, handle_health, handle_lookup_hmac, - handle_submit_confirmation, handle_submit_response, + handle_submit_confirmation, handle_submit_response, handle_submit_shared_kel_rot, }; use crate::host_allowlist::{HostAllowlist, host_allowlist_middleware}; use crate::rate_limiter::{TieredRateLimiter, middleware::tiered_rate_limit_middleware}; @@ -84,6 +84,10 @@ pub fn build_pairing_router( "/v1/pairing/sessions/{id}/confirmation", get(handle_get_confirmation), ) + .route( + "/v1/pairing/sessions/{id}/shared-kel-rot", + post(handle_submit_shared_kel_rot), + ) // Per-request deadline — protects against slow-write clients // that hold a connection open indefinitely (a Slowloris // variant that targets the response write side). 30s is diff --git a/crates/auths-pairing-daemon/src/state.rs b/crates/auths-pairing-daemon/src/state.rs index ce09a12c..58df6f05 100644 --- a/crates/auths-pairing-daemon/src/state.rs +++ b/crates/auths-pairing-daemon/src/state.rs @@ -6,7 +6,7 @@ use tokio::time::Instant; use auths_core::pairing::types::{ CreateSessionRequest, GetConfirmationResponse, GetSessionResponse, SessionStatus, - SubmitConfirmationRequest, SubmitResponseRequest, SuccessResponse, + SubmitConfirmationRequest, SubmitResponseRequest, SubmitSharedKelRotRequest, SuccessResponse, }; /// Errors from session state transitions. @@ -39,6 +39,11 @@ pub struct DaemonState { pub(crate) response_tx: Mutex>>, pub(crate) confirmation: Mutex>, pub(crate) confirmation_notify: Arc, + /// A co-authored shared-KEL rotation received from the paired device, + /// already signature-verified at the HTTP boundary. The embedding host + /// takes it and replays it against the registry's prior key state. + pub(crate) shared_kel_rot: Mutex>, + pub(crate) shared_kel_rot_notify: Arc, pub(crate) pairing_token: Vec, /// Replay-protection cache shared across HMAC + Sig auth paths. /// Each `(kid, nonce)` is remembered for the nonce TTL window. @@ -100,6 +105,8 @@ impl DaemonState { response_tx: Mutex::new(Some(response_tx)), confirmation: Mutex::new(None), confirmation_notify: Arc::new(Notify::new()), + shared_kel_rot: Mutex::new(None), + shared_kel_rot_notify: Arc::new(Notify::new()), pairing_token, #[cfg(feature = "server")] nonce_cache: crate::auth::NonceCache::new(), @@ -261,6 +268,51 @@ impl DaemonState { }) } + /// Submit a co-authored shared-KEL rotation. One per session — a + /// second submission is a conflict (the host has at most one rotation + /// to replay; "fixing up" an already-delivered rotation is not a flow). + /// + /// The handler verifies the envelope's indexed signatures BEFORE this + /// is called; state only enforces session identity and single-shot + /// semantics. + pub async fn submit_shared_kel_rot( + &self, + id: &str, + request: SubmitSharedKelRotRequest, + ) -> Result { + if id != self.session.session_id { + return Err(SessionError::Conflict("session ID mismatch")); + } + + let mut rot = self.shared_kel_rot.lock().await; + if rot.is_some() { + return Err(SessionError::Conflict( + "shared-KEL rotation already submitted", + )); + } + *rot = Some(request); + drop(rot); + + self.shared_kel_rot_notify.notify_waiters(); + + Ok(SuccessResponse { + success: true, + message: "Shared-KEL rotation received".to_string(), + }) + } + + /// Take the received shared-KEL rotation, if any. The embedding host + /// calls this (after awaiting [`Self::shared_kel_rot_notify`]) to + /// replay the rotation against the registry's prior key state. + pub async fn take_shared_kel_rot(&self) -> Option { + self.shared_kel_rot.lock().await.take() + } + + /// Notifier fired when a shared-KEL rotation arrives. + pub fn shared_kel_rot_notify(&self) -> Arc { + Arc::clone(&self.shared_kel_rot_notify) + } + /// Get the current confirmation state. pub async fn get_confirmation(&self, id: &str) -> Option { if id != self.session.session_id { diff --git a/crates/auths-pairing-daemon/tests/cases/mod.rs b/crates/auths-pairing-daemon/tests/cases/mod.rs index 43cc833f..8ee92b43 100644 --- a/crates/auths-pairing-daemon/tests/cases/mod.rs +++ b/crates/auths-pairing-daemon/tests/cases/mod.rs @@ -10,6 +10,7 @@ mod rate_limiter; mod rate_tiers; mod request_limits; mod router; +mod shared_kel_rot; #[cfg(feature = "subkey-chain-v1")] mod subkey_chain; mod token; diff --git a/crates/auths-pairing-daemon/tests/cases/shared_kel_rot.rs b/crates/auths-pairing-daemon/tests/cases/shared_kel_rot.rs new file mode 100644 index 00000000..02d1867c --- /dev/null +++ b/crates/auths-pairing-daemon/tests/cases/shared_kel_rot.rs @@ -0,0 +1,276 @@ +//! End-to-end test of the shared-KEL rotation receive endpoint. +//! +//! A paired device (P-256, Secure-Enclave-shape) binds its pubkey via +//! `POST /response`, then submits a co-authored rotation envelope to +//! `POST /shared-kel-rot`. The daemon must: +//! - accept only under the session-bound `Auths-Sig` key, +//! - decode the envelope and verify its CESR indexed signatures against +//! the rotation's own key list before storing anything, +//! - hold exactly one rotation for the embedding host (`take_shared_kel_rot`), +//! - reject a tampered envelope with the typed 400 (`invalid-shared-kel-rot`). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::body::Body; +use axum::http::{Method, Request}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer}; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use tower::ServiceExt; + +use auths_keri::{ + CesrKey, Event, IndexedSignature, KeriPublicKey, KeriSequence, Prefix, RotEvent, RotEventInit, + Said, Threshold, VersionString, encode_signed_rot, finalize_rot_event, serialize_attachment, + serialize_for_signing, +}; +use auths_pairing_daemon::domain_separation::DAEMON_SIG_CONTEXT; + +use super::build_test_daemon; + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +struct P256DeviceKey { + sk: SigningKey, + vk: VerifyingKey, +} + +impl P256DeviceKey { + fn new() -> Self { + let sk = SigningKey::random(&mut OsRng); + let vk = *sk.verifying_key(); + Self { sk, vk } + } + + fn vk_compressed(&self) -> [u8; 33] { + let encoded = self.vk.to_encoded_point(true); + let mut arr = [0u8; 33]; + arr.copy_from_slice(encoded.as_bytes()); + arr + } + + fn kid(&self) -> [u8; 16] { + let mut h = Sha256::new(); + h.update(self.vk_compressed()); + let full = h.finalize(); + let mut out = [0u8; 16]; + out.copy_from_slice(&full[..16]); + out + } + + fn sign_canonical_raw(&self, canonical: &[u8]) -> [u8; 64] { + let sig: Signature = self.sk.sign(canonical); + sig.to_bytes().into() + } +} + +fn canonical(method: &str, path: &str, body: &[u8], ts: i64, nonce: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(body); + let body_hash_hex = hex::encode(hasher.finalize()); + let nonce_b64 = URL_SAFE_NO_PAD.encode(nonce); + let mut out = Vec::new(); + out.extend_from_slice(DAEMON_SIG_CONTEXT); + out.push(b'\n'); + out.extend_from_slice(method.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(path.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(body_hash_hex.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(nonce_b64.as_bytes()); + out +} + +fn sig_header(key: &P256DeviceKey, method: &str, path: &str, body: &[u8], nonce: &[u8]) -> String { + let ts = now(); + let c = canonical(method, path, body, ts, nonce); + let sig = key.sign_canonical_raw(&c); + format!( + "Auths-Sig kid={},ts={},nonce={},sig={}", + URL_SAFE_NO_PAD.encode(key.kid()), + ts, + URL_SAFE_NO_PAD.encode(nonce), + URL_SAFE_NO_PAD.encode(sig), + ) +} + +/// Bind `key` to the session via `POST /response` (first signed request). +async fn bind_pubkey(router: axum::Router, key: &P256DeviceKey) { + let vk_b64 = URL_SAFE_NO_PAD.encode(key.vk_compressed()); + let body = format!( + r#"{{"device_ephemeral_pubkey":"AAAA","device_signing_pubkey":"{vk_b64}","curve":"p256","device_did":"did:key:zDna","signature":"","device_name":"Test"}}"# + ); + let path = "/v1/pairing/sessions/test-session-001/response"; + let auth = sig_header(key, "POST", path, body.as_bytes(), &[7u8; 16]); + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert!( + resp.status().is_success(), + "pubkey bind (submit response) failed: {}", + resp.status() + ); +} + +/// Build a valid signed-rot wire envelope: a single-controller rotation +/// whose indexed signature verifies against its own `k[0]`. +fn valid_rot_envelope(controller: &SigningKey) -> String { + let compressed: [u8; 33] = controller + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .try_into() + .unwrap(); + let pk = KeriPublicKey::P256 { + key: compressed, + transferable: true, + }; + // A structurally-valid prior SAID (any Blake3 digest will do — the + // daemon's gate does not replay chain linkage; the host does). + let prior_said = auths_keri::compute_next_commitment(&pk); + let rot = finalize_rot_event(RotEvent::new(RotEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::new_unchecked("EOZZSHAREDKELPREFIXxxxxxxxxxxxxxxxxxxxxxxxxx".into()), + s: KeriSequence::new(1), + p: prior_said, + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(pk.to_qb64().unwrap())], + nt: Threshold::Simple(1), + n: vec![], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], + a: vec![], + })) + .unwrap(); + let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); + let sig: Signature = controller.sign(&canonical); + let raw: [u8; 64] = sig.to_bytes().into(); + let attachment = serialize_attachment(&[IndexedSignature { + index: 0, + prior_index: None, + sig: raw.to_vec(), + }]) + .unwrap(); + encode_signed_rot(&rot, &attachment).unwrap() +} + +#[tokio::test] +async fn signed_rot_envelope_is_received_and_held_for_the_host() { + let (router, state, _) = build_test_daemon(); + let key = P256DeviceKey::new(); + bind_pubkey(router.clone(), &key).await; + + let controller = SigningKey::random(&mut OsRng); + let envelope = valid_rot_envelope(&controller); + let body = format!(r#"{{"rot_envelope":"{envelope}"}}"#); + let path = "/v1/pairing/sessions/test-session-001/shared-kel-rot"; + let auth = sig_header(&key, "POST", path, body.as_bytes(), &[8u8; 16]); + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert!( + resp.status().is_success(), + "valid rot envelope must be accepted, got {}", + resp.status() + ); + + let held = state + .take_shared_kel_rot() + .await + .expect("host must find the received rotation"); + assert_eq!(held.rot_envelope, envelope); + assert!( + state.take_shared_kel_rot().await.is_none(), + "take is single-shot" + ); +} + +#[tokio::test] +async fn tampered_rot_envelope_is_rejected_with_typed_400() { + let (router, state, _) = build_test_daemon(); + let key = P256DeviceKey::new(); + bind_pubkey(router.clone(), &key).await; + + let controller = SigningKey::random(&mut OsRng); + let envelope = valid_rot_envelope(&controller); + // Re-sign the same event with a DIFFERENT key: the envelope decodes + // fine but the indexed signature no longer verifies against k[0]. + let (rot, _attachment) = auths_keri::decode_signed_rot(&envelope).unwrap(); + let attacker = SigningKey::random(&mut OsRng); + let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); + let bad_sig: Signature = attacker.sign(&canonical); + let raw: [u8; 64] = bad_sig.to_bytes().into(); + let bad_attachment = serialize_attachment(&[IndexedSignature { + index: 0, + prior_index: None, + sig: raw.to_vec(), + }]) + .unwrap(); + let forged = encode_signed_rot(&rot, &bad_attachment).unwrap(); + + let body = format!(r#"{{"rot_envelope":"{forged}"}}"#); + let path = "/v1/pairing/sessions/test-session-001/shared-kel-rot"; + let auth = sig_header(&key, "POST", path, body.as_bytes(), &[9u8; 16]); + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + 400, + "forged indexed signature must be rejected at the boundary" + ); + assert!( + state.take_shared_kel_rot().await.is_none(), + "a rejected envelope must never be stored" + ); +} + +#[tokio::test] +async fn unbound_key_cannot_submit_a_rot() { + // No prior /response — no bound pubkey — the endpoint must 401 + // before doing any envelope work. + let (router, state, _) = build_test_daemon(); + let key = P256DeviceKey::new(); + let controller = SigningKey::random(&mut OsRng); + let envelope = valid_rot_envelope(&controller); + let body = format!(r#"{{"rot_envelope":"{envelope}"}}"#); + let path = "/v1/pairing/sessions/test-session-001/shared-kel-rot"; + let auth = sig_header(&key, "POST", path, body.as_bytes(), &[10u8; 16]); + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("authorization", auth) + .body(Body::from(body)) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), 401); + assert!(state.take_shared_kel_rot().await.is_none()); +} diff --git a/crates/auths-pairing-protocol/src/types.rs b/crates/auths-pairing-protocol/src/types.rs index 7a74d969..d3dddf33 100644 --- a/crates/auths-pairing-protocol/src/types.rs +++ b/crates/auths-pairing-protocol/src/types.rs @@ -209,3 +209,47 @@ pub struct GetConfirmationResponse { #[serde(default)] pub aborted: bool, } + +/// Maximum size in bytes for [`SubmitSharedKelRotRequest::rot_envelope`]. +/// +/// A single-sig P-256 rotation over a handful of controllers is well under +/// 2 KB in its base64url wire form; the cap leaves headroom for multi-sig +/// attachments while refusing payloads that could only be junk. +pub const SHARED_KEL_ROT_ENVELOPE_MAX_BYTES: usize = 8192; + +/// Request submitting a co-authored shared-KEL rotation to the daemon. +/// +/// The envelope is the single-string wire form produced by +/// `auths_keri::encode_signed_rot` — the rotation event plus its CESR +/// indexed-signature attachment. The daemon decodes it, verifies the +/// indexed signatures against the event's own key list, and holds it for +/// the embedding host to replay against the registry's prior state. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct SubmitSharedKelRotRequest { + /// base64url-no-pad wire envelope of the signed rotation + /// (`auths_keri::encode_signed_rot`). + pub rot_envelope: String, +} + +impl SubmitSharedKelRotRequest { + /// Validate payload-size invariants that must hold before transmission. + /// + /// Returns `Err` with a diagnostic string when `rot_envelope` exceeds + /// [`SHARED_KEL_ROT_ENVELOPE_MAX_BYTES`]. + /// + /// Usage: + /// ```ignore + /// req.validate()?; + /// ``` + pub fn validate(&self) -> Result<(), String> { + if self.rot_envelope.len() > SHARED_KEL_ROT_ENVELOPE_MAX_BYTES { + return Err(format!( + "rot_envelope is {} bytes, exceeds cap of {}", + self.rot_envelope.len(), + SHARED_KEL_ROT_ENVELOPE_MAX_BYTES, + )); + } + Ok(()) + } +} diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index fda021b5..0b104ac6 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -22,8 +22,12 @@ use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; use crate::context::AuthsContext; -use crate::domains::compliance::frameworks::FrameworkReport; -use crate::domains::compliance::query::{ComplianceQueryError, EvidencePack}; +use crate::domains::compliance::frameworks::{FrameworkReport, INTOTO_STATEMENT_TYPE}; +use crate::domains::compliance::query::{ + ComplianceQueryError, EvidencePack, RowVerdict, verify_evidence_pack_offline, +}; +use crate::domains::org::offline_verify::authenticate_bundled_kel; +use auths_verifier::IdentityDID; /// The in-toto Statement payload type carried in the DSSE envelope. pub const DSSE_INTOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; @@ -141,6 +145,109 @@ impl DsseEnvelope { } } +/// A DSSE-signed evidence pack that has passed full offline verification — +/// this value cannot exist unless the embedded org KEL authenticated (RT-002), +/// the envelope signature verified over its PAE bytes against the KEL-resolved +/// org verkey, the org was pinned, and no duplicity was detected. +/// +/// Per-row tamper/transparency findings are verdicts, not errors: a pack that +/// honestly reports a rejected-after-revocation release is still *verified* — +/// the log telling the truth about a damned signature is the system working. +/// [`Self::authentic`] folds the rows into the auditor's single verdict. +#[derive(Debug, Clone, Serialize)] +pub struct VerifiedEvidencePack { + /// The verified pack (safe to render — its bytes are what the org signed). + pub pack: EvidencePack, + /// The authenticated org KEL position the verdict is derived as-of. + pub org_kel_seq: u128, + /// One verdict per evidence row, re-derived from the embedded KEL. + pub verdicts: Vec, +} + +impl VerifiedEvidencePack { + /// The auditor's single verdict: every row's authority re-derivation matched + /// the recorded row, and every transparency proof present verified. + pub fn authentic(&self) -> bool { + self.verdicts + .iter() + .all(|v| v.authority_consistent && v.transparency_verified.unwrap_or(true)) + } +} + +/// Verify a DSSE-signed offline evidence pack with **zero network**, trusting +/// nothing but `pinned_roots` and mathematics. +/// +/// The auditor-side chain, in trust order: +/// 1. Parse the envelope and locate the embedded org KEL — the one +/// self-certifying structure in the payload (its prefix commits to its +/// inception keys; every later event is signature-chained). No other pack +/// claim is trusted yet. +/// 2. Authenticate that KEL (RT-002) and resolve the org's **current** verkey +/// from it — never from a keychain, a server, or a config file. +/// 3. Verify the DSSE signature over the PAE bytes against that verkey. +/// 4. Run [`verify_evidence_pack_offline`]: org root pinned, KEL duplicity, +/// per-row authority re-derivation, transparency proofs where present. +/// +/// Fail-closed: a missing bundle, an unauthenticated KEL, a signature that does +/// not verify, an unpinned org, or duplicity is an `Err` — never a verdict. +/// +/// Args: +/// * `envelope_json`: The DSSE envelope as produced by [`sign_evidence_pack`]. +/// * `pinned_roots`: The verifier's pinned trust roots (its only trust input). +/// +/// Usage: +/// ```ignore +/// let verified = verify_signed_evidence_pack_offline(&raw, &roots)?; +/// assert!(verified.authentic()); +/// ``` +pub fn verify_signed_evidence_pack_offline( + envelope_json: &str, + pinned_roots: &[IdentityDID], +) -> Result { + let envelope = DsseEnvelope::from_json(envelope_json)?; + let payload = envelope.decoded_payload()?; + let statement: serde_json::Value = serde_json::from_slice(&payload) + .map_err(|e| ComplianceQueryError::Decode(format!("dsse payload is not JSON: {e}")))?; + if statement["_type"] != INTOTO_STATEMENT_TYPE { + return Err(ComplianceQueryError::Decode(format!( + "dsse payload is not an in-toto Statement ({INTOTO_STATEMENT_TYPE})" + ))); + } + let pack: EvidencePack = + serde_json::from_value(statement["predicate"].clone()).map_err(|e| { + ComplianceQueryError::Decode(format!( + "statement predicate is not an evidence pack: {e}" + )) + })?; + let bundle = pack.org_bundle.as_ref().ok_or_else(|| { + ComplianceQueryError::OfflineVerification( + "pack carries no embedded org bundle — not an offline-verifiable pack".into(), + ) + })?; + + // The org verkey, from the authenticated embedded KEL alone. + let state = authenticate_bundled_kel(&bundle.org_kel, None) + .map_err(|e| ComplianceQueryError::OfflineVerification(e.to_string()))?; + let cesr = state.current_key().ok_or_else(|| { + ComplianceQueryError::Verification("org KEL resolves to no current key".into()) + })?; + let org_key = KeriPublicKey::parse(cesr.as_str()) + .map_err(|e| ComplianceQueryError::Decode(format!("org verkey decode: {e}")))?; + let org_curve = match org_key { + KeriPublicKey::Ed25519(_) => CurveType::Ed25519, + KeriPublicKey::P256 { .. } => CurveType::P256, + }; + envelope.verify(org_key.as_bytes(), org_curve)?; + + // Only now is the payload trusted enough to re-derive every row from it. + let verdicts = verify_evidence_pack_offline(&pack, pinned_roots)?; + Ok(VerifiedEvidencePack { + org_kel_seq: state.sequence, + pack, + verdicts, + }) +} + /// Sign a compliance evidence pack as a DSSE-wrapped in-toto Statement, org-signed. /// /// The payload is the canonical in-toto Statement ([`EvidencePack::to_intoto_statement`]); diff --git a/crates/auths-sdk/src/domains/compliance/mod.rs b/crates/auths-sdk/src/domains/compliance/mod.rs index 8e090990..1b7c0100 100644 --- a/crates/auths-sdk/src/domains/compliance/mod.rs +++ b/crates/auths-sdk/src/domains/compliance/mod.rs @@ -10,14 +10,16 @@ pub mod error; pub mod frameworks; /// Compliance-as-a-query: deterministic, offline-verifiable evidence packs. pub mod query; +/// Release attestations anchored in the org KEL (attest + discovery). +pub mod releases; /// Compliance services pub mod service; /// Compliance types and configuration pub mod types; pub use dsse::{ - DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature, sign_evidence_pack, - sign_framework_report, + DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature, VerifiedEvidencePack, + sign_evidence_pack, sign_framework_report, verify_signed_evidence_pack_offline, }; pub use error::*; pub use frameworks::{ @@ -29,3 +31,7 @@ pub use query::{ EvidenceRow, ReleaseRecord, RowVerdict, TransparencyInclusion, build_evidence_pack, build_offline_evidence_pack, load_witness_policy, verify_evidence_pack_offline, }; +pub use releases::{ + AnchoredRelease, ArtifactDigest, ReleaseAttestation, ReleaseAttestationKind, attest_release, + discover_releases, +}; diff --git a/crates/auths-sdk/src/domains/compliance/query.rs b/crates/auths-sdk/src/domains/compliance/query.rs index a7bd89b4..8c87aa83 100644 --- a/crates/auths-sdk/src/domains/compliance/query.rs +++ b/crates/auths-sdk/src/domains/compliance/query.rs @@ -59,6 +59,18 @@ pub enum ComplianceQueryError { /// transparency proof that did not check out). #[error("offline verification failed: {0}")] OfflineVerification(String), + /// Anchoring a release attestation in the org KEL failed. + #[error("release anchoring failed: {0}")] + Anchor(#[from] auths_id::keri::AnchorError), + /// A registry read/write failed while attesting or discovering releases. + #[error("registry access failed: {0}")] + Registry(String), + /// A malformed artifact digest or release-attestation blob. + #[error("invalid release attestation: {0}")] + InvalidRelease(String), + /// An anchored release blob does not hash back to its KEL seal digest. + #[error("anchored release attestation {0} does not match its KEL seal digest")] + TamperedRelease(String), } /// The compliance framework a report targets. Predicate rendering (SLSA diff --git a/crates/auths-sdk/src/domains/compliance/releases.rs b/crates/auths-sdk/src/domains/compliance/releases.rs new file mode 100644 index 00000000..fb3c3806 --- /dev/null +++ b/crates/auths-sdk/src/domains/compliance/releases.rs @@ -0,0 +1,304 @@ +//! Release attestations anchored in the org KEL — the signing position as log +//! fact, not caller assertion. +//! +//! At signing time the org anchors a [`ReleaseAttestation`] (artifact digest + +//! signer) in its KEL via the standard ixn seal ([`try_stage_anchor`]) and +//! stores the canonical blob content-addressed by its SAID — one atomic commit, +//! so a crash can never leave a dangling seal or an orphaned blob. At report +//! time [`discover_releases`] walks the org KEL's anchors, resolves each SAID +//! back to its blob, and parses only the blobs that are release attestations — +//! every discovered row's `signed_at` **is** the anchoring KEL position, so the +//! evidence pack derives "what the log proves it shipped" instead of trusting a +//! caller-supplied list. + +use std::sync::Arc; + +use auths_core::signing::StorageSigner; +use auths_core::storage::keychain::KeyAlias; +use auths_id::attestation::enriched::canonical_said; +use auths_id::keri::types::{Prefix, Said}; +use auths_id::keri::{resolve_anchored_saids_via_backend, try_stage_anchor}; +use auths_id::ports::registry::RegistryBackend; +use auths_id::storage::registry::backend::AtomicWriteBatch; +use serde::{Deserialize, Serialize}; + +use crate::context::AuthsContext; +use crate::domains::compliance::query::{ComplianceQueryError, ReleaseRecord}; + +/// A parsed `sha256:<64 lowercase hex>` artifact digest. Construction goes +/// through [`ArtifactDigest::parse`]; a malformed digest is unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ArtifactDigest(String); + +impl ArtifactDigest { + /// Parse a `sha256:<64 hex>` digest, normalizing hex to lowercase. + pub fn parse(s: &str) -> Result { + let hex = s.strip_prefix("sha256:").ok_or_else(|| { + ComplianceQueryError::InvalidRelease(format!( + "artifact digest must start with 'sha256:' (got '{s}')" + )) + })?; + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ComplianceQueryError::InvalidRelease(format!( + "artifact digest must be 64 hex characters after 'sha256:' (got {} chars)", + hex.len() + ))); + } + Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase()))) + } + + /// The canonical `sha256:` string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consume into the canonical `sha256:` string. + pub fn into_inner(self) -> String { + self.0 + } +} + +impl TryFrom for ArtifactDigest { + type Error = String; + fn try_from(s: String) -> Result { + Self::parse(&s).map_err(|e| e.to_string()) + } +} + +impl From for String { + fn from(d: ArtifactDigest) -> Self { + d.0 + } +} + +impl std::fmt::Display for ArtifactDigest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Schema discriminator for [`ReleaseAttestation`]. Parsing any other anchored +/// blob (membership attestation, ACDC credential, …) fails here, so discovery +/// can never mistake a foreign blob for a release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReleaseAttestationKind { + /// Version 1 org release attestation. + #[serde(rename = "auths/org-release/v1")] + OrgReleaseV1, +} + +/// The release fact the org anchors in its KEL at signing time: which artifact, +/// signed by which member. The anchoring ixn's sequence number — not any field +/// in here — is the release's signing position. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReleaseAttestation { + /// Schema discriminator (always `auths/org-release/v1`). + pub kind: ReleaseAttestationKind, + /// The artifact content digest. + pub artifact_digest: ArtifactDigest, + /// The signing member's KEL prefix. + pub signer: Prefix, +} + +impl ReleaseAttestation { + /// Parse a stored blob back into a release attestation. Non-release blobs + /// (wrong/missing `kind`, unknown fields) fail closed. + pub fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + .map_err(|e| ComplianceQueryError::InvalidRelease(e.to_string())) + } +} + +/// A release attestation that is anchored in the org KEL: the proof-carrying +/// result of [`attest_release`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AnchoredRelease { + /// The artifact content digest. + pub artifact_digest: ArtifactDigest, + /// The signing member's KEL prefix. + pub signer: Prefix, + /// The org-KEL position of the anchoring ixn — the release's signing + /// position, derived from the log, never caller input. + pub signed_at: u128, + /// The SAID of the anchored attestation (the ixn's seal digest). + pub attestation_said: Said, +} + +/// Anchor a release attestation in the org KEL at signing time. +/// +/// Builds the canonical [`ReleaseAttestation`], seals its SAID into a new org +/// ixn event, and stores the blob content-addressed by that SAID — all staged +/// into one atomic registry commit. The returned `signed_at` is the ixn's KEL +/// position; [`discover_releases`] re-derives exactly this value at report +/// time. +/// +/// Args: +/// * `ctx`: Auths context (registry, key storage, passphrase provider). +/// * `org_prefix`: The org's KEL prefix (the controller that signs the ixn). +/// * `org_alias`: Keychain alias of the org's signing key. +/// * `artifact_digest`: The parsed artifact digest. +/// * `signer`: The signing member's KEL prefix. +/// +/// Usage: +/// ```ignore +/// let anchored = attest_release(&ctx, &org_prefix, &org_alias, digest, member)?; +/// println!("anchored at org KEL seq {}", anchored.signed_at); +/// ``` +pub fn attest_release( + ctx: &AuthsContext, + org_prefix: &Prefix, + org_alias: &KeyAlias, + artifact_digest: ArtifactDigest, + signer: Prefix, +) -> Result { + let attestation = ReleaseAttestation { + kind: ReleaseAttestationKind::OrgReleaseV1, + artifact_digest, + signer, + }; + let canonical = json_canon::to_string(&attestation) + .map_err(|e| ComplianceQueryError::Canonicalize(e.to_string()))?; + + let org_signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + let mut batch = AtomicWriteBatch::new(); + let (attestation_said, ixn) = try_stage_anchor( + ctx.registry.as_ref(), + &org_signer, + org_alias, + ctx.passphrase_provider.as_ref(), + org_prefix, + &attestation, + &mut batch, + )?; + batch.stage_credential( + org_prefix.clone(), + attestation_said.clone(), + canonical.into_bytes(), + ); + ctx.registry + .commit_batch(&batch) + .map_err(|e| ComplianceQueryError::Registry(e.to_string()))?; + + Ok(AnchoredRelease { + artifact_digest: attestation.artifact_digest, + signer: attestation.signer, + signed_at: ixn.s.value(), + attestation_said, + }) +} + +/// Discover the org's releases from its own KEL anchors. +/// +/// Walks every ixn digest seal in the org KEL, resolves each SAID to its +/// content-addressed blob, and keeps exactly the blobs that parse as +/// [`ReleaseAttestation`]s — anchors for other facts (membership, device links) +/// are skipped because their blobs are absent or fail the `kind` parse. Each +/// returned row's `signed_at` is the anchoring KEL position. A blob that parses +/// as a release but does not hash back to its seal digest is registry +/// corruption and fails closed. +/// +/// Args: +/// * `backend`: The org's registry backend. +/// * `org_prefix`: The org's KEL prefix. +/// +/// Usage: +/// ```ignore +/// let records = discover_releases(ctx.registry.as_ref(), &org_prefix)?; +/// let pack = build_evidence_pack(&ctx, org, &org_prefix, period, fw, &records, &policy, now)?; +/// ``` +pub fn discover_releases( + backend: &dyn RegistryBackend, + org_prefix: &Prefix, +) -> Result, ComplianceQueryError> { + let anchored = resolve_anchored_saids_via_backend(backend, org_prefix, None)?; + + let mut records = Vec::new(); + for (seq, said) in anchored { + let Some(bytes) = backend + .load_credential(org_prefix, &said) + .map_err(|e| ComplianceQueryError::Registry(e.to_string()))? + else { + continue; // anchor for some other fact — no content-addressed blob + }; + let Ok(attestation) = ReleaseAttestation::parse(&bytes) else { + continue; // a credential of another kind shares the store — skip + }; + // The blob must hash back to the seal digest that anchors it. + if canonical_said(&attestation).as_ref() != Some(&said) { + return Err(ComplianceQueryError::TamperedRelease( + said.as_str().to_string(), + )); + } + records.push(ReleaseRecord { + artifact_digest: attestation.artifact_digest.into_inner(), + signer_prefix: attestation.signer, + signed_at: Some(seq), + transparency: None, + }); + } + Ok(records) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + const HEX64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn artifact_digest_parses_and_normalizes() { + let upper = format!("sha256:{}", HEX64.to_ascii_uppercase()); + let d = ArtifactDigest::parse(&upper).unwrap(); + assert_eq!(d.as_str(), format!("sha256:{HEX64}")); + } + + #[test] + fn artifact_digest_rejects_malformed_input() { + for bad in [ + "aaaa", + "sha256:", + "sha256:zz", + "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + assert!( + ArtifactDigest::parse(bad).is_err(), + "'{bad}' must be rejected" + ); + } + } + + #[test] + fn release_attestation_round_trips_canonically() { + let att = ReleaseAttestation { + kind: ReleaseAttestationKind::OrgReleaseV1, + artifact_digest: ArtifactDigest::parse(&format!("sha256:{HEX64}")).unwrap(), + signer: Prefix::new_unchecked("EMember".to_string()), + }; + let canonical = json_canon::to_string(&att).unwrap(); + let back = ReleaseAttestation::parse(canonical.as_bytes()).unwrap(); + assert_eq!(att, back); + assert_eq!(canonical, json_canon::to_string(&back).unwrap()); + } + + #[test] + fn parse_rejects_foreign_blobs() { + // Wrong kind tag. + let wrong_kind = format!( + r#"{{"kind":"auths/device-link/v1","artifact_digest":"sha256:{HEX64}","signer":"EMember"}}"# + ); + assert!(ReleaseAttestation::parse(wrong_kind.as_bytes()).is_err()); + + // A membership-attestation-shaped blob (no kind at all). + let foreign = br#"{"subject":"did:keri:EMember","org_role":"member"}"#; + assert!(ReleaseAttestation::parse(foreign).is_err()); + + // Unknown extra fields fail closed. + let extra = format!( + r#"{{"kind":"auths/org-release/v1","artifact_digest":"sha256:{HEX64}","signer":"EMember","x":1}}"# + ); + assert!(ReleaseAttestation::parse(extra.as_bytes()).is_err()); + } +} diff --git a/crates/auths-sdk/src/domains/device/service.rs b/crates/auths-sdk/src/domains/device/service.rs index 27dcdba2..a5e72032 100644 --- a/crates/auths-sdk/src/domains/device/service.rs +++ b/crates/auths-sdk/src/domains/device/service.rs @@ -254,6 +254,7 @@ pub fn extend_device( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, ctx.passphrase_provider.as_ref(), @@ -358,6 +359,7 @@ fn sign_attestation( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, signer, passphrase_provider, diff --git a/crates/auths-sdk/src/domains/identity/service.rs b/crates/auths-sdk/src/domains/identity/service.rs index c1a935d4..2813c6b2 100644 --- a/crates/auths-sdk/src/domains/identity/service.rs +++ b/crates/auths-sdk/src/domains/identity/service.rs @@ -352,6 +352,7 @@ fn bind_device( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, signer, passphrase_provider, diff --git a/crates/auths-sdk/src/domains/org/offline_verify.rs b/crates/auths-sdk/src/domains/org/offline_verify.rs index 0e3d5a41..d935a232 100644 --- a/crates/auths-sdk/src/domains/org/offline_verify.rs +++ b/crates/auths-sdk/src/domains/org/offline_verify.rs @@ -60,10 +60,14 @@ fn check_kel_integrity(kel: &BundledKel) -> Result<(), OrgError> { /// the parallel `events` × hex `attachments` and replays them through /// `validate_signed_kel`. Fails closed on a length mismatch, an unparseable /// attachment, or a signature that doesn't verify. -fn authenticate_bundled_kel( +/// +/// Returns the authenticated [`auths_keri::KeyState`] — the only trust-rooted +/// source of the controller's *current* verkey available offline (e.g. to verify +/// a DSSE envelope signed by the org whose KEL the evidence embeds). +pub(crate) fn authenticate_bundled_kel( kel: &BundledKel, lookup: Option<&dyn auths_keri::DelegatorKelLookup>, -) -> Result<(), OrgError> { +) -> Result { let integrity_err = |reason: String| OrgError::BundleIntegrity { id: kel.prefix.as_str().to_string(), reason, @@ -89,8 +93,7 @@ fn authenticate_bundled_kel( signed.push(auths_keri::SignedEvent::new(event, sigs)); } auths_keri::validate_signed_kel(&signed, lookup) - .map_err(|e| integrity_err(format!("KEL signature authentication failed (RT-002): {e}")))?; - Ok(()) + .map_err(|e| integrity_err(format!("KEL signature authentication failed (RT-002): {e}"))) } /// Re-attach a delegated event's source seal from its parsed attachment — the JSON diff --git a/crates/auths-sdk/src/domains/org/service.rs b/crates/auths-sdk/src/domains/org/service.rs index 842be39d..3dc92502 100644 --- a/crates/auths-sdk/src/domains/org/service.rs +++ b/crates/auths-sdk/src/domains/org/service.rs @@ -290,6 +290,7 @@ pub fn create_org( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, ctx.passphrase_provider.as_ref(), diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index 5d187a84..431763d5 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -15,7 +15,7 @@ use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage}; use auths_id::attestation::core::resign_attestation; use auths_id::attestation::create::create_signed_attestation; use auths_id::storage::git_refs::AttestationMetadata; -use auths_verifier::core::{ResourceId, SignerType}; +use auths_verifier::core::{OidcBinding, ResourceId, SignerType}; use auths_verifier::types::CanonicalDid; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; @@ -660,6 +660,7 @@ fn create_and_sign_attestation( delegated_by: None, commit_sha, signer_type: None, + oidc_binding: None, }, signer, &noop_provider, @@ -679,6 +680,28 @@ fn create_and_sign_attestation( .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string())) } +/// Inputs for [`sign_artifact_ephemeral`] — the artifact bytes plus the +/// provenance the one-time key binds them to. Named fields (the +/// `AttestationInput` pattern) so call sites stay readable as the shape +/// evolves. +pub struct EphemeralSignRequest<'a> { + /// Raw artifact bytes to sign. + pub data: &'a [u8], + /// Optional human-readable name for the artifact. + pub artifact_name: Option, + /// Git commit SHA this artifact was built from (required, 40 or 64 hex chars). + pub commit_sha: String, + /// Optional TTL in seconds. + pub expires_in: Option, + /// Optional attestation note. + pub note: Option, + /// Optional CI environment metadata (serialized into payload, covered by signature). + pub ci_env: Option, + /// Verified OIDC workload binding, if the runner presented a token + /// (signed envelope field, so verify-time policy joins can trust it). + pub oidc_binding: Option, +} + /// Signs artifact bytes with a one-time ephemeral Ed25519 key. No keychain, no /// identity storage, no passphrase — the key is generated, used, and zeroized /// within this function call. @@ -690,29 +713,34 @@ fn create_and_sign_attestation( /// /// Args: /// * `now` - Current UTC time (injected per clock pattern). -/// * `data` - Raw artifact bytes to sign. -/// * `artifact_name` - Optional human-readable name for the artifact. -/// * `commit_sha` - Git commit SHA this artifact was built from (required, 40 or 64 hex chars). -/// * `expires_in` - Optional TTL in seconds. -/// * `note` - Optional attestation note. -/// * `ci_env` - Optional CI environment metadata (serialized into payload, covered by signature). +/// * `req` - The artifact bytes plus provenance to bind (see +/// [`EphemeralSignRequest`]). /// /// Usage: /// ```ignore -/// let result = sign_artifact_ephemeral( -/// Utc::now(), b"artifact bytes", Some("release.tar.gz".into()), -/// "abc123def456abc123def456abc123def456abc1".into(), None, None, None, -/// )?; +/// let result = sign_artifact_ephemeral(Utc::now(), EphemeralSignRequest { +/// data: b"artifact bytes", +/// artifact_name: Some("release.tar.gz".into()), +/// commit_sha: "abc123def456abc123def456abc123def456abc1".into(), +/// expires_in: None, +/// note: None, +/// ci_env: None, +/// oidc_binding: None, +/// })?; /// ``` pub fn sign_artifact_ephemeral( now: DateTime, - data: &[u8], - artifact_name: Option, - commit_sha: String, - expires_in: Option, - note: Option, - ci_env: Option, + req: EphemeralSignRequest<'_>, ) -> Result { + let EphemeralSignRequest { + data, + artifact_name, + commit_sha, + expires_in, + note, + ci_env, + oidc_binding, + } = req; // 1. Generate ephemeral P-256 seed and zeroize on drop let mut seed_bytes = Zeroizing::new([0u8; 32]); ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), seed_bytes.as_mut()) @@ -791,6 +819,7 @@ pub fn sign_artifact_ephemeral( delegated_by: None, commit_sha: Some(validated_sha), signer_type: Some(SignerType::Workload), + oidc_binding, }, &signer, &noop_provider, @@ -909,6 +938,7 @@ pub fn sign_artifact_raw( delegated_by: None, commit_sha: validated_commit_sha, signer_type: None, + oidc_binding: None, }, &signer, &noop_provider, diff --git a/crates/auths-sdk/src/pairing/delegation.rs b/crates/auths-sdk/src/pairing/delegation.rs index 68e6c6ad..1eec8707 100644 --- a/crates/auths-sdk/src/pairing/delegation.rs +++ b/crates/auths-sdk/src/pairing/delegation.rs @@ -33,45 +33,29 @@ use auths_crypto::CurveType; use auths_id::keri::delegation::{DeviceDipBundle, anchor_received_dip, build_device_dip}; use auths_id::keri::types::Prefix; use auths_id::keri::{Event, parse_did_keri, validate_delegation}; -use auths_keri::{DipEvent, IxnEvent, SourceSeal, serialize_source_seal_couples}; +use auths_keri::{ + DipEvent, IxnEvent, SourceSeal, decode_signed_dip as keri_decode_signed_dip, + encode_signed_dip as keri_encode_signed_dip, serialize_source_seal_couples, +}; use auths_verifier::types::CanonicalDid; use crate::context::AuthsContext; use crate::pairing::PairingError; -/// A device's signed delegated-inception, serialized for the pairing wire. -/// -/// Carried in `SubmitResponseRequest.responder_inception_event` as -/// base64url(JSON). The attachment (the device's CESR signature over the dip) is -/// kept alongside the event so the initiator can replay the signed dip exactly. -#[derive(serde::Serialize, serde::Deserialize)] -struct WireSignedDip { - event: DipEvent, - attachment_b64: String, -} - /// Encode a device-signed dip for the `responder_inception_event` wire field. +/// +/// The wire form itself ([`auths_keri::WireSignedDip`]) is shared with every +/// other producer (the mobile FFI's dip assembler); this wrapper only maps the +/// error into [`PairingError`]. fn encode_signed_dip(dip: &DipEvent, attachment: &[u8]) -> Result { - let wire = WireSignedDip { - event: dip.clone(), - attachment_b64: URL_SAFE_NO_PAD.encode(attachment), - }; - let json = serde_json::to_vec(&wire) - .map_err(|e| PairingError::AttestationFailed(format!("encode dip: {e}")))?; - Ok(URL_SAFE_NO_PAD.encode(json)) + keri_encode_signed_dip(dip, attachment) + .map_err(|e| PairingError::AttestationFailed(format!("encode dip: {e}"))) } /// Decode a device-signed dip received in `responder_inception_event`. fn decode_signed_dip(encoded: &str) -> Result<(DipEvent, Vec), PairingError> { - let json = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|e| PairingError::AttestationFailed(format!("decode dip envelope: {e}")))?; - let wire: WireSignedDip = serde_json::from_slice(&json) - .map_err(|e| PairingError::AttestationFailed(format!("decode dip json: {e}")))?; - let attachment = URL_SAFE_NO_PAD - .decode(&wire.attachment_b64) - .map_err(|e| PairingError::AttestationFailed(format!("decode dip attachment: {e}")))?; - Ok((wire.event, attachment)) + keri_decode_signed_dip(encoded) + .map_err(|e| PairingError::AttestationFailed(format!("decode dip: {e}"))) } /// Encode the root's anchoring `ixn` for the confirmation channel. diff --git a/crates/auths-sdk/src/workflows/ci/machine_identity.rs b/crates/auths-sdk/src/workflows/ci/machine_identity.rs index 4be83236..c38c65dc 100644 --- a/crates/auths-sdk/src/workflows/ci/machine_identity.rs +++ b/crates/auths-sdk/src/workflows/ci/machine_identity.rs @@ -1,9 +1,7 @@ use chrono::{DateTime, Utc}; use std::sync::Arc; -use auths_oidc_port::{ - JwksClient, JwtValidator, OidcError, OidcValidationConfig, TimestampClient, TimestampConfig, -}; +use auths_oidc_port::{JwtValidator, OidcError, OidcValidationConfig}; use auths_verifier::core::{Attestation, Ed25519Signature, OidcBinding, ResourceId}; use auths_verifier::types::CanonicalDid; use ring::signature::Ed25519KeyPair; @@ -26,8 +24,6 @@ use ring::signature::Ed25519KeyPair; /// token, /// config, /// jwt_validator, -/// jwks_client, -/// timestamp_client, /// Utc::now(), /// ).await?; /// ``` @@ -65,23 +61,20 @@ pub struct OidcMachineIdentity { /// Create a machine identity from an OIDC token. /// -/// Validates the token, extracts claims, performs replay detection, -/// and optionally timestamps the identity. +/// Validates the token (signature against the issuer's JWKS via the +/// injected validator, issuer/audience/expiry claims), extracts and +/// platform-normalizes the claims, and performs replay detection. /// /// # Args /// /// * `token`: Raw JWT OIDC token /// * `config`: Machine identity configuration -/// * `jwt_validator`: JWT validator implementation -/// * `jwks_client`: JWKS client for key resolution -/// * `timestamp_client`: Optional timestamp client +/// * `jwt_validator`: JWT validator implementation (owns JWKS resolution) /// * `now`: Current UTC time for validation pub async fn create_machine_identity_from_oidc_token( token: &str, config: OidcMachineIdentityConfig, jwt_validator: Arc, - _jwks_client: Arc, - timestamp_client: Arc, now: DateTime, ) -> Result { let validation_config = OidcValidationConfig::builder() @@ -136,11 +129,6 @@ pub async fn create_machine_identity_from_oidc_token( let normalized_claims = normalize_platform_claims(&config.platform, &claims)?; - let _timestamp = timestamp_client - .timestamp(token.as_bytes(), &TimestampConfig::default()) - .await - .ok(); - Ok(OidcMachineIdentity { platform: config.platform, subject, @@ -184,6 +172,23 @@ fn normalize_platform_claims( }) } +impl From<&OidcMachineIdentity> for OidcBinding { + /// The one conversion from a validated machine identity to the + /// signature-covered wire binding — every signing path uses this, so + /// the two shapes can never drift. + fn from(mi: &OidcMachineIdentity) -> Self { + OidcBinding { + issuer: mi.issuer.clone(), + subject: mi.subject.clone(), + audience: mi.audience.clone(), + token_exp: mi.token_exp, + platform: Some(mi.platform.clone()), + jti: mi.jti.clone(), + normalized_claims: Some(mi.normalized_claims.clone()), + } + } +} + /// Parameters for signing a commit with an identity. /// /// Args: @@ -254,15 +259,7 @@ pub fn sign_commit_with_identity( let device_pk = auths_verifier::DevicePublicKey::ed25519(device_public_key); - let oidc_binding = params.oidc_binding.as_ref().map(|mi| OidcBinding { - issuer: mi.issuer.clone(), - subject: mi.subject.clone(), - audience: mi.audience.clone(), - token_exp: mi.token_exp, - platform: Some(mi.platform.clone()), - jti: mi.jti.clone(), - normalized_claims: Some(mi.normalized_claims.clone()), - }); + let oidc_binding = params.oidc_binding.as_ref().map(OidcBinding::from); let rid = format!("auths/commits/{}", params.commit_sha); @@ -382,15 +379,7 @@ mod tests { normalized_claims: claims, }; - let binding = OidcBinding { - issuer: machine_id.issuer.clone(), - subject: machine_id.subject.clone(), - audience: machine_id.audience.clone(), - token_exp: machine_id.token_exp, - platform: Some(machine_id.platform.clone()), - jti: machine_id.jti.clone(), - normalized_claims: Some(machine_id.normalized_claims.clone()), - }; + let binding = OidcBinding::from(&machine_id); assert_eq!( binding.issuer, diff --git a/crates/auths-sdk/src/workflows/compliance.rs b/crates/auths-sdk/src/workflows/compliance.rs index ea58b917..4316adbd 100644 --- a/crates/auths-sdk/src/workflows/compliance.rs +++ b/crates/auths-sdk/src/workflows/compliance.rs @@ -5,8 +5,8 @@ //! the CLI and other presentation layers, mirroring [`crate::workflows::org`]. pub use crate::domains::compliance::dsse::{ - DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature, sign_evidence_pack, - sign_framework_report, + DSSE_INTOTO_PAYLOAD_TYPE, DsseEnvelope, DsseSignature, VerifiedEvidencePack, + sign_evidence_pack, sign_framework_report, verify_signed_evidence_pack_offline, }; pub use crate::domains::compliance::frameworks::{ FrameworkReport, RowTimeliness, SignerVerifierAllowList, VsaParams, build_framework_report, @@ -17,3 +17,7 @@ pub use crate::domains::compliance::query::{ EvidenceRow, ReleaseRecord, RowVerdict, TransparencyInclusion, build_evidence_pack, build_offline_evidence_pack, load_witness_policy, verify_evidence_pack_offline, }; +pub use crate::domains::compliance::releases::{ + AnchoredRelease, ArtifactDigest, ReleaseAttestation, ReleaseAttestationKind, attest_release, + discover_releases, +}; diff --git a/crates/auths-sdk/tests/cases/compliance_query.rs b/crates/auths-sdk/tests/cases/compliance_query.rs index 29ae4cea..f76f7b12 100644 --- a/crates/auths-sdk/tests/cases/compliance_query.rs +++ b/crates/auths-sdk/tests/cases/compliance_query.rs @@ -10,7 +10,9 @@ use auths_core::testing::IsolatedKeychainHandle; use auths_crypto::CurveType; use auths_id::keri::types::Prefix; use auths_sdk::context::AuthsContext; -use auths_sdk::domains::compliance::dsse::{sign_evidence_pack, sign_framework_report}; +use auths_sdk::domains::compliance::dsse::{ + sign_evidence_pack, sign_framework_report, verify_signed_evidence_pack_offline, +}; use auths_sdk::domains::compliance::frameworks::{ SPDX_VERSION, SignerVerifierAllowList, VsaParams, build_framework_report, sbom_document_sha256, }; @@ -331,6 +333,104 @@ fn dsse_sign_and_verify_round_trips() { ); } +#[test] +fn signed_offline_pack_verifies_end_to_end_from_envelope_and_roots_alone() { + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let member = add_test_member(&ctx, &org_prefix, &org_alias, "judy"); + + // Revoke the member so the pack carries an honestly-damned row: the pack + // must still verify as AUTHENTIC (the log telling the truth is the system + // working), with the row's authority re-derivation consistent. + let signed = revoke_member( + &ctx, + &org_prefix, + &org_alias, + &format!("did:keri:{}", member.as_str()), + None, + ) + .expect("revoke") + .expect("record"); + let revoked_at = signed.record.revoked_at_seq; + + let releases = vec![ + ReleaseRecord { + artifact_digest: "sha256:gg".into(), + signer_prefix: member.clone(), + signed_at: Some(revoked_at - 1), + transparency: None, + }, + ReleaseRecord { + artifact_digest: "sha256:hh".into(), + signer_prefix: member, + signed_at: Some(revoked_at), + transparency: None, + }, + ]; + let policy = load_witness_policy(None); + let pack = build_offline_evidence_pack( + &ctx, + org_did.clone(), + &org_prefix, + "2026-Q3", + ComplianceFramework::Slsa, + &releases, + &policy, + now(), + ) + .expect("build offline pack"); + + let (_org_pk, org_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + &org_alias, + ctx.passphrase_provider.as_ref(), + ) + .expect("org pubkey"); + let envelope = sign_evidence_pack(&ctx, org_did.as_str(), &org_alias, org_curve, &pack) + .expect("org-sign the pack"); + let raw = envelope.to_canonical_json().expect("canonical envelope"); + + // The auditor's whole input: the envelope bytes + a pinned root. No + // keychain, no registry, no context. + let verified = verify_signed_evidence_pack_offline(&raw, std::slice::from_ref(&org_did)) + .expect("signed pack verifies offline"); + assert_eq!(verified.verdicts.len(), 2); + assert!( + verified.verdicts.iter().all(|v| v.authority_consistent), + "honest rows re-derive consistently — including the damned one" + ); + assert_eq!( + verified.verdicts[1].authority_at_release, + AuthorityAtSigning::RejectedAfterRevocation { revoked_at }, + "the post-revocation row is damned by the evidence itself" + ); + assert!( + verified.authentic(), + "a pack honestly reporting a damned row is still authentic" + ); + + // Cook the books: mutate one row inside the payload (valid JSON, changed + // bytes) — the DSSE signature over the PAE must refuse. + use base64::Engine; + let engine = base64::engine::general_purpose::STANDARD; + let mut statement: serde_json::Value = + serde_json::from_slice(&engine.decode(envelope.payload.as_bytes()).unwrap()).unwrap(); + statement["predicate"]["rows"][0]["artifact_digest"] = serde_json::json!("sha256:00"); + let mut tampered = envelope.clone(); + tampered.payload = engine.encode(statement.to_string().as_bytes()); + let raw_tampered = tampered.to_canonical_json().unwrap(); + assert!( + verify_signed_evidence_pack_offline(&raw_tampered, std::slice::from_ref(&org_did)).is_err(), + "a tampered payload must fail the DSSE signature check" + ); + + // An auditor who pinned a different root rejects the whole pack. + let stranger = IdentityDID::new_unchecked("did:keri:EStranger".to_string()); + assert!( + verify_signed_evidence_pack_offline(&raw, &[stranger]).is_err(), + "an unpinned org must fail closed" + ); +} + /// A fixed presentation-boundary timestamp (the domain never calls `Utc::now`). fn now() -> chrono::DateTime { chrono::DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z") @@ -684,3 +784,126 @@ fn cra_report_maps_obligations_and_ssdf_practices() { assert!(canonical.contains("\"policy_met\":false")); assert!(!canonical.contains("non_equivocation")); } + +// ── Anchored releases: attest at signing time, discover at report time ────── + +use auths_sdk::domains::compliance::releases::{ArtifactDigest, attest_release, discover_releases}; + +/// A parsed `sha256:` digest of one repeated hex character (test fixture). +fn digest_of(c: char) -> ArtifactDigest { + ArtifactDigest::parse(&format!("sha256:{}", c.to_string().repeat(64))).expect("valid digest") +} + +#[test] +fn attest_then_discover_derives_signed_at_from_the_org_kel() { + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let member = add_test_member(&ctx, &org_prefix, &org_alias, "judy"); + + let d1 = digest_of('a'); + let d2 = digest_of('b'); + let a1 = attest_release(&ctx, &org_prefix, &org_alias, d1.clone(), member.clone()) + .expect("attest release 1"); + let a2 = attest_release(&ctx, &org_prefix, &org_alias, d2.clone(), member.clone()) + .expect("attest release 2"); + assert!( + a2.signed_at > a1.signed_at, + "each attestation anchors at a strictly later KEL position" + ); + + // add_test_member anchored a membership attestation in the same KEL — only + // the release attestations may be discovered. + let records = discover_releases(ctx.registry.as_ref(), &org_prefix).expect("discover"); + assert_eq!(records.len(), 2, "exactly the attested releases discovered"); + assert_eq!(records[0].artifact_digest, d1.as_str()); + assert_eq!(records[0].signer_prefix, member); + assert_eq!( + records[0].signed_at, + Some(a1.signed_at), + "signed_at IS the anchoring position, never caller input" + ); + assert_eq!(records[1].artifact_digest, d2.as_str()); + assert_eq!(records[1].signed_at, Some(a2.signed_at)); + + // The discovered rows classify as authorized — the member was live at both + // anchored positions. + let policy = load_witness_policy(None); + let pack = build_evidence_pack( + &ctx, + org_did, + &org_prefix, + "2026-Q3", + ComplianceFramework::Slsa, + &records, + &policy, + now(), + ) + .expect("build pack from discovered releases"); + assert!( + pack.rows + .iter() + .all(|r| r.authority_at_release == AuthorityAtSigning::AuthorizedBeforeRevocation) + ); +} + +#[test] +fn release_attested_after_revocation_is_damned_by_discovery() { + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let member = add_test_member(&ctx, &org_prefix, &org_alias, "kevin"); + + let before = attest_release( + &ctx, + &org_prefix, + &org_alias, + digest_of('c'), + member.clone(), + ) + .expect("attest before revocation"); + + let signed = revoke_member( + &ctx, + &org_prefix, + &org_alias, + &format!("did:keri:{}", member.as_str()), + None, + ) + .expect("revoke") + .expect("fresh revoke writes a record"); + let revoked_at = signed.record.revoked_at_seq; + assert!(before.signed_at < revoked_at); + + let after = attest_release( + &ctx, + &org_prefix, + &org_alias, + digest_of('d'), + member.clone(), + ) + .expect("attest after revocation"); + assert!(after.signed_at >= revoked_at); + + let records = discover_releases(ctx.registry.as_ref(), &org_prefix).expect("discover"); + assert_eq!(records.len(), 2); + + let policy = load_witness_policy(None); + let pack = build_evidence_pack( + &ctx, + org_did, + &org_prefix, + "2026-Q3", + ComplianceFramework::Slsa, + &records, + &policy, + now(), + ) + .expect("build pack"); + assert_eq!( + pack.rows[0].authority_at_release, + AuthorityAtSigning::AuthorizedBeforeRevocation, + "the pre-revocation release stays authorized at its anchored position" + ); + assert_eq!( + pack.rows[1].authority_at_release, + AuthorityAtSigning::RejectedAfterRevocation { revoked_at }, + "a release anchored after revocation is damned by the log itself" + ); +} diff --git a/crates/auths-sdk/tests/cases/ephemeral_signing.rs b/crates/auths-sdk/tests/cases/ephemeral_signing.rs index 2e9066a8..76eb5950 100644 --- a/crates/auths-sdk/tests/cases/ephemeral_signing.rs +++ b/crates/auths-sdk/tests/cases/ephemeral_signing.rs @@ -1,4 +1,4 @@ -use auths_sdk::domains::signing::service::sign_artifact_ephemeral; +use auths_sdk::domains::signing::service::{EphemeralSignRequest, sign_artifact_ephemeral}; use auths_verifier::core::{Attestation, SignerType}; use chrono::Utc; @@ -8,12 +8,15 @@ const VALID_SHA: &str = "abc123def456abc123def456abc123def456abc1"; fn produces_valid_attestation_with_did_key_issuer() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"test data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -36,12 +39,15 @@ fn produces_valid_attestation_with_did_key_issuer() { fn signer_type_is_workload() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"test data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -53,12 +59,15 @@ fn signer_type_is_workload() { fn commit_sha_is_present() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"test data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -70,22 +79,28 @@ fn commit_sha_is_present() { fn each_call_uses_different_ephemeral_key() { let r1 = sign_artifact_ephemeral( Utc::now(), - b"data1", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"data1", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); let r2 = sign_artifact_ephemeral( Utc::now(), - b"data2", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"data2", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -108,12 +123,15 @@ fn ci_environment_in_payload() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - Some("test.tar.gz".into()), - VALID_SHA.into(), - None, - None, - Some(ci_env), + EphemeralSignRequest { + data: b"test data", + artifact_name: Some("test.tar.gz".into()), + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: Some(ci_env), + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -128,8 +146,19 @@ fn ci_environment_in_payload() { #[test] fn empty_data_produces_valid_attestation() { - let result = sign_artifact_ephemeral(Utc::now(), b"", None, VALID_SHA.into(), None, None, None) - .expect("empty data should still produce valid attestation"); + let result = sign_artifact_ephemeral( + Utc::now(), + EphemeralSignRequest { + data: b"", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, + ) + .expect("empty data should still produce valid attestation"); let att: Attestation = serde_json::from_str(&result.attestation_json).unwrap(); assert!(att.issuer.as_str().starts_with("did:key:z")); @@ -139,12 +168,15 @@ fn empty_data_produces_valid_attestation() { fn invalid_commit_sha_rejected() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - None, - "not-a-valid-sha".into(), - None, - None, - None, + EphemeralSignRequest { + data: b"test data", + artifact_name: None, + commit_sha: "not-a-valid-sha".into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ); assert!(result.is_err(), "invalid commit SHA should be rejected"); @@ -160,12 +192,15 @@ fn invalid_commit_sha_rejected() { fn tamper_commit_sha_breaks_signature() { let result = sign_artifact_ephemeral( Utc::now(), - b"test data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"test data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -203,12 +238,15 @@ fn tamper_commit_sha_breaks_signature() { fn tamper_artifact_fails_digest_check() { let result = sign_artifact_ephemeral( Utc::now(), - b"original artifact data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"original artifact data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -229,12 +267,15 @@ fn ephemeral_key_collision_check() { for _ in 0..100 { let r = sign_artifact_ephemeral( Utc::now(), - b"data", - None, - VALID_SHA.into(), - None, - None, - None, + EphemeralSignRequest { + data: b"data", + artifact_name: None, + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); let att: Attestation = serde_json::from_str(&r.attestation_json).unwrap(); @@ -249,3 +290,86 @@ fn ephemeral_key_collision_check() { } use sha2::Digest; + +/// A verified OIDC binding rides in the SIGNED envelope: the attestation +/// verifies as-signed, and any tampering with the binding's claims breaks +/// the signature. This is what makes the verify-time policy join (org +/// policy ⋈ binding) trustworthy. +#[test] +fn oidc_binding_is_signature_covered() { + use auths_verifier::core::OidcBinding; + + let mut claims = serde_json::Map::new(); + claims.insert("repository".to_string(), "acme/widget".into()); + claims.insert( + "workflow_ref".to_string(), + "acme/widget/.github/workflows/release.yml@refs/tags/v1.0".into(), + ); + let binding = OidcBinding { + issuer: "https://token.actions.githubusercontent.com".to_string(), + subject: "repo:acme/widget:ref:refs/tags/v1.0".to_string(), + audience: "https://github.com/acme".to_string(), + token_exp: 4_102_444_800, + platform: Some("github".to_string()), + jti: Some("jti-1".to_string()), + normalized_claims: Some(claims), + }; + + let result = sign_artifact_ephemeral( + Utc::now(), + EphemeralSignRequest { + data: b"release bytes", + artifact_name: Some("release.tar.gz".into()), + commit_sha: VALID_SHA.into(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: Some(binding), + }, + ) + .expect("signing with binding should succeed"); + + let att: Attestation = serde_json::from_str(&result.attestation_json).unwrap(); + let bound = att + .oidc_binding + .clone() + .expect("binding should be embedded"); + assert_eq!(bound.subject, "repo:acme/widget:ref:refs/tags/v1.0"); + + let (pk_bytes, curve): (Vec, auths_crypto::CurveType) = + match auths_crypto::did_key_decode(att.issuer.as_str()).expect("did:key resolves") { + auths_crypto::DecodedDidKey::Ed25519(k) => { + (k.to_vec(), auths_crypto::CurveType::Ed25519) + } + auths_crypto::DecodedDidKey::P256(k) => (k, auths_crypto::CurveType::P256), + }; + let pk = + auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes).expect("valid device pubkey"); + + let rt = tokio::runtime::Runtime::new().unwrap(); + + // As-signed: the chain verifies. + let report = rt + .block_on(auths_verifier::verify_chain( + std::slice::from_ref(&att), + &pk, + )) + .expect("verification should run"); + assert!( + report.is_valid(), + "binding-carrying attestation must verify" + ); + + // Tampered binding: swap the repository claim — the signature must break. + let mut tampered = att; + let b = tampered.oidc_binding.as_mut().unwrap(); + b.normalized_claims + .as_mut() + .unwrap() + .insert("repository".to_string(), "attacker/fork".into()); + let report = rt.block_on(auths_verifier::verify_chain(&[tampered], &pk)); + // An Err (signature mismatch) is equally fail-closed. + if let Ok(r) = report { + assert!(!r.is_valid(), "tampered binding must not verify"); + } +} diff --git a/crates/auths-verifier/src/core.rs b/crates/auths-verifier/src/core.rs index 11bb06a3..255e127a 100644 --- a/crates/auths-verifier/src/core.rs +++ b/crates/auths-verifier/src/core.rs @@ -1159,6 +1159,12 @@ pub struct CanonicalAttestationData<'a> { /// Git commit SHA for provenance binding (included in signed envelope). #[serde(skip_serializing_if = "Option::is_none")] pub commit_sha: Option<&'a str>, + /// OIDC workload binding (included in signed envelope when present, so a + /// verifier can trust the claims it joins against an org policy). + /// `skip_serializing_if` keeps canonical bytes for binding-less + /// attestations byte-identical to the pre-binding shape. + #[serde(skip_serializing_if = "Option::is_none")] + pub oidc_binding: Option<&'a OidcBinding>, } /// Produce the canonical JSON bytes over which signatures are computed. @@ -1224,6 +1230,7 @@ impl Attestation { delegated_by: self.delegated_by.as_ref(), signer_type: self.signer_type.as_ref(), commit_sha: self.commit_sha.as_deref(), + oidc_binding: self.oidc_binding.as_ref(), } } diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 414cb046..0911c848 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -61,6 +61,8 @@ pub mod error; /// C-compatible FFI bindings for attestation and chain verification. #[cfg(feature = "ffi")] pub mod ffi; +/// OIDC-subject policy and the verify-time join for keyless CI signing. +pub mod oidc_policy; pub mod presentation; mod software_verify; pub mod ssh_sig; @@ -93,6 +95,9 @@ pub use core::{ decode_public_key_bytes, decode_public_key_hex, }; +// Re-export the OIDC policy join (keyless CI verify-time exchange) +pub use oidc_policy::{OidcPolicyError, OidcPolicyJoin, OidcSubjectPolicy}; + // Re-export test utilities #[cfg(any(test, feature = "test-utils"))] pub use testing::AttestationBuilder; diff --git a/crates/auths-verifier/src/oidc_policy.rs b/crates/auths-verifier/src/oidc_policy.rs new file mode 100644 index 00000000..324eec0a --- /dev/null +++ b/crates/auths-verifier/src/oidc_policy.rs @@ -0,0 +1,318 @@ +//! OIDC-subject policy — the org's side of the keyless CI exchange. +//! +//! An org that wants keyless CI signing never hands a key to the runner. +//! Instead it states, ahead of time, WHICH workload identity it trusts to +//! sign: the OIDC issuer, the repository, and (optionally) the exact +//! workflow. At verify time the verifier JOINS the artifact's signed +//! [`OidcBinding`](crate::core::OidcBinding) — the claims the signer +//! validated against the issuer's JWKS while the token was live — against +//! that policy. The org key is never reachable from CI; the policy is the +//! delegation. +//! +//! Parse, don't validate: [`OidcSubjectPolicy::parse`] is the only +//! constructor, so a policy in hand is always well-formed (non-empty issuer +//! and repository). The join is fail-closed: a missing binding, missing +//! claim, or any mismatch is an error, never a pass. + +use serde::{Deserialize, Serialize}; + +use crate::core::OidcBinding; + +/// The OIDC workload identity an org trusts to sign its artifacts. +/// +/// Wire form (JSON): +/// ```json +/// { +/// "issuer": "https://token.actions.githubusercontent.com", +/// "repository": "acme/widget", +/// "workflow_ref": "acme/widget/.github/workflows/release.yml" +/// } +/// ``` +/// +/// `workflow_ref` may pin the exact ref (`...release.yml@refs/tags/v1.0`) or +/// just the workflow path (no `@`), which then matches any ref of that file. +/// Omitting it trusts every workflow in the repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OidcSubjectPolicy { + /// The OIDC issuer the org trusts (exact match). + issuer: String, + /// The repository whose workloads may sign (exact match). + repository: String, + /// Optional workflow pin — exact `path@ref`, or `path` to allow any ref. + #[serde(default, skip_serializing_if = "Option::is_none")] + workflow_ref: Option, +} + +/// Why a policy failed to parse or a binding failed to join. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum OidcPolicyError { + /// The policy JSON did not parse or had empty required fields. + #[error("invalid OIDC policy: {0}")] + InvalidPolicy(String), + /// The attestation carries no OIDC binding to join. + #[error("attestation carries no OIDC binding — signer presented no verified OIDC identity")] + MissingBinding, + /// The binding lacks a claim the policy constrains. + #[error("OIDC binding lacks the '{0}' claim the policy requires")] + MissingClaim(&'static str), + /// A claim did not match the policy. + #[error("OIDC {claim} mismatch: policy trusts '{expected}', binding presented '{got}'")] + Mismatch { + /// Which claim mismatched. + claim: &'static str, + /// What the policy trusts. + expected: String, + /// What the binding presented. + got: String, + }, +} + +/// Proof that a binding satisfied a policy — only [`OidcSubjectPolicy::join`] +/// constructs it, so holding one means the join passed. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct OidcPolicyJoin { + /// The issuer both sides agreed on. + pub issuer: String, + /// The repository both sides agreed on. + pub repository: String, + /// The workflow_ref the binding presented (when the policy pinned one). + #[serde(skip_serializing_if = "Option::is_none")] + pub workflow_ref: Option, + /// The token subject, for audit display. + pub subject: String, +} + +impl OidcSubjectPolicy { + /// Parse a policy from its JSON wire form. The only constructor. + pub fn parse(json: &str) -> Result { + let policy: Self = serde_json::from_str(json) + .map_err(|e| OidcPolicyError::InvalidPolicy(e.to_string()))?; + if policy.issuer.trim().is_empty() { + return Err(OidcPolicyError::InvalidPolicy("issuer is empty".into())); + } + if policy.repository.trim().is_empty() { + return Err(OidcPolicyError::InvalidPolicy("repository is empty".into())); + } + if let Some(wf) = &policy.workflow_ref + && wf.trim().is_empty() + { + return Err(OidcPolicyError::InvalidPolicy( + "workflow_ref is empty".into(), + )); + } + Ok(policy) + } + + /// The issuer this policy trusts. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// The repository this policy trusts. + pub fn repository(&self) -> &str { + &self.repository + } + + /// JOIN a signed OIDC binding against this policy, fail-closed. + /// + /// Checks, in order: issuer (exact), repository (exact, from the + /// platform-normalized claims), and — when the policy pins one — + /// workflow_ref (exact `path@ref`, or path-only when the policy value + /// carries no `@`). + /// + /// Args: + /// * `binding`: The signature-covered OIDC binding from a verified + /// attestation. Callers must only pass bindings from attestations whose + /// signatures verified — the join trusts the claims, not the envelope. + pub fn join(&self, binding: &OidcBinding) -> Result { + if binding.issuer != self.issuer { + return Err(OidcPolicyError::Mismatch { + claim: "issuer", + expected: self.issuer.clone(), + got: binding.issuer.clone(), + }); + } + + let claims = binding + .normalized_claims + .as_ref() + .ok_or(OidcPolicyError::MissingClaim("repository"))?; + + let repository = claims + .get("repository") + .and_then(|v| v.as_str()) + .ok_or(OidcPolicyError::MissingClaim("repository"))?; + if repository != self.repository { + return Err(OidcPolicyError::Mismatch { + claim: "repository", + expected: self.repository.clone(), + got: repository.to_string(), + }); + } + + let mut joined_workflow_ref = None; + if let Some(pinned) = &self.workflow_ref { + let presented = claims + .get("workflow_ref") + .and_then(|v| v.as_str()) + .ok_or(OidcPolicyError::MissingClaim("workflow_ref"))?; + let presented_path = presented.split('@').next().unwrap_or(presented); + let matches = if pinned.contains('@') { + presented == pinned + } else { + presented_path == pinned + }; + if !matches { + return Err(OidcPolicyError::Mismatch { + claim: "workflow_ref", + expected: pinned.clone(), + got: presented.to_string(), + }); + } + joined_workflow_ref = Some(presented.to_string()); + } + + Ok(OidcPolicyJoin { + issuer: self.issuer.clone(), + repository: self.repository.clone(), + workflow_ref: joined_workflow_ref, + subject: binding.subject.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GH_ISSUER: &str = "https://token.actions.githubusercontent.com"; + + fn binding(claims: serde_json::Value) -> OidcBinding { + let map = match claims { + serde_json::Value::Object(m) => m, + _ => panic!("claims must be an object"), + }; + OidcBinding { + issuer: GH_ISSUER.to_string(), + subject: "repo:acme/widget:ref:refs/tags/v1.0".to_string(), + audience: "https://github.com/acme".to_string(), + token_exp: 4_102_444_800, + platform: Some("github".to_string()), + jti: Some("jti-1".to_string()), + normalized_claims: Some(map), + } + } + + fn policy(workflow_ref: Option<&str>) -> OidcSubjectPolicy { + let mut v = serde_json::json!({ + "issuer": GH_ISSUER, + "repository": "acme/widget", + }); + if let Some(wf) = workflow_ref { + v["workflow_ref"] = serde_json::Value::String(wf.to_string()); + } + OidcSubjectPolicy::parse(&v.to_string()).expect("valid policy") + } + + #[test] + fn parse_rejects_empty_issuer() { + let err = OidcSubjectPolicy::parse(r#"{"issuer":" ","repository":"acme/widget"}"#) + .expect_err("empty issuer must not parse"); + assert!(matches!(err, OidcPolicyError::InvalidPolicy(_))); + } + + #[test] + fn parse_rejects_unknown_fields() { + let err = OidcSubjectPolicy::parse(r#"{"issuer":"i","repository":"r","extra":"smuggled"}"#) + .expect_err("unknown fields must not parse"); + assert!(matches!(err, OidcPolicyError::InvalidPolicy(_))); + } + + #[test] + fn join_passes_on_issuer_and_repository() { + let b = binding(serde_json::json!({ "repository": "acme/widget" })); + let join = policy(None).join(&b).expect("join must pass"); + assert_eq!(join.repository, "acme/widget"); + assert_eq!(join.subject, "repo:acme/widget:ref:refs/tags/v1.0"); + } + + #[test] + fn join_fails_closed_on_issuer_mismatch() { + let mut b = binding(serde_json::json!({ "repository": "acme/widget" })); + b.issuer = "https://evil.example".to_string(); + let err = policy(None).join(&b).expect_err("must reject"); + assert!(matches!( + err, + OidcPolicyError::Mismatch { + claim: "issuer", + .. + } + )); + } + + #[test] + fn join_fails_closed_on_repository_mismatch() { + let b = binding(serde_json::json!({ "repository": "attacker/fork" })); + let err = policy(None).join(&b).expect_err("must reject"); + assert!(matches!( + err, + OidcPolicyError::Mismatch { + claim: "repository", + .. + } + )); + } + + #[test] + fn join_fails_closed_on_missing_claims() { + let mut b = binding(serde_json::json!({ "repository": "acme/widget" })); + b.normalized_claims = None; + let err = policy(None).join(&b).expect_err("must reject"); + assert_eq!(err, OidcPolicyError::MissingClaim("repository")); + } + + #[test] + fn workflow_path_pin_matches_any_ref() { + let b = binding(serde_json::json!({ + "repository": "acme/widget", + "workflow_ref": "acme/widget/.github/workflows/release.yml@refs/tags/v1.0", + })); + let join = policy(Some("acme/widget/.github/workflows/release.yml")) + .join(&b) + .expect("path pin matches any ref"); + assert_eq!( + join.workflow_ref.as_deref(), + Some("acme/widget/.github/workflows/release.yml@refs/tags/v1.0") + ); + } + + #[test] + fn workflow_exact_pin_requires_exact_ref() { + let b = binding(serde_json::json!({ + "repository": "acme/widget", + "workflow_ref": "acme/widget/.github/workflows/release.yml@refs/heads/main", + })); + let err = policy(Some( + "acme/widget/.github/workflows/release.yml@refs/tags/v1.0", + )) + .join(&b) + .expect_err("exact pin must reject other refs"); + assert!(matches!( + err, + OidcPolicyError::Mismatch { + claim: "workflow_ref", + .. + } + )); + } + + #[test] + fn workflow_pin_fails_closed_when_binding_lacks_workflow_ref() { + let b = binding(serde_json::json!({ "repository": "acme/widget" })); + let err = policy(Some("acme/widget/.github/workflows/release.yml")) + .join(&b) + .expect_err("must reject"); + assert_eq!(err, OidcPolicyError::MissingClaim("workflow_ref")); + } +} diff --git a/docs/plans/blockers/fixes.md b/docs/plans/blockers/fixes.md new file mode 100644 index 00000000..9633c4fd --- /dev/null +++ b/docs/plans/blockers/fixes.md @@ -0,0 +1,316 @@ +# Platform blockers: missing pieces between the vision and the code + +**Provenance.** These findings come from an adversarial planning review of Demo 3 +("Drop the Laptop in the River", `auths-demos/.flow/specs/fn-7.md`), 2026-06-11, +verified against platform source at rev `861c430f`. Every claim below was checked +against the code, not doc comments. Line numbers are accurate at that rev; re-grep +for the quoted snippets if the files have moved. + +**How to use this document.** Each item is self-contained: problem → why it +matters in plain language → evidence (file:line + snippet) → suggested change → +acceptance criteria. An agent can pick up any single item and build it without +reading the others, except where a dependency is called out explicitly. + +**Severity legend.** +- 🔴 **Breaks a core product promise** — the headline KERI story does not survive this gap. +- 🟡 **Blocks a major workflow** — feature exists in the vision/docs but cannot be exercised. +- 🟢 **Sharp edge** — works, but silently misleads or fails unsafely. + +| # | Item | Severity | +|---|------|----------| +| 1A | Pre-committed next key has no escrow path | 🔴 | +| 1B | Phone-driven shared-KEL rotation blocked on CESR indexed signatures | 🟡 | +| 1C | LAN device recovery unsupported | 🟡 | +| 2A | Root rotation retroactively invalidates all prior commit history | 🔴 | +| 2B | Root signers skip revocation/rotation ordering entirely | 🔴 (same fix as 2A) | +| 3A | `Auths-Anchor-Seq` is self-asserted and forgeable | 🔴 | +| 3B | Stale trailer file silently mis-orders honest commits | 🟢 | +| 4A | `auths pair --offline` cannot complete a pairing | 🟡 | +| 4B | No loopback bind for the pairing daemon | 🟢 | +| 4C | Paired devices are islands — no post-pairing KEL sync channel | 🟡 | +| 4D | Cross-machine registry propagation: nothing pushes | 🟡 | +| 5A | Auth challenges can be signed but not verified offline | 🟡 | +| 6A | Mobile FFI has no KEL read surface | 🟡 | +| 6B | `build-xcframework.sh` swallows per-target build failures | 🟢 | + +--- + +## Theme 1 — Key lifecycle & recovery + +The product's signature story is "lose a device, lose nothing: rotate and move on." +Today, every path that would make that true is missing or blocked. + +### 1A. The pre-committed next key has no escrow path — losing the device loses the next key too 🔴 + +**Problem.** KERI's recovery guarantee rests on pre-rotation: at inception you +commit to the *digest* of your next key, and the next private key is supposed to +live somewhere safer than the device that signs daily. In Auths, the next key is +generated and stored **in the same keychain file as the current key**, and rotation +can only read it from that local keychain: + +`crates/auths-sdk/src/domains/identity/rotation.rs:375-392` +```rust +fn retrieve_precommitted_key( + did: &IdentityDID, + current_alias: &KeyAlias, + state: &KeyState, + ctx: &AuthsContext, +) -> Result<(Zeroizing>, KeyAlias), RotationError> { + let target_alias = KeyAlias::new_unchecked(format!( + "{}--next-{}", + current_alias, state.last_establishment_sequence + )); + + let (did_check, _role, encrypted_next) = + ctx.key_storage.load_key(&target_alias).map_err(|e| { + RotationError::KeyNotFound(format!( + "pre-committed next key '{}' not found: {e}", + target_alias + )) + })?; +``` + +So if the laptop (keychain and all) is destroyed, **rotation is impossible from any +machine** — `auths id rotate` on a fresh machine fails with `KeyNotFound` even when +it can read the shared registry. The same applies to `auths device remove`, which is +single-author and needs the root's signing key +(`crates/auths-cli/src/commands/device/authorization.rs:306-309`): + +```rust +DeviceSubcommand::Remove { device_did, key } => { + // Remove = revoke the device's KERI delegation: the root anchors a + // revocation marker so verifiers stop honouring the device. Single- + // author (the root's key signs); the device's key is not needed. +``` + +**Layman implication.** The pitch is "your identity survives the death of your +laptop." The reality is: if your laptop dies, the key that would rescue your +identity died with it. Recovery today means "you had a full backup of the +keychain," which is no better than the systems Auths claims to replace. + +**Suggested change.** Add a **recovery-kit export/import** flow: + +1. `auths id create` (and every rotation) offers/performs an export of *only* the + encrypted pre-committed next key: `auths key export-next --out recovery-kit.enc` + (contents: the `--next-` keychain entry + DID + sequence metadata, + passphrase-encrypted; printable as a QR for paper backup). +2. `auths id rotate --recovery-kit recovery-kit.enc` on any machine: imports the + next key into the local keychain under the expected alias, then proceeds through + the existing `retrieve_precommitted_key` path unchanged. +3. In `retrieve_precommitted_key`, on `KeyNotFound`, return a purposeful error that + names the recovery-kit path (today the error is a bare "not found"). + +The minimal seam: a new function in +`crates/auths-sdk/src/domains/identity/rotation.rs` that hydrates the keychain +from a kit file before `resolve_rotation_context` runs, plus CLI surface in +`crates/auths-cli/src/commands/id/identity.rs` (`IdSubcommand::Rotate` arm, around +line 514) and a new `key export-next` subcommand. + +**Acceptance.** +- A fresh machine with only `recovery-kit.enc` + the shared registry can run + `auths id rotate --recovery-kit …` successfully; the resulting `rot` validates. +- The kit file never contains the *current* signing key. +- `auths id rotate` on a machine without the next key fails with an error that + names the recovery-kit mechanism. + +### 1B. Phone-driven shared-KEL rotation is scaffolding, blocked on CESR indexed signatures 🟡 + +**Problem.** The vision ("from the phone, rotate the identity's keys") is +explicitly stubbed: + +`crates/auths-mobile-ffi/src/shared_kel_context.rs:14-20` +```text +**Status**: scaffolding only. End-to-end shared-KEL rotation is +blocked on CESR indexed-signature support in `auths-keri::validate` +(the validator rejects asymmetric rotations today). Callers that +invoke `build_shared_kel_rot_payload` during Stage-1 development +receive `MobileError::PairingFailed(…)` with a clear message; +``` + +**Layman implication.** The most dramatic recovery story — your phone, the +surviving device, presses one button and rotates the compromised identity — cannot +happen. The phone can pair and sign, but it cannot change the identity's keys. + +**Suggested change.** Two pieces, in order: +1. **CESR indexed-signature support in `auths-keri::validate`** so a rotation event + signed by one of N controllers (an indexed signature) validates. This is the + named blocker; search `crates/auths-keri/src/` for the validation path that + rejects asymmetric rotations. +2. **A daemon endpoint to receive the signed `rot`** — the phone has no write path + to the registry (see 4C). Add a `POST /kel/events` route to + `crates/auths-pairing-daemon/src/router.rs` that validates the event against the + current KEL and appends it to the registry. + +Then unstub `build_shared_kel_rot_payload` / `assemble_shared_kel_rot` (the +two-step SE signing dance is already designed; only the validator and transport are +missing). + +**Acceptance.** A paired controller device can produce a `rot` via the FFI +two-step, POST it, and `auths id show` / KEL replay reflects the rotation; the +validator accepts the indexed signature. + +### 1C. LAN device recovery is a hard error 🟡 + +**Problem.** `crates/auths-cli/src/commands/device/pair/lan.rs:47-54` +```rust +if let Some(ref old_did) = recover { + let _ = old_did; + return Err(anyhow::anyhow!( + "Device recovery (--recover) over LAN is not yet supported. Use online recovery \ + (`auths pair --recover --registry `), or remove the lost device with \ + `auths device remove ` and pair a replacement." + )); +} +``` + +**Layman implication.** Replacing a lost phone requires a registry *server* — +the offline-first, no-server promise breaks exactly when the user is most +stressed (they just lost a device). + +**Suggested change.** The LAN path already carries `recovery_target` in +`CreateSessionRequest` (lan.rs:91 populates it). Implement the +pair-replacement-then-revoke flow over the LAN session: on successful pairing +where `recovery_target` is set, anchor the new device's delegation *and* the old +device's revocation in one sequence (reuse +`auths_sdk::domains::device::remove_device` after the pairing attestation lands). + +**Acceptance.** `auths pair --recover ` (no `--registry`) completes over +LAN; `auths device list` shows the old device revoked and the new device active. + +--- + +## Theme 2 — Verification semantics: history must survive rotation + +### 2A. Rotating a root identity retroactively invalidates its entire commit history 🔴 + +**Problem.** The KERI promise is "old commits still verify; the verifier reports +which (now-superseded) key era signed them." The verifier does not do this. It +checks the SSH signature against **only the current key**, and a signature by any +prior establishment key is mapped to a *rejection*: + +`crates/auths-verifier/src/commit_kel.rs:606-619` (current-key-only check) +```rust +let Some(current_cesr) = device_state.current_keys.first() else { … }; +… +match verify_commit_signature( + commit_bytes, + std::slice::from_ref(¤t_pk), // ← only the CURRENT key + provider, + None, +) +``` + +`crates/auths-verifier/src/commit_kel.rs:720-740` — `establishment_keys` is used +only to pick **which rejection** to emit: +```rust +if envelope.public_key != *current_pk + && establishment_keys(device_kel).contains(&envelope.public_key) +{ + return CommitVerdict::SignedBySupersededKey; +} +CommitVerdict::SignerKeyMismatch +``` + +And the CLI treats that as invalid — +`crates/auths-cli/src/commands/verify_commit.rs:635-641`: +```rust +CommitVerdict::SignedBySupersededKey => { + result.ssh_valid = Some(false); + result.error = Some( + "Commit was signed by a superseded device key (the device has since rotated)" + .to_string(), + ); +} +``` + +Because the machine that runs `auths id create` signs **directly as root** +(`crates/auths-sdk/src/domains/identity/local.rs:55-64`; confirmed by +`crates/auths-sdk/src/domains/identity/service.rs:130` — "The CI identity is a +root identity signing directly"), this is the *default* configuration. Rotate +your key once and every commit you ever signed goes red. + +**Layman implication.** The one thing this product exists to fix — "key rotation +is routine, your history is safe" — is inverted. Today, rotation is the apocalypse +the docs say it prevents: it bricks your past instead of your future. + +**Suggested change.** Era-aware verification in `authorize_commit` +(`commit_kel.rs:538-638`). When the signature matches a superseded establishment +key, order the commit's in-band position against the **rotation** position (the +same trick already used for revocations): + +1. Generalize `revocation_position` (`commit_kel.rs:190-198`) with a sibling + `supersession_position(device_kel, signer_key) -> Option` — the sequence + of the `rot`/`drt` event that rotated the signer key away. +2. In `classify_unknown_signer`, when the key is a superseded establishment key: + - `parse_anchor_seq(commit_bytes) < supersession_position` → return + `CommitVerdict::Valid { … }` carrying era metadata (suggested: extend `Valid` + with `key_era: Option` rather than adding a parallel "ValidSuperseded" + verdict, so downstream `is_valid()` callers keep working). + - `>= supersession_position`, or no anchor seq → keep + `SignedBySupersededKey` (now a *meaningful* rejection: "signed by a key after + it was rotated away"). +3. CLI rendering (`verify_commit.rs`): `Valid` with `key_era` prints + "valid (signed under key era N, since rotated)" — green, with the era note. + +Note `classify_unknown_signer` will need `root_kel`/positions threaded in; it +currently receives only `commit_bytes`, `device_kel`, `current_pk` +(`commit_kel.rs:720`). + +**Dependency.** The ordering input is the self-asserted anchor seq — see 3A. Land +2A first (it makes the semantics right against honest signers), but 3A is what +makes it hold against adversaries. + +**Acceptance.** +- A commit signed pre-rotation by a root identity verifies `valid: true` after + one or more rotations, with the era reported. +- A commit signed by the old key *at a position at/after the rotation* still + yields `SignedBySupersededKey`. +- Existing tests in `commit_kel.rs` (`commit_before_revocation_still_valid` etc.) + gain rotation-ordering twins. + +### 2B. Root signers skip revocation/rotation ordering entirely 🔴 (folded into 2A) + +**Problem.** `reject_unauthorized_delegate` returns early for the root: + +`crates/auths-verifier/src/commit_kel.rs:659-662` +```rust +let root_signs_directly = device_prefix == *root_prefix && device_state.delegator.is_none(); +if root_signs_directly { + return None; // ← revocation/ordering checks never run for root signers +} +``` + +So `SignedAfterRevocation` — the precise, ordered rejection — is **unreachable** +for the default (root-signing) configuration; root-key misuse can only ever +surface as the blunt `SignedBySupersededKey`. The fix is the same +supersession-ordering logic as 2A; just ensure it runs on the root path too. + +--- + +## Theme 3 — Revocation ordering security + +### 3A. `Auths-Anchor-Seq` is self-asserted: a thief forges a low number and stays green 🔴 + +**Problem.** The verifier orders a commit against a revocation using a trailer +**the signer writes and signs**: + +`crates/auths-verifier/src/commit_kel.rs:224-234` +```rust +fn parse_anchor_seq(commit_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(commit_bytes).ok()?; + text.lines().find_map(|line| { + let rest = line.trim().strip_prefix(ANCHOR_SEQ_TRAILER)?; + … +``` + +`crates/auths-verifier/src/commit_kel.rs:318-331` +```rust +match (revocation, signing_anchor) { + (None, _) => RevocationOrdering::NotRevoked, + (Some(_), None) => RevocationOrdering::RevokedUnknownPosition, + (Some(rev), Some(sign)) if sign < rev => RevocationOrdering::SignedBefore, // ← attacker picks `sign` + … +``` + +An attacker holding a stolen (later revoked) device key simply stamps +`Auths-Anchor-Seq: 0` on their commit. ` diff --git a/docs/plans/blockers/revolutionary.md b/docs/plans/blockers/revolutionary.md new file mode 100644 index 00000000..64568b6d --- /dev/null +++ b/docs/plans/blockers/revolutionary.md @@ -0,0 +1,69 @@ +# Blockers: what stands between Auths and its revolutionary claims + +**Status:** living document. Written 2026-06-11 against the workspace at that date +(spot-check line numbers before editing — they drift). +**Audience:** an engineer or LLM session picking up any one theme and building the fix. +Each theme is self-contained: the claim, the code reality (with file:line and snippets), +what it costs in the real world if unsolved, what it unlocks once solved, and a concrete +suggested design with acceptance criteria. + +**Context:** Auths' paradigm — *identity is an event log, keys are rotatable entries* — +is sound (KERI). The engineering is high quality. But several headline properties are +currently aspirational: they hold against polite actors, not adversaries, or they hold +in the delegated-device path but not the root path, or they exist as scaffolding. This +document is the gap list. We are pre-launch with zero users (see root `CLAUDE.md`): +no backward-compatibility constraints — rip and replace freely. + +Severity ordering: Theme 1 and 2 are existential to the pitch. Themes 3–5 block the +multi-device story. Themes 6–7 are enablers. + +--- + +## Theme 1 — Revocation ordering is self-asserted (the verifier trusts the attacker) + +### The claim +"If a thief resurrects the laptop and signs something *after* the rotation, verification +flags it — the KEL proves that key's authority had ended." (`auths-demos/demo_vision.md`, +Demo 3.) This is the single most dramatic security property in the pitch. + +### The code reality +A commit's signing position is carried in the `Auths-Anchor-Seq` trailer — written by +the **signer**, covered by the **signer's own signature**, and trusted verbatim by the +verifier. + +`crates/auths-verifier/src/commit_kel.rs:224-234` — the verifier parses the claimed +position straight out of the commit body: + +```rust +fn parse_anchor_seq(commit_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(commit_bytes).ok()?; + text.lines().find_map(|line| { + let rest = line.trim().strip_prefix(ANCHOR_SEQ_TRAILER)?; + rest.trim_start().strip_prefix(':')?.trim().parse::().ok() + }) +} +``` + +`crates/auths-verifier/src/commit_kel.rs:318-331` — ordering against the revocation +uses only that claimed number: + +```rust +fn classify_revocation(signing_anchor: Option, revocation: Option) -> RevocationOrdering { + match (revocation, signing_anchor) { + (None, _) => RevocationOrdering::NotRevoked, + (Some(_), None) => RevocationOrdering::RevokedUnknownPosition, + (Some(rev), Some(sign)) if sign < rev => RevocationOrdering::SignedBefore, // attacker picks `sign` + (Some(rev), Some(sign)) => RevocationOrdering::SignedAfter { signed_at: sign, revoked_at: rev }, + } +} +``` + +A thief holding a stolen (revoked) device key simply stamps `Auths-Anchor-Seq: 0` and +their post-revocation commit classifies `SignedBefore` → checked against the device's +current key (which the thief holds, and which never rotated because the device is dead) +→ **`CommitVerdict::Valid`**. The trailer is inside the signed payload, so the thief's +own signature legitimizes the forged position. + +The honest path stamps the trailer from a *static file* refreshed only when a KEL-advancing +command runs on that machine (`crates/auths-sdk/src/workflows/commit_hooks.rs:195-206`, +`refresh_commit_trailers`) — so the diff --git a/docs/prompts/red_team.md b/docs/prompts/red_team.md index 6ff9d801..93b28f68 100644 --- a/docs/prompts/red_team.md +++ b/docs/prompts/red_team.md @@ -12,7 +12,7 @@ attacked. Your reputation rests on the vulnerability you *missed*, not the one y reported. Write your report here: -/Users/bordumb/workspace/repositories/auths-base/auths/docs/prompts +/Users/bordumb/workspace/repositories/auths-base/auths/docs/plans filename: red_team_{TODAY_DATE}.md --- From 8dabb79a940664309439666326895244f2ee8d85 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 12:01:42 +0100 Subject: [PATCH 03/32] fix: complete oidc_binding field in auths-python and auths-node bindings AttestationInput gained an oidc_binding field and all in-tree call sites were updated, but the two binding-package initializers were missed, leaving the tree uncompilable under the pre-commit hook (E0063). Add oidc_binding: None to match the in-tree default. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- packages/auths-node/src/org.rs | 1 + packages/auths-python/Cargo.lock | 7 +++++++ packages/auths-python/src/org.rs | 1 + 3 files changed, 9 insertions(+) diff --git a/packages/auths-node/src/org.rs b/packages/auths-node/src/org.rs index aee5d2a4..de46b1ec 100644 --- a/packages/auths-node/src/org.rs +++ b/packages/auths-node/src/org.rs @@ -196,6 +196,7 @@ pub fn create_org( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, &provider, diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index a988e0b9..4f2a1b75 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -2061,11 +2061,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", + "rand 0.8.5", + "rsa", "serde", "serde_json", + "sha2", "signature", "simple_asn1", ] diff --git a/packages/auths-python/src/org.rs b/packages/auths-python/src/org.rs index 0d6190e0..3a51c997 100644 --- a/packages/auths-python/src/org.rs +++ b/packages/auths-python/src/org.rs @@ -182,6 +182,7 @@ pub fn create_org( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, &provider, From e1162f185f0ec0ce14ca0d75db6a4df2b0308f39 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 12:08:09 +0100 Subject: [PATCH 04/32] fix(DOTAK-2): multi-capability credentials verify via single-source capability codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue/list/verify carried two divergent grammars for the a.capability claim (comma-joined on issuance, comma-split on list, single-wrapped on verify), so a credential with more than one capability verified as schema_invalid. Add Capability::join_claim/parse_claim — an exact encode/decode inverse pair, the one source of truth — and route all three consumers through it; delete the inline join/split and the verifier's single-wrap. A malformed claim now fails closed to SchemaInvalid rather than fail-wrong. Round-trip and edge-case tests added. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-keri/src/capability.rs | 99 +++++++++++++++++++ .../src/domains/credentials/issue.rs | 14 +-- crates/auths-verifier/src/credential.rs | 22 +++-- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/crates/auths-keri/src/capability.rs b/crates/auths-keri/src/capability.rs index 0f666c2c..0fa55259 100644 --- a/crates/auths-keri/src/capability.rs +++ b/crates/auths-keri/src/capability.rs @@ -196,6 +196,63 @@ impl Capability { pub fn namespace(&self) -> Option<&str> { self.0.split(':').next().filter(|_| self.0.contains(':')) } + + // ======================================================================== + // Capability-claim codec — the single grammar for an ACDC `a.capability` + // ======================================================================== + + /// Separator between capabilities packed into one `a.capability` claim string. + /// + /// A capability identifier can never contain a comma ([`Capability::parse`] + /// only admits alphanumerics, `:`, `-`, `_`), so the comma is an unambiguous + /// delimiter for the multi-capability claim. + const CLAIM_SEPARATOR: char = ','; + + /// Encode a set of capabilities into the single `a.capability` claim string. + /// + /// This is the one source of truth for the on-wire grammar: capabilities are + /// joined by [`Self::CLAIM_SEPARATOR`]. The issuer writes the claim with this; + /// every reader decodes it with [`Self::parse_claim`] — they cannot disagree. + /// + /// # Examples + /// + /// ``` + /// use auths_keri::Capability; + /// let caps = [Capability::parse("fs:read").unwrap(), Capability::parse("fs:write").unwrap()]; + /// assert_eq!(Capability::join_claim(&caps), "fs:read,fs:write"); + /// ``` + pub fn join_claim(capabilities: &[Capability]) -> String { + capabilities + .iter() + .map(Capability::as_str) + .collect::>() + .join(&Self::CLAIM_SEPARATOR.to_string()) + } + + /// Decode an `a.capability` claim string into its capabilities. + /// + /// The inverse of [`Self::join_claim`]: splits on [`Self::CLAIM_SEPARATOR`] and + /// parses each segment. Returns `Err` if any segment is not a valid capability, + /// so an issuer/verifier grammar mismatch fails closed rather than silently + /// admitting a malformed claim. An empty claim yields no capabilities. + /// + /// # Examples + /// + /// ``` + /// use auths_keri::Capability; + /// let caps = Capability::parse_claim("fs:read,fs:write").unwrap(); + /// assert_eq!(caps.len(), 2); + /// assert!(Capability::parse_claim("").unwrap().is_empty()); + /// ``` + pub fn parse_claim(claim: &str) -> Result, CapabilityError> { + if claim.is_empty() { + return Ok(Vec::new()); + } + claim + .split(Self::CLAIM_SEPARATOR) + .map(Capability::parse) + .collect() + } } impl fmt::Display for Capability { @@ -499,6 +556,48 @@ mod tests { assert_eq!(caps[2], Capability::parse("custom-cap").unwrap()); } + // ======================================================================== + // Capability-claim codec tests — issuer and verifier share one grammar + // ======================================================================== + + #[test] + fn claim_codec_roundtrips_multi_capability() { + let caps = vec![ + Capability::parse("fs:read").unwrap(), + Capability::parse("fs:write").unwrap(), + ]; + let claim = Capability::join_claim(&caps); + assert_eq!(claim, "fs:read,fs:write"); + assert_eq!(Capability::parse_claim(&claim).unwrap(), caps); + } + + #[test] + fn claim_codec_roundtrips_single_capability() { + let caps = vec![Capability::sign_commit()]; + let claim = Capability::join_claim(&caps); + assert_eq!(claim, "sign_commit"); + assert_eq!(Capability::parse_claim(&claim).unwrap(), caps); + } + + #[test] + fn parse_claim_empty_is_no_capabilities() { + assert!(Capability::parse_claim("").unwrap().is_empty()); + } + + #[test] + fn parse_claim_rejects_malformed_segment() { + // A segment with an invalid char fails closed rather than silently dropping. + assert!(matches!( + Capability::parse_claim("fs:read,has space"), + Err(CapabilityError::InvalidChars(_)) + )); + } + + #[test] + fn join_claim_of_empty_is_empty_string() { + assert_eq!(Capability::join_claim(&[]), ""); + } + // ======================================================================== // Serde roundtrip tests (critical for backward compat) // ======================================================================== diff --git a/crates/auths-sdk/src/domains/credentials/issue.rs b/crates/auths-sdk/src/domains/credentials/issue.rs index 19d5e932..e731946b 100644 --- a/crates/auths-sdk/src/domains/credentials/issue.rs +++ b/crates/auths-sdk/src/domains/credentials/issue.rs @@ -105,14 +105,9 @@ fn build_attributes( expires_at: Option>, ) -> serde_json::Map { let mut data = serde_json::Map::new(); - let capability = capabilities - .iter() - .map(Capability::as_str) - .collect::>() - .join(","); data.insert( CAPABILITY_FIELD.to_string(), - serde_json::Value::String(capability), + serde_json::Value::String(Capability::join_claim(capabilities)), ); if let Some(role) = role { data.insert( @@ -405,12 +400,7 @@ fn credential_claims( .data .get(CAPABILITY_FIELD) .and_then(|v| v.as_str()) - .and_then(|c| { - c.split(',') - .map(Capability::parse) - .collect::, _>>() - .ok() - }) + .and_then(|c| Capability::parse_claim(c).ok()) .unwrap_or_default(); (subject, caps) } diff --git a/crates/auths-verifier/src/credential.rs b/crates/auths-verifier/src/credential.rs index 5d79ec73..8944144e 100644 --- a/crates/auths-verifier/src/credential.rs +++ b/crates/auths-verifier/src/credential.rs @@ -282,11 +282,7 @@ pub fn verify_credential_sync( ) else { return CredentialVerdict::SchemaInvalid; }; - let Ok(caps) = capability_claims(acdc) - .iter() - .map(|c| Capability::parse(c)) - .collect::, _>>() - else { + let Ok(caps) = capability_claims(acdc) else { return CredentialVerdict::SchemaInvalid; }; @@ -317,14 +313,20 @@ fn validate_against_schema(acdc: &Acdc) -> Result<(), ()> { } } -/// The capability claims granted by the credential (`a.capability`). -fn capability_claims(acdc: &Acdc) -> Vec { - acdc.a +/// The capability claims granted by the credential, decoded from `a.capability`. +/// +/// Decodes the single claim string through [`Capability::parse_claim`] — the same +/// grammar the issuer encodes with — so a multi-capability credential yields each +/// capability. A malformed claim is an `Err` (the caller fails closed as +/// `SchemaInvalid`) rather than a silently-wrong single capability. +fn capability_claims(acdc: &Acdc) -> Result, ()> { + let claim = acdc + .a .data .get(CAPABILITY_FIELD) .and_then(|v| v.as_str()) - .map(|c| vec![c.to_string()]) - .unwrap_or_default() + .unwrap_or_default(); + Capability::parse_claim(claim).map_err(|_| ()) } /// Reject an expired credential by comparing the optional `a.expiry` to `now`. From 922fee7894f80739fe2ff258e5f58091575e26bc Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 13:11:10 +0100 Subject: [PATCH 05/32] feat(signing): capture repository/sha/workflow_ref in signed CI provenance The signing CiEnvironment struct gains repository, sha, and a fully-qualified workflow_ref, populated at the CLI boundary in detect_ci_environment from GITHUB_REPOSITORY / GITHUB_WORKFLOW_REF (canonical, falling back to GITHUB_WORKFLOW) / GITHUB_SHA, with the GitLab and CircleCI equivalents. All fields are signature-covered in the ephemeral attestation payload, so provenance is SLSA-shaped: source identity (repository + sha) plus builder identity (workflow_ref) plus run, rather than a bare commit_sha the verifier must trust the runner for. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/artifact/mod.rs | 4 +++ .../auths-sdk/src/domains/signing/ci_env.rs | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index dad09bb5..a14c088b 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -418,14 +418,18 @@ pub fn handle_artifact( let ci_env = match ci_platform.as_deref() { Some("local") => CiEnvironment { platform: CiPlatform::Local, + repository: None, workflow_ref: None, + sha: None, run_id: None, actor: None, runner_os: None, }, Some(name) => CiEnvironment { platform: CiPlatform::Generic, + repository: None, workflow_ref: None, + sha: None, run_id: None, actor: None, runner_os: Some(name.to_string()), diff --git a/crates/auths-sdk/src/domains/signing/ci_env.rs b/crates/auths-sdk/src/domains/signing/ci_env.rs index f21695d7..82515c76 100644 --- a/crates/auths-sdk/src/domains/signing/ci_env.rs +++ b/crates/auths-sdk/src/domains/signing/ci_env.rs @@ -26,11 +26,18 @@ pub enum CiPlatform { /// Structured CI environment metadata embedded in ephemeral attestations. /// -/// Serialized into the attestation `payload` (covered by signature). +/// Serialized into the attestation `payload` (covered by signature). The +/// shape mirrors SLSA provenance's `invocation`/`builder` triple: *which +/// source* (`repository` + `sha`), *which workflow* (`workflow_ref`), and +/// *which run* (`run_id`). Capturing all three under signature is what lets +/// a verifier bind an artifact to a specific commit of a specific repo built +/// by a specific workflow — rather than trusting the runner's word for it. /// /// Args: /// * `platform` - CI platform identifier. -/// * `workflow_ref` - Workflow file path or reference. +/// * `repository` - Source repository, `owner/name` (SLSA source identity). +/// * `workflow_ref` - Fully-qualified workflow reference (SLSA builder id). +/// * `sha` - Commit SHA the workflow ran against (SLSA source digest). /// * `run_id` - CI run identifier. /// * `actor` - User or bot that triggered the run. /// * `runner_os` - OS of the CI runner. @@ -44,9 +51,18 @@ pub enum CiPlatform { pub struct CiEnvironment { /// CI platform. pub platform: CiPlatform, - /// Workflow file path or reference. + /// Source repository in `owner/name` form (SLSA source identity). + #[serde(skip_serializing_if = "Option::is_none")] + pub repository: Option, + /// Fully-qualified workflow reference — the SLSA builder identity, e.g. + /// `owner/repo/.github/workflows/release.yml@refs/tags/v1` on GitHub. + /// Falls back to the bare workflow name only when the canonical ref is + /// unavailable. #[serde(skip_serializing_if = "Option::is_none")] pub workflow_ref: Option, + /// Commit SHA the workflow ran against (SLSA source digest). + #[serde(skip_serializing_if = "Option::is_none")] + pub sha: Option, /// CI run identifier. #[serde(skip_serializing_if = "Option::is_none")] pub run_id: Option, @@ -73,7 +89,13 @@ pub fn detect_ci_environment() -> Option { if std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") { return Some(CiEnvironment { platform: CiPlatform::GithubActions, - workflow_ref: std::env::var("GITHUB_WORKFLOW").ok(), + repository: std::env::var("GITHUB_REPOSITORY").ok(), + // Prefer the SLSA-canonical fully-qualified ref; the bare + // workflow name is only a fallback for runners that don't set it. + workflow_ref: std::env::var("GITHUB_WORKFLOW_REF") + .ok() + .or_else(|| std::env::var("GITHUB_WORKFLOW").ok()), + sha: std::env::var("GITHUB_SHA").ok(), run_id: std::env::var("GITHUB_RUN_ID").ok(), actor: std::env::var("GITHUB_ACTOR").ok(), runner_os: std::env::var("RUNNER_OS").ok(), @@ -83,7 +105,9 @@ pub fn detect_ci_environment() -> Option { if std::env::var("GITLAB_CI").is_ok() { return Some(CiEnvironment { platform: CiPlatform::GitlabCi, + repository: std::env::var("CI_PROJECT_PATH").ok(), workflow_ref: std::env::var("CI_CONFIG_PATH").ok(), + sha: std::env::var("CI_COMMIT_SHA").ok(), run_id: std::env::var("CI_PIPELINE_ID").ok(), actor: std::env::var("GITLAB_USER_LOGIN").ok(), runner_os: None, @@ -93,7 +117,9 @@ pub fn detect_ci_environment() -> Option { if std::env::var("CIRCLECI").is_ok() { return Some(CiEnvironment { platform: CiPlatform::CircleCi, + repository: std::env::var("CIRCLE_PROJECT_REPONAME").ok(), workflow_ref: std::env::var("CIRCLE_WORKFLOW_ID").ok(), + sha: std::env::var("CIRCLE_SHA1").ok(), run_id: std::env::var("CIRCLE_BUILD_NUM").ok(), actor: std::env::var("CIRCLE_USERNAME").ok(), runner_os: None, @@ -103,7 +129,9 @@ pub fn detect_ci_environment() -> Option { if std::env::var("CI").is_ok() { return Some(CiEnvironment { platform: CiPlatform::Generic, + repository: None, workflow_ref: None, + sha: None, run_id: None, actor: None, runner_os: None, From 3791a7508f0602dfff91704f6420c0b872c0e1c8 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 13:33:17 +0100 Subject: [PATCH 06/32] feat(org): public authenticated_org_state() for offline key-state resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the org's authenticated key state derived from a bundled org KEL as a single public call: AirGappedOrgBundle::authenticated_org_state(). It authenticates the embedded org KEL (RT-002) and returns the resolved KeyState — the only trust-rooted source of the org's current verkey available offline. authenticate_bundled_kel is widened to pub and re-exported from domains::org so callers holding a bare BundledKel share the same primitive instead of reconstructing SignedEvents and replaying validate_signed_kel. The offline DSSE compliance-verify path routes through the new method (one source of truth). Adds a regression test covering resolve-and-fail-closed. Co-Authored-By: Claude Opus 4.8 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .../auths-sdk/src/domains/compliance/dsse.rs | 4 +-- crates/auths-sdk/src/domains/org/bundle.rs | 27 ++++++++++++++ crates/auths-sdk/src/domains/org/mod.rs | 2 +- .../src/domains/org/offline_verify.rs | 9 ++++- .../auths-sdk/tests/cases/org_delegation.rs | 36 +++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index 0b104ac6..60338b42 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -26,7 +26,6 @@ use crate::domains::compliance::frameworks::{FrameworkReport, INTOTO_STATEMENT_T use crate::domains::compliance::query::{ ComplianceQueryError, EvidencePack, RowVerdict, verify_evidence_pack_offline, }; -use crate::domains::org::offline_verify::authenticate_bundled_kel; use auths_verifier::IdentityDID; /// The in-toto Statement payload type carried in the DSSE envelope. @@ -226,7 +225,8 @@ pub fn verify_signed_evidence_pack_offline( })?; // The org verkey, from the authenticated embedded KEL alone. - let state = authenticate_bundled_kel(&bundle.org_kel, None) + let state = bundle + .authenticated_org_state() .map_err(|e| ComplianceQueryError::OfflineVerification(e.to_string()))?; let cesr = state.current_key().ok_or_else(|| { ComplianceQueryError::Verification("org KEL resolves to no current key".into()) diff --git a/crates/auths-sdk/src/domains/org/bundle.rs b/crates/auths-sdk/src/domains/org/bundle.rs index e05c7003..30b98d45 100644 --- a/crates/auths-sdk/src/domains/org/bundle.rs +++ b/crates/auths-sdk/src/domains/org/bundle.rs @@ -35,6 +35,7 @@ use crate::domains::org::audit::list_offboarding_records; use crate::domains::org::delegation::list_members; use crate::domains::org::error::OrgError; use crate::domains::org::offboarding::SignedOffboardingRecord; +use crate::domains::org::offline_verify::authenticate_bundled_kel; /// Schema version of the air-gapped org bundle wire format. Bump on any /// breaking change to [`AirGappedOrgBundle`]. @@ -105,6 +106,32 @@ impl AirGappedOrgBundle { pub fn from_json(json: &str) -> Result { serde_json::from_str(json).map_err(|e| OrgError::Signing(format!("parse org bundle: {e}"))) } + + /// The org's authenticated key state, derived from the bundled org KEL alone. + /// + /// Authenticates the embedded org KEL (RT-002 — every event SAID-correct AND + /// signed by the controlling key-state, not merely structurally valid) and + /// returns the resolved [`auths_keri::KeyState`]. The org KEL is the root of + /// trust, so it authenticates with no delegator lookup. + /// + /// This is the one public call for "give me the org's authenticated key state + /// from evidence alone" — the only trust-rooted source of the org's *current* + /// verkey available offline (e.g. to verify a DSSE envelope the org signed). + /// Every downstream verifier (CI gate, browser widget, third-party audit tool) + /// shares it instead of re-implementing the authenticate-then-resolve path. + /// + /// Fails closed ([`OrgError::BundleIntegrity`]) on a tampered event, a length + /// mismatch between events and attachments, an unparseable attachment, or a + /// signature that does not verify. + /// + /// Usage: + /// ```ignore + /// let state = bundle.authenticated_org_state()?; + /// let org_verkey = state.current_key(); + /// ``` + pub fn authenticated_org_state(&self) -> Result { + authenticate_bundled_kel(&self.org_kel, None) + } } /// Collect one identifier's KEL and per-event attachments into a [`BundledKel`]. diff --git a/crates/auths-sdk/src/domains/org/mod.rs b/crates/auths-sdk/src/domains/org/mod.rs index 6b203c89..8ccaadee 100644 --- a/crates/auths-sdk/src/domains/org/mod.rs +++ b/crates/auths-sdk/src/domains/org/mod.rs @@ -34,7 +34,7 @@ pub use metrics::{FleetMetrics, fleet_metrics}; pub use offboarding::{ OffboardingRecord, SignedOffboardingRecord, load_offboarding_record, verify_offboarding_record, }; -pub use offline_verify::{OfflineVerifyReport, verify_org_bundle}; +pub use offline_verify::{OfflineVerifyReport, authenticate_bundled_kel, verify_org_bundle}; pub use policy::{ Expr, LoadedOrgPolicy, OrgPolicySet, evaluate_with_org_policy, load_org_policy, set_org_policy, }; diff --git a/crates/auths-sdk/src/domains/org/offline_verify.rs b/crates/auths-sdk/src/domains/org/offline_verify.rs index d935a232..378ec7e3 100644 --- a/crates/auths-sdk/src/domains/org/offline_verify.rs +++ b/crates/auths-sdk/src/domains/org/offline_verify.rs @@ -64,7 +64,14 @@ fn check_kel_integrity(kel: &BundledKel) -> Result<(), OrgError> { /// Returns the authenticated [`auths_keri::KeyState`] — the only trust-rooted /// source of the controller's *current* verkey available offline (e.g. to verify /// a DSSE envelope signed by the org whose KEL the evidence embeds). -pub(crate) fn authenticate_bundled_kel( +/// +/// This is the shared ecosystem primitive: a downstream verifier (CI gate, +/// browser widget, third-party audit tool) holding a [`BundledKel`] gets the +/// controller's authenticated key state from evidence alone in one call, rather +/// than reconstructing `SignedEvent`s and replaying `validate_signed_kel` itself. +/// For an [`AirGappedOrgBundle`], prefer +/// [`AirGappedOrgBundle::authenticated_org_state`]. +pub fn authenticate_bundled_kel( kel: &BundledKel, lookup: Option<&dyn auths_keri::DelegatorKelLookup>, ) -> Result { diff --git a/crates/auths-sdk/tests/cases/org_delegation.rs b/crates/auths-sdk/tests/cases/org_delegation.rs index 6490b29e..a976d175 100644 --- a/crates/auths-sdk/tests/cases/org_delegation.rs +++ b/crates/auths-sdk/tests/cases/org_delegation.rs @@ -707,6 +707,42 @@ fn offline_verify_rejects_forged_kel_signature() { ); } +#[test] +fn authenticated_org_state_resolves_org_verkey_from_evidence_alone() { + // The ecosystem primitive: a downstream verifier holding only the bundle + // gets the org's authenticated key state in one public call, with no network + // and no re-implementation of the authenticate-then-resolve path. + use auths_sdk::domains::org::build_org_bundle; + + let (ctx, _org_alias, org_prefix, _tmp) = setup(); + let mut bundle = build_org_bundle(&ctx, &org_prefix).expect("bundle"); + + let state = bundle + .authenticated_org_state() + .expect("the org KEL is the root of trust and authenticates against itself"); + let verkey = state + .current_key() + .expect("an authenticated org KEL resolves to a current verkey"); + assert!( + !verkey.as_str().is_empty(), + "current org verkey is a non-empty CESR-encoded key, got {verkey:?}" + ); + + // Fail-closed: a forged org-inception signature must not yield a key state + // (RT-002 — authentication, not just SAID integrity). + let att = &mut bundle.org_kel.attachments[0]; + if let Some(last) = att.pop() { + att.push(if last == '0' { '1' } else { '0' }); + } + let err = bundle + .authenticated_org_state() + .expect_err("a forged org KEL signature must fail closed"); + assert!( + matches!(err, OrgError::BundleIntegrity { .. }), + "expected BundleIntegrity, got {err:?}" + ); +} + #[test] fn empty_org_returns_empty_member_list() { let (ctx, _org_alias, org_prefix, _tmp) = setup(); From 48b9765ecc0fbcedd3b10ac9b164ded15ffa48ab Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 13:56:01 +0100 Subject: [PATCH 07/32] feat(compliance): SOC 2 (TSC) and ISO 27001 Annex-A framework predicates Add ComplianceFramework::Soc2 and ::Iso27001 with build_soc2 / build_iso27001, each mapping the already-classified evidence rows to its framework's control IDs (SOC 2 CC6.1/6.2/6.3, CC7.2; ISO 27001:2022 A.5.16/A.5.18/A.8.4/A.8.28) through one shared ControlMapping shape and the existing DSSE in-toto statement path. Wired through CliFramework and the compliance report --framework selector; help and tests cover both. Mirrors the existing CRA->SSDF mapping. Co-Authored-By: Claude Opus 4.8 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/compliance.rs | 9 +- .../src/domains/compliance/frameworks.rs | 133 ++++++++++++++++++ .../auths-sdk/src/domains/compliance/query.rs | 11 +- .../auths-sdk/tests/cases/compliance_query.rs | 70 +++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/crates/auths-cli/src/commands/compliance.rs b/crates/auths-cli/src/commands/compliance.rs index 7298cdc4..f6310e15 100644 --- a/crates/auths-cli/src/commands/compliance.rs +++ b/crates/auths-cli/src/commands/compliance.rs @@ -108,6 +108,10 @@ pub enum CliFramework { Sbom, /// EU Cyber Resilience Act obligation mapping. Cra, + /// SOC 2 Trust Services Criteria (TSC) control mapping. + Soc2, + /// ISO/IEC 27001:2022 Annex-A control mapping. + Iso27001, } impl From for ComplianceFramework { @@ -116,6 +120,8 @@ impl From for ComplianceFramework { CliFramework::Slsa => ComplianceFramework::Slsa, CliFramework::Sbom => ComplianceFramework::Sbom, CliFramework::Cra => ComplianceFramework::Cra, + CliFramework::Soc2 => ComplianceFramework::Soc2, + CliFramework::Iso27001 => ComplianceFramework::Iso27001, } } } @@ -187,7 +193,8 @@ pub enum ComplianceSubcommand { period: String, /// Target framework (tags the pack; with `--predicate`, selects the - /// rendered predicate: SLSA provenance+VSA / SPDX SBOM / CRA mapping) + /// rendered predicate: SLSA provenance+VSA / SPDX SBOM / CRA mapping / + /// SOC 2 TSC mapping / ISO 27001 Annex-A mapping) #[arg(long, value_enum, default_value = "slsa")] framework: CliFramework, diff --git a/crates/auths-sdk/src/domains/compliance/frameworks.rs b/crates/auths-sdk/src/domains/compliance/frameworks.rs index 65923a1e..655a9d95 100644 --- a/crates/auths-sdk/src/domains/compliance/frameworks.rs +++ b/crates/auths-sdk/src/domains/compliance/frameworks.rs @@ -37,6 +37,10 @@ pub const SLSA_REPORT_PREDICATE_TYPE: &str = "https://auths.dev/compliance/slsa/ pub const SBOM_REPORT_PREDICATE_TYPE: &str = "https://auths.dev/compliance/sbom/v1"; /// CRA obligation-mapping report predicate type. pub const CRA_REPORT_PREDICATE_TYPE: &str = "https://auths.dev/compliance/cra/v1"; +/// SOC 2 Trust Services Criteria mapping report predicate type. +pub const SOC2_REPORT_PREDICATE_TYPE: &str = "https://auths.dev/compliance/soc2/v1"; +/// ISO/IEC 27001:2022 Annex-A mapping report predicate type. +pub const ISO27001_REPORT_PREDICATE_TYPE: &str = "https://auths.dev/compliance/iso27001/v1"; /// Whether a fact is immutable build-level evidence or a point-in-time status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -148,6 +152,8 @@ pub fn build_framework_report( ComplianceFramework::Slsa => Ok(build_slsa(pack, vsa)), ComplianceFramework::Sbom => Ok(build_sbom(pack)), ComplianceFramework::Cra => Ok(build_cra(pack)), + ComplianceFramework::Soc2 => Ok(build_soc2(pack)), + ComplianceFramework::Iso27001 => Ok(build_iso27001(pack)), } } @@ -360,3 +366,130 @@ fn build_cra(pack: &EvidencePack) -> FrameworkReport { sbom_sha256: None, } } + +// ── SOC 2 (TSC) / ISO 27001 (Annex-A) ────────────────────────────────────────── + +/// One control mapped from the pack's evidence to a framework's control ID. +/// +/// The same shape serves SOC 2 (Trust Services Criteria, `id` like `CC6.3`) and +/// ISO 27001:2022 (Annex-A, `id` like `A.5.18`): each says which evidence the +/// pack already carries (`satisfied_by`) discharges which control, and at what +/// `status`. Documentation + a typed report shape over the already-classified +/// evidence — never a new trust primitive. +#[derive(Debug, Clone, Serialize)] +struct ControlMapping { + id: &'static str, + description: &'static str, + satisfied_by: &'static str, + status: &'static str, +} + +/// SOC 2 Trust Services Criteria the evidence pack speaks to. Off-boarding a +/// compromised signer and refusing post-revocation authority is exactly the +/// logical-access removal CC6.2/CC6.3 test; provenance discharges CC7.x change +/// integrity. +const SOC2_CRITERIA: &[ControlMapping] = &[ + ControlMapping { + id: "CC6.1", + description: "Logical access security — identity and authority are bound to a self-certifying KEL.", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "CC6.2", + description: "Register and authorize new internal/external users before granting access (member onboarding events).", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "CC6.3", + description: "Remove access on role change/termination — off-boarding revokes a signer; post-revocation releases are rejected.", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "CC7.2", + description: "Detect and act on anomalies — release provenance and the transparency log make tampering evident.", + satisfied_by: "slsa_provenance", + status: "covered", + }, +]; + +/// ISO/IEC 27001:2022 Annex-A controls the evidence pack speaks to. The 2022 +/// revision's identity/access controls (A.5.16 identity management, A.5.18 +/// access rights, A.8.4 access to source/build) are discharged by the same +/// authority-at-release evidence. +const ISO27001_ANNEX_A: &[ControlMapping] = &[ + ControlMapping { + id: "A.5.16", + description: "Identity management — each signer is a self-certifying KEL identity.", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "A.5.18", + description: "Access rights are provisioned and revoked — off-boarding revokes a signer; post-revocation releases are rejected.", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "A.8.4", + description: "Access to source code / build pipeline is authorized at release time, by KEL position.", + satisfied_by: "authority_at_release", + status: "covered", + }, + ControlMapping { + id: "A.8.28", + description: "Secure development — release provenance binds each artifact to an authorized signer.", + satisfied_by: "slsa_provenance", + status: "covered", + }, +]; + +/// Render a control-mapping framework report (SOC 2 / ISO 27001) over the +/// already-classified pack — the one shape shared by the two control frameworks. +fn build_control_mapping( + pack: &EvidencePack, + framework: ComplianceFramework, + predicate_type: &str, + controls_field: &str, + controls: &[ControlMapping], + note: &str, +) -> FrameworkReport { + let predicate = serde_json::json!({ + controls_field: controls, + "releasesAssessed": pack.rows.len(), + "equivocationVisibility": pack.equivocation_visibility, + "note": note, + }); + FrameworkReport { + framework, + predicate_type: predicate_type.to_string(), + statement: statement(&pack.rows, predicate_type, predicate), + sbom_sha256: None, + } +} + +fn build_soc2(pack: &EvidencePack) -> FrameworkReport { + build_control_mapping( + pack, + ComplianceFramework::Soc2, + SOC2_REPORT_PREDICATE_TYPE, + "trustServicesCriteria", + SOC2_CRITERIA, + "Maps the evidence pack to SOC 2 Trust Services Criteria; it is a \ + reporting mapping over the classified evidence, not a new trust primitive.", + ) +} + +fn build_iso27001(pack: &EvidencePack) -> FrameworkReport { + build_control_mapping( + pack, + ComplianceFramework::Iso27001, + ISO27001_REPORT_PREDICATE_TYPE, + "annexAControls", + ISO27001_ANNEX_A, + "Maps the evidence pack to ISO/IEC 27001:2022 Annex-A controls; it is a \ + reporting mapping over the classified evidence, not a new trust primitive.", + ) +} diff --git a/crates/auths-sdk/src/domains/compliance/query.rs b/crates/auths-sdk/src/domains/compliance/query.rs index 8c87aa83..95a1b41b 100644 --- a/crates/auths-sdk/src/domains/compliance/query.rs +++ b/crates/auths-sdk/src/domains/compliance/query.rs @@ -73,9 +73,10 @@ pub enum ComplianceQueryError { TamperedRelease(String), } -/// The compliance framework a report targets. Predicate rendering (SLSA -/// provenance / SPDX SBOM / CRA mapping) lands in fn-157.9; here it only tags -/// the pack. +/// The compliance framework a report targets. Each variant selects the +/// predicate [`crate::domains::compliance::frameworks::build_framework_report`] +/// renders (SLSA provenance + VSA / SPDX SBOM / CRA→SSDF / SOC 2 TSC / ISO +/// 27001 Annex-A); here it only tags the pack. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ComplianceFramework { @@ -85,6 +86,10 @@ pub enum ComplianceFramework { Sbom, /// EU Cyber Resilience Act obligation mapping. Cra, + /// SOC 2 Trust Services Criteria (TSC) control mapping. + Soc2, + /// ISO/IEC 27001:2022 Annex-A control mapping. + Iso27001, } /// The transparency-log evidence that proves an artifact's entry is in the log, diff --git a/crates/auths-sdk/tests/cases/compliance_query.rs b/crates/auths-sdk/tests/cases/compliance_query.rs index f76f7b12..35467ada 100644 --- a/crates/auths-sdk/tests/cases/compliance_query.rs +++ b/crates/auths-sdk/tests/cases/compliance_query.rs @@ -785,6 +785,76 @@ fn cra_report_maps_obligations_and_ssdf_practices() { assert!(!canonical.contains("non_equivocation")); } +#[test] +fn soc2_report_maps_trust_services_criteria() { + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let m = add_test_member(&ctx, &org_prefix, &org_alias, "trent"); + let releases = vec![ReleaseRecord { + artifact_digest: "sha256:c2".into(), + signer_prefix: m, + signed_at: Some(2), + transparency: None, + }]; + let pack = pack_with( + &ctx, + &org_did, + &org_prefix, + ComplianceFramework::Soc2, + &releases, + ); + let report = build_framework_report(&pack, &vsa(SignerVerifierAllowList::new())).unwrap(); + + let criteria = report.statement["predicate"]["trustServicesCriteria"] + .as_array() + .unwrap(); + assert!(!criteria.is_empty()); + // The off-boarding controls the demo names must be present. + let ids: Vec<&str> = criteria.iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"CC6.2") && ids.contains(&"CC6.3")); + assert_eq!( + report.predicate_type, + "https://auths.dev/compliance/soc2/v1" + ); + let canonical = report.to_intoto_statement().unwrap(); + assert!(canonical.contains("\"policy_met\":false")); + assert!(!canonical.contains("non_equivocation")); +} + +#[test] +fn iso27001_report_maps_annex_a_controls() { + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let m = add_test_member(&ctx, &org_prefix, &org_alias, "peggy"); + let releases = vec![ReleaseRecord { + artifact_digest: "sha256:15".into(), + signer_prefix: m, + signed_at: Some(2), + transparency: None, + }]; + let pack = pack_with( + &ctx, + &org_did, + &org_prefix, + ComplianceFramework::Iso27001, + &releases, + ); + let report = build_framework_report(&pack, &vsa(SignerVerifierAllowList::new())).unwrap(); + + let controls = report.statement["predicate"]["annexAControls"] + .as_array() + .unwrap(); + assert!(!controls.is_empty()); + let ids: Vec<&str> = controls.iter().filter_map(|c| c["id"].as_str()).collect(); + // The 2022 identity/access-rights controls the demo names. + assert!(ids.contains(&"A.5.16") && ids.contains(&"A.5.18")); + assert_eq!( + report.predicate_type, + "https://auths.dev/compliance/iso27001/v1" + ); + let canonical = report.to_intoto_statement().unwrap(); + assert!(canonical.contains("\"policy_met\":false")); + assert!(!canonical.contains("non_equivocation")); +} + // ── Anchored releases: attest at signing time, discover at report time ────── use auths_sdk::domains::compliance::releases::{ArtifactDigest, attest_release, discover_releases}; From eafdb9d0258262db766eb572bf29eaec4bed8413 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 15:33:44 +0100 Subject: [PATCH 08/32] feat(AITFC-4): offline org-bundle + evidence-pack verification in auths-verifier, exported to WASM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pure offline-verification core down into auths-verifier so every surface (native CLI, FFI, browser) shares one implementation: - tlog: RFC 6962 Merkle proof math, checkpoint/proof wire types, and TransparencyError, moved from auths-transparency (which now re-exports). - org_bundle: AirGappedOrgBundle/BundledKel wire types, off-boarding record verification, RT-002 bundled-KEL authentication, authority-by-KEL-position classification, and verify_org_bundle, moved from auths-sdk (which keeps the registry-bound builder and re-exports the rest). - evidence_pack: pack wire types, per-row authority re-derivation, Merkle transparency checks, and verify_evidence_pack_offline, likewise. - wasm: new verifyOrgBundle / verifyEvidencePackOffline exports — sync, panic-free, tagged-union verdicts with 16 MiB input ceilings. Focused fail-closed error types OrgBundleError (AUTHS-E2201..6) and EvidencePackError (AUTHS-E2301..3) replace OrgError::Bundle* (E5619-21 retired in registry.lock); error docs regenerated via xtask. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/errors/registry.rs | 30 +- .../auths-sdk/src/domains/compliance/query.rs | 451 ++----------- crates/auths-sdk/src/domains/org/audit.rs | 48 +- crates/auths-sdk/src/domains/org/bundle.rs | 121 +--- crates/auths-sdk/src/domains/org/error.rs | 41 +- .../auths-sdk/src/domains/org/offboarding.rs | 141 +--- .../src/domains/org/offline_verify.rs | 284 +------- .../auths-sdk/tests/cases/org_delegation.rs | 13 +- crates/auths-transparency/src/checkpoint.rs | 158 +---- crates/auths-transparency/src/error.rs | 45 +- crates/auths-transparency/src/merkle.rs | 612 +----------------- crates/auths-transparency/src/proof.rs | 130 +--- crates/auths-transparency/src/types.rs | 216 +------ crates/auths-verifier/src/evidence_pack.rs | 582 +++++++++++++++++ crates/auths-verifier/src/lib.rs | 6 + .../auths-verifier/src/org_bundle/bundle.rs | 118 ++++ crates/auths-verifier/src/org_bundle/error.rs | 83 +++ crates/auths-verifier/src/org_bundle/mod.rs | 35 + .../auths-verifier/src/org_bundle/record.rs | 144 +++++ .../auths-verifier/src/org_bundle/verify.rs | 422 ++++++++++++ crates/auths-verifier/src/tlog/checkpoint.rs | 153 +++++ crates/auths-verifier/src/tlog/error.rs | 43 ++ crates/auths-verifier/src/tlog/merkle.rs | 605 +++++++++++++++++ crates/auths-verifier/src/tlog/mod.rs | 29 + crates/auths-verifier/src/tlog/proof.rs | 128 ++++ crates/auths-verifier/src/tlog/types.rs | 213 ++++++ crates/auths-verifier/src/wasm.rs | 49 ++ docs/errors/AUTHS-E2201.md | 9 + docs/errors/AUTHS-E2202.md | 9 + docs/errors/AUTHS-E2203.md | 9 + docs/errors/AUTHS-E2204.md | 9 + docs/errors/AUTHS-E2205.md | 9 + docs/errors/AUTHS-E2206.md | 9 + docs/errors/AUTHS-E2301.md | 9 + docs/errors/AUTHS-E2302.md | 9 + docs/errors/AUTHS-E2303.md | 9 + docs/errors/AUTHS-E5619.md | 9 - docs/errors/AUTHS-E5620.md | 9 - docs/errors/AUTHS-E5621.md | 9 - docs/errors/index.md | 12 +- docs/errors/registry.lock | 12 +- 41 files changed, 2856 insertions(+), 2176 deletions(-) create mode 100644 crates/auths-verifier/src/evidence_pack.rs create mode 100644 crates/auths-verifier/src/org_bundle/bundle.rs create mode 100644 crates/auths-verifier/src/org_bundle/error.rs create mode 100644 crates/auths-verifier/src/org_bundle/mod.rs create mode 100644 crates/auths-verifier/src/org_bundle/record.rs create mode 100644 crates/auths-verifier/src/org_bundle/verify.rs create mode 100644 crates/auths-verifier/src/tlog/checkpoint.rs create mode 100644 crates/auths-verifier/src/tlog/error.rs create mode 100644 crates/auths-verifier/src/tlog/merkle.rs create mode 100644 crates/auths-verifier/src/tlog/mod.rs create mode 100644 crates/auths-verifier/src/tlog/proof.rs create mode 100644 crates/auths-verifier/src/tlog/types.rs create mode 100644 docs/errors/AUTHS-E2201.md create mode 100644 docs/errors/AUTHS-E2202.md create mode 100644 docs/errors/AUTHS-E2203.md create mode 100644 docs/errors/AUTHS-E2204.md create mode 100644 docs/errors/AUTHS-E2205.md create mode 100644 docs/errors/AUTHS-E2206.md create mode 100644 docs/errors/AUTHS-E2301.md create mode 100644 docs/errors/AUTHS-E2302.md create mode 100644 docs/errors/AUTHS-E2303.md delete mode 100644 docs/errors/AUTHS-E5619.md delete mode 100644 docs/errors/AUTHS-E5620.md delete mode 100644 docs/errors/AUTHS-E5621.md diff --git a/crates/auths-cli/src/errors/registry.rs b/crates/auths-cli/src/errors/registry.rs index 4dca5092..a75cc4ec 100644 --- a/crates/auths-cli/src/errors/registry.rs +++ b/crates/auths-cli/src/errors/registry.rs @@ -82,6 +82,19 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E2108" => Some("# AUTHS-E2108\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnknownSigner`\n\n## Message\n\nsigner key not in allowed keys\n\n## Suggestion\n\nAdd the signer's key to the allowed signers list\n"), "AUTHS-E2109" => Some("# AUTHS-E2109\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::CommitParseFailed`\n\n## Message\n\ncommit parse failed: {0}\n"), + // --- auths-verifier (OrgBundleError) --- + "AUTHS-E2201" => Some("# AUTHS-E2201\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::Integrity`\n\n## Message\n\nbundle integrity failure for '{id}': {reason}\n"), + "AUTHS-E2202" => Some("# AUTHS-E2202\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::MissingMemberKel`\n\n## Message\n\nbundle is missing the KEL for delegated member '{member}'\n"), + "AUTHS-E2203" => Some("# AUTHS-E2203\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::MissingDelegatorSeal`\n\n## Message\n\nmember '{member}' has no delegation seal in the org KEL\n"), + "AUTHS-E2204" => Some("# AUTHS-E2204\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::Canonicalize`\n\n## Message\n\ncanonicalization failed: {0}\n"), + "AUTHS-E2205" => Some("# AUTHS-E2205\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::Parse`\n\n## Message\n\nparse failed: {0}\n"), + "AUTHS-E2206" => Some("# AUTHS-E2206\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::RecordInvalid`\n\n## Message\n\noffboarding record invalid: {0}\n"), + + // --- auths-verifier (EvidencePackError) --- + "AUTHS-E2301" => Some("# AUTHS-E2301\n\n**Crate:** `auths-verifier`\n\n**Type:** `EvidencePackError::Canonicalize`\n\n## Message\n\ncanonicalization failed: {0}\n"), + "AUTHS-E2302" => Some("# AUTHS-E2302\n\n**Crate:** `auths-verifier`\n\n**Type:** `EvidencePackError::Decode`\n\n## Message\n\ndecode failed: {0}\n"), + "AUTHS-E2303" => Some("# AUTHS-E2303\n\n**Crate:** `auths-verifier`\n\n**Type:** `EvidencePackError::OfflineVerification`\n\n## Message\n\noffline verification failed: {0}\n"), + // --- auths-core (AgentError) --- "AUTHS-E3001" => Some("# AUTHS-E3001\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::KeyNotFound`\n\n## Message\n\nKey not found\n\n## Suggestion\n\nRun `auths key list` to see available keys\n"), "AUTHS-E3002" => Some("# AUTHS-E3002\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::IncorrectPassphrase`\n\n## Message\n\nIncorrect passphrase\n"), @@ -412,9 +425,6 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E5616" => Some("# AUTHS-E5616\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::IdentityInit`\n\n## Message\n\nfailed to initialize organization identity: {0}\n"), "AUTHS-E5617" => Some("# AUTHS-E5617\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Attestation`\n\n## Message\n\nfailed to create admin attestation: {0}\n"), "AUTHS-E5618" => Some("# AUTHS-E5618\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::MemberNotDelegable`\n\n## Message\n\nmember '{did}' is not a delegated identifier of organization '{org}'\n"), - "AUTHS-E5619" => Some("# AUTHS-E5619\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::BundleIntegrity`\n\n## Message\n\nbundle integrity failure for '{id}': {reason}\n"), - "AUTHS-E5620" => Some("# AUTHS-E5620\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::BundleMissingMemberKel`\n\n## Message\n\nbundle is missing the KEL for delegated member '{member}'\n"), - "AUTHS-E5621" => Some("# AUTHS-E5621\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::BundleMissingDelegatorSeal`\n\n## Message\n\nmember '{member}' has no delegation seal in the org KEL\n"), "AUTHS-E5622" => Some("# AUTHS-E5622\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::PolicyCompile`\n\n## Message\n\ninvalid org policy: {reason}\n"), "AUTHS-E5623" => Some("# AUTHS-E5623\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::PolicyBlobMissing`\n\n## Message\n\norg policy blob for hash '{hash}' is missing from storage\n"), "AUTHS-E5624" => Some("# AUTHS-E5624\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::PolicyIntegrity`\n\n## Message\n\norg policy integrity failure: KEL committed hash '{expected}' but the stored blob hashes to '{actual}'\n"), @@ -537,6 +547,15 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E2107", "AUTHS-E2108", "AUTHS-E2109", + "AUTHS-E2201", + "AUTHS-E2202", + "AUTHS-E2203", + "AUTHS-E2204", + "AUTHS-E2205", + "AUTHS-E2206", + "AUTHS-E2301", + "AUTHS-E2302", + "AUTHS-E2303", "AUTHS-E3001", "AUTHS-E3002", "AUTHS-E3003", @@ -792,9 +811,6 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E5616", "AUTHS-E5617", "AUTHS-E5618", - "AUTHS-E5619", - "AUTHS-E5620", - "AUTHS-E5621", "AUTHS-E5622", "AUTHS-E5623", "AUTHS-E5624", @@ -882,6 +898,6 @@ mod tests { #[test] fn all_codes_count_matches_registry() { - assert_eq!(all_codes().len(), 357); + assert_eq!(all_codes().len(), 363); } } diff --git a/crates/auths-sdk/src/domains/compliance/query.rs b/crates/auths-sdk/src/domains/compliance/query.rs index 95a1b41b..4999d57d 100644 --- a/crates/auths-sdk/src/domains/compliance/query.rs +++ b/crates/auths-sdk/src/domains/compliance/query.rs @@ -4,37 +4,38 @@ //! Each row answers "who signed this artifact, and were they authorized **at //! release time**?" — using [`classify_authority_at_signing`], ordered by KEL //! position, never wall-clock. The pack embeds the honest witness-diversity -//! verdict ([`HonestyCeiling`] via [`ceiling_for_policy_load`]) rather than a -//! bare non-equivocation flag, so it can never over-claim third-party -//! non-equivocation while only self-run witnesses exist. +//! verdict ([`auths_transparency::HonestyCeiling`] via +//! [`ceiling_for_policy_load`]) rather than a bare non-equivocation flag, so +//! it can never over-claim third-party non-equivocation while only self-run +//! witnesses exist. //! -//! The pack is canonicalized with `json-canon` so two runs over the same inputs -//! produce byte-identical output (an auditor can re-derive it). An **offline** -//! pack ([`build_offline_evidence_pack`]) additionally embeds the org's KEL -//! material as an [`AirGappedOrgBundle`] plus, per row, the transparency-log -//! inclusion (and consistency) proof, so each row verifies with **zero network** -//! via [`verify_evidence_pack_offline`]. The org DSSE signature over the in-toto +//! The pack's wire types and the **verification** half +//! ([`verify_evidence_pack_offline`]) live in +//! [`auths_verifier::evidence_pack`] — the leaf crate every surface (native, +//! FFI, browser WASM) shares — and are re-exported here. This module keeps +//! the **build** half: classifying releases against the live registry and +//! embedding the org's KEL material as an [`AirGappedOrgBundle`] so each row +//! verifies with **zero network**. The org DSSE signature over the in-toto //! statement lives in [`crate::domains::compliance::dsse`]. use std::path::Path; use auths_id::keri::types::Prefix; -use auths_transparency::{ - ConsistencyProof, HonestyCeiling, InclusionProof, MerkleHash, SignedCheckpoint, WitnessPolicy, - WitnessPolicyError, ceiling_for_policy_load, -}; +use auths_transparency::{WitnessPolicy, WitnessPolicyError, ceiling_for_policy_load}; use auths_verifier::IdentityDID; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +pub use auths_verifier::evidence_pack::{ + ComplianceFramework, EVIDENCE_PACK_SCHEMA_VERSION, EvidencePack, EvidencePackError, + EvidenceRow, RowVerdict, TransparencyInclusion, verify_evidence_pack_offline, + verify_transparency_inclusion, +}; + use crate::context::AuthsContext; -use crate::domains::org::audit::{AuthorityAtSigning, classify_authority_at_signing}; -use crate::domains::org::bundle::{AirGappedOrgBundle, build_org_bundle}; +use crate::domains::org::audit::classify_authority_at_signing; +use crate::domains::org::bundle::build_org_bundle; use crate::domains::org::error::OrgError; -use crate::domains::org::offline_verify::{classify_authority_in_bundle, verify_org_bundle}; - -/// The current evidence-pack schema version. -pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1; /// A typed failure building or verifying a compliance evidence pack. #[derive(Debug, thiserror::Error)] @@ -73,48 +74,15 @@ pub enum ComplianceQueryError { TamperedRelease(String), } -/// The compliance framework a report targets. Each variant selects the -/// predicate [`crate::domains::compliance::frameworks::build_framework_report`] -/// renders (SLSA provenance + VSA / SPDX SBOM / CRA→SSDF / SOC 2 TSC / ISO -/// 27001 Annex-A); here it only tags the pack. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ComplianceFramework { - /// SLSA provenance. - Slsa, - /// SPDX software bill of materials. - Sbom, - /// EU Cyber Resilience Act obligation mapping. - Cra, - /// SOC 2 Trust Services Criteria (TSC) control mapping. - Soc2, - /// ISO/IEC 27001:2022 Annex-A control mapping. - Iso27001, -} - -/// The transparency-log evidence that proves an artifact's entry is in the log, -/// bundled so a row verifies offline. -/// -/// `inclusion_proof` proves the `leaf_hash` is in the tree at size N (root -/// `inclusion_proof.root`). When the inclusion was taken at a tree size **older** -/// than the embedded `signed_checkpoint`, a `consistency_proof` (N→M) proves the -/// older root is a prefix of the checkpoint root. With no consistency proof the -/// inclusion must be **against** the checkpoint (same root and size). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TransparencyInclusion { - /// The artifact's transparency-log leaf hash (the value `inclusion_proof` proves). - pub leaf_hash: MerkleHash, - /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`. - pub inclusion_proof: InclusionProof, - /// The signed checkpoint the inclusion is anchored to (directly, or via the - /// consistency proof). Its signature trust requires a **pinned log key** - /// ([`auths_transparency::verify_checkpoint_signature`]) — a separate axis from - /// this offline Merkle check. - pub signed_checkpoint: SignedCheckpoint, - /// Consistency proof from the inclusion's tree size to the checkpoint's, present - /// only when the inclusion was taken at an earlier size than the checkpoint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consistency_proof: Option, +impl From for ComplianceQueryError { + fn from(e: EvidencePackError) -> Self { + match e { + EvidencePackError::Canonicalize(m) => Self::Canonicalize(m), + EvidencePackError::Decode(m) => Self::Decode(m), + EvidencePackError::OfflineVerification(m) => Self::OfflineVerification(m), + other => Self::OfflineVerification(other.to_string()), + } + } } /// A release to classify: an artifact digest, the member that signed it, and its @@ -134,96 +102,6 @@ pub struct ReleaseRecord { pub transparency: Option, } -/// One row of compliance evidence: the signer's authority **at release**. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EvidenceRow { - /// The artifact content digest. - pub artifact_digest: String, - /// The signing member's self-certifying identity. - pub signer: IdentityDID, - /// The signer's authority at the signing position, by KEL order. - pub authority_at_release: AuthorityAtSigning, - /// The artifact's in-band signing position, if any. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signed_at: Option, - /// Transparency-log inclusion evidence, when supplied — lets the row prove the - /// artifact's log membership offline. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transparency: Option, -} - -/// A deterministic, offline-verifiable compliance evidence pack. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvidencePack { - /// Schema version. - pub schema_version: u32, - /// The org whose history this pack covers. - pub org: IdentityDID, - /// The reporting period (free-form, e.g. `2026-Q3`). - pub period: String, - /// The framework this pack targets. - pub framework: ComplianceFramework, - /// The honest witness-diversity verdict — NEVER a bare `non_equivocation` - /// flag. With only self-run/placeholder witnesses this is `policy_met == - /// false` ("single-operator — not yet independent"). - pub equivocation_visibility: HonestyCeiling, - /// When the pack was generated (injected clock; never `Utc::now()` in domain - /// code). Two runs with the same inputs and timestamp are byte-identical. - pub generated_at: DateTime, - /// One row per classified release. - pub rows: Vec, - /// The embedded, URL-free KEL material (org + member KELs + off-boarding - /// records + pinned root) that makes authority re-derivable offline. `None` - /// for a non-offline pack ([`build_evidence_pack`]). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub org_bundle: Option, -} - -impl EvidencePack { - /// Canonicalize with `json-canon` — the byte-exact, reproducible form an - /// auditor re-derives and the org signs. - pub fn canonicalize(&self) -> Result { - json_canon::to_string(self).map_err(|e| ComplianceQueryError::Canonicalize(e.to_string())) - } - - /// Parse a pack back from its canonical JSON form. Typed identifiers fail - /// closed on malformed input. - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| ComplianceQueryError::Decode(e.to_string())) - } - - /// Render as a canonical in-toto Statement: `subject` = the artifact digests, - /// `predicate` = this pack. The org DSSE signature over these bytes lives in - /// [`crate::domains::compliance::dsse::sign_evidence_pack`]; the SLSA/SBOM/CRA - /// predicate variants land in fn-157.9. - pub fn to_intoto_statement(&self) -> Result { - let subject: Vec = self - .rows - .iter() - .map(|r| { - let digest = r - .artifact_digest - .strip_prefix("sha256:") - .unwrap_or(&r.artifact_digest); - serde_json::json!({ - "name": r.artifact_digest, - "digest": { "sha256": digest }, - }) - }) - .collect(); - - let statement = serde_json::json!({ - "_type": "https://in-toto.io/Statement/v1", - "subject": subject, - "predicateType": "https://auths.dev/compliance/evidence/v1", - "predicate": self, - }); - - json_canon::to_string(&statement) - .map_err(|e| ComplianceQueryError::Canonicalize(e.to_string())) - } -} - /// Load the pinned witness-diversity policy, **failing closed**. /// /// Mirrors the monitor/cosigner: with no pinned policy path (the reality until an @@ -324,9 +202,10 @@ pub fn build_evidence_pack( /// Build an **offline-verifiable** compliance evidence pack. /// /// Identical to [`build_evidence_pack`] but embeds the org's KEL material as an -/// [`AirGappedOrgBundle`] (org KEL + every member KEL + off-boarding records + -/// pinned root), so [`verify_evidence_pack_offline`] can re-derive every row's -/// authority and check each row's transparency proof with **zero network**. +/// [`AirGappedOrgBundle`](crate::domains::org::bundle::AirGappedOrgBundle) +/// (org KEL + every member KEL + off-boarding records + pinned root), so +/// [`verify_evidence_pack_offline`] can re-derive every row's authority and +/// check each row's transparency proof with **zero network**. /// /// Args: /// * `ctx`: Auths context (registry, clock — used to build the embedded bundle). @@ -369,139 +248,12 @@ pub fn build_offline_evidence_pack( Ok(pack) } -/// The offline-verification verdict for one evidence row. -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct RowVerdict { - /// The artifact this row covers. - pub artifact_digest: String, - /// The row's signer. - pub signer: IdentityDID, - /// The authority recorded in the row. - pub authority_at_release: AuthorityAtSigning, - /// Whether re-deriving authority from the embedded KEL matches the row's - /// recorded verdict (a tampered row flips this to `false`). - pub authority_consistent: bool, - /// Whether the row's transparency inclusion/consistency proof verified, or - /// `None` when the row carries no transparency evidence. - pub transparency_verified: Option, -} - -/// Verify the transparency inclusion (and consistency) of one row, offline. -fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), ComplianceQueryError> { - t.inclusion_proof.verify(&t.leaf_hash).map_err(|e| { - ComplianceQueryError::OfflineVerification(format!("inclusion proof did not verify: {e}")) - })?; - - let checkpoint_root = t.signed_checkpoint.checkpoint.root; - let checkpoint_size = t.signed_checkpoint.checkpoint.size; - - match &t.consistency_proof { - Some(c) => { - if c.old_root != t.inclusion_proof.root || c.old_size != t.inclusion_proof.size { - return Err(ComplianceQueryError::OfflineVerification( - "consistency proof old root/size does not match the inclusion proof".into(), - )); - } - if c.new_root != checkpoint_root || c.new_size != checkpoint_size { - return Err(ComplianceQueryError::OfflineVerification( - "consistency proof new root/size does not match the signed checkpoint".into(), - )); - } - c.verify().map_err(|e| { - ComplianceQueryError::OfflineVerification(format!( - "consistency proof did not verify: {e}" - )) - })?; - } - None => { - if t.inclusion_proof.root != checkpoint_root - || t.inclusion_proof.size != checkpoint_size - { - return Err(ComplianceQueryError::OfflineVerification( - "inclusion proof is not against the signed checkpoint and no consistency proof was provided".into(), - )); - } - } - } - Ok(()) -} - -/// Verify an offline evidence pack with **zero network**. -/// -/// Checks the embedded [`AirGappedOrgBundle`] integrity (every event self-addresses), -/// confirms the org is a pinned root, flags KEL duplicity, then for each row -/// re-derives authority-at-release from the embedded KEL (tamper check) and verifies -/// any transparency inclusion/consistency proof. The checkpoint **signature** trust -/// (that the log operator signed the root) is a separate axis requiring a pinned log -/// key ([`auths_transparency::verify_checkpoint_signature`]); this function proves -/// the Merkle membership, not the log operator's identity. -/// -/// Args: -/// * `pack`: The pack to verify (must have been built by [`build_offline_evidence_pack`]). -/// * `pinned_roots`: The verifier's pinned trust roots. -/// -/// Usage: -/// ```ignore -/// let verdicts = verify_evidence_pack_offline(&pack, &roots)?; -/// assert!(verdicts.iter().all(|v| v.authority_consistent)); -/// ``` -pub fn verify_evidence_pack_offline( - pack: &EvidencePack, - pinned_roots: &[IdentityDID], -) -> Result, ComplianceQueryError> { - let bundle = pack.org_bundle.as_ref().ok_or_else(|| { - ComplianceQueryError::OfflineVerification( - "pack carries no embedded org bundle — not an offline-verifiable pack".into(), - ) - })?; - - let report = verify_org_bundle(bundle, pinned_roots, None) - .map_err(|e| ComplianceQueryError::OfflineVerification(e.to_string()))?; - if !report.root_pinned { - return Err(ComplianceQueryError::OfflineVerification(format!( - "org {} is not in the pinned trust roots", - bundle.org_did.as_str() - ))); - } - if report.duplicity_detected { - return Err(ComplianceQueryError::OfflineVerification( - "org KEL shows duplicity (same-seq divergent SAIDs)".into(), - )); - } - - let mut verdicts = Vec::with_capacity(pack.rows.len()); - for row in &pack.rows { - let signer_prefix = Prefix::new_unchecked( - row.signer - .as_str() - .strip_prefix("did:keri:") - .unwrap_or(row.signer.as_str()) - .to_string(), - ); - let rederived = classify_authority_in_bundle(bundle, &signer_prefix, row.signed_at); - let authority_consistent = rederived == row.authority_at_release; - - let transparency_verified = row - .transparency - .as_ref() - .map(|t| verify_transparency_inclusion(t).is_ok()); - - verdicts.push(RowVerdict { - artifact_digest: row.artifact_digest.clone(), - signer: row.signer.clone(), - authority_at_release: row.authority_at_release.clone(), - authority_consistent, - transparency_verified, - }); - } - Ok(verdicts) -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; - use auths_transparency::types::LogOrigin; + use auths_transparency::HonestyCeiling; + use auths_verifier::org_bundle::AuthorityAtSigning; fn fixed_now() -> DateTime { DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z") @@ -524,50 +276,17 @@ mod tests { framework: ComplianceFramework::Slsa, equivocation_visibility: single_operator_ceiling(), generated_at: fixed_now(), - rows: vec![ - EvidenceRow { - artifact_digest: "sha256:aa".into(), - signer: IdentityDID::new_unchecked("did:keri:EAlice"), - authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation, - signed_at: Some(7), - transparency: None, - }, - EvidenceRow { - artifact_digest: "sha256:bb".into(), - signer: IdentityDID::new_unchecked("did:keri:EBob"), - authority_at_release: AuthorityAtSigning::RejectedRevokedPositionUnknown { - revoked_at: 12, - }, - signed_at: None, - transparency: None, - }, - ], + rows: vec![EvidenceRow { + artifact_digest: "sha256:aa".into(), + signer: IdentityDID::new_unchecked("did:keri:EAlice"), + authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation, + signed_at: Some(7), + transparency: None, + }], org_bundle: None, } } - #[test] - fn canonicalize_is_deterministic() { - let a = sample_pack().canonicalize().unwrap(); - let b = sample_pack().canonicalize().unwrap(); - assert_eq!( - a, b, - "same inputs must produce byte-identical canonical bytes" - ); - } - - #[test] - fn pack_round_trips_through_json() { - let pack = sample_pack(); - let json = pack.canonicalize().unwrap(); - let back = EvidencePack::from_json(&json).unwrap(); - assert_eq!( - json, - back.canonicalize().unwrap(), - "canonical JSON must round-trip byte-identically" - ); - } - #[test] fn pack_embeds_single_operator_ceiling_not_a_bare_flag() { let pack = sample_pack(); @@ -577,90 +296,4 @@ mod tests { assert!(!json.contains("non_equivocation")); assert!(json.contains("not yet independent")); } - - #[test] - fn position_unknown_is_represented_honestly_not_authorized() { - let json = sample_pack().canonicalize().unwrap(); - assert!(json.contains("rejected_revoked_position_unknown")); - // The unclassifiable row must NOT be silently rendered as authorized. - let bob_authorized = json.matches("authorized_before_revocation").count(); - assert_eq!( - bob_authorized, 1, - "only Alice is authorized-before-revocation" - ); - } - - #[test] - fn intoto_statement_carries_subjects_and_predicate_type() { - let stmt = sample_pack().to_intoto_statement().unwrap(); - assert!(stmt.contains("https://in-toto.io/Statement/v1")); - assert!(stmt.contains("https://auths.dev/compliance/evidence/v1")); - assert!(stmt.contains("\"sha256\":\"aa\"")); - // The predicate carries the authority verdicts. - assert!(stmt.contains("authority_at_signing")); - } - - fn signed_checkpoint_at(size: u64, root: MerkleHash) -> SignedCheckpoint { - use auths_transparency::checkpoint::Checkpoint; - use auths_verifier::{Ed25519PublicKey, Ed25519Signature}; - SignedCheckpoint { - checkpoint: Checkpoint { - origin: LogOrigin::new("auths.dev/log").unwrap(), - size, - root, - timestamp: fixed_now(), - }, - log_signature: Ed25519Signature::from_bytes([0u8; 64]), - log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), - witnesses: vec![], - ecdsa_checkpoint_signature: None, - ecdsa_checkpoint_key: None, - } - } - - #[test] - fn transparency_inclusion_against_checkpoint_verifies() { - use auths_transparency::merkle::{hash_children, hash_leaf}; - let a = hash_leaf(b"artifact-a"); - let b = hash_leaf(b"artifact-b"); - let root = hash_children(&a, &b); - - let t = TransparencyInclusion { - leaf_hash: a, - inclusion_proof: InclusionProof { - index: 0, - size: 2, - root, - hashes: vec![b], - }, - signed_checkpoint: signed_checkpoint_at(2, root), - consistency_proof: None, - }; - verify_transparency_inclusion(&t).expect("inclusion against the checkpoint verifies"); - } - - #[test] - fn transparency_inclusion_mismatched_checkpoint_fails() { - use auths_transparency::merkle::{hash_children, hash_leaf}; - let a = hash_leaf(b"artifact-a"); - let b = hash_leaf(b"artifact-b"); - let root = hash_children(&a, &b); - - let t = TransparencyInclusion { - leaf_hash: a, - inclusion_proof: InclusionProof { - index: 0, - size: 2, - root, - hashes: vec![b], - }, - // A checkpoint over a DIFFERENT root with no consistency proof must fail. - signed_checkpoint: signed_checkpoint_at(2, MerkleHash::from_bytes([0x99; 32])), - consistency_proof: None, - }; - assert!( - verify_transparency_inclusion(&t).is_err(), - "inclusion not anchored to the checkpoint must fail closed" - ); - } } diff --git a/crates/auths-sdk/src/domains/org/audit.rs b/crates/auths-sdk/src/domains/org/audit.rs index c374d06e..7791674b 100644 --- a/crates/auths-sdk/src/domains/org/audit.rs +++ b/crates/auths-sdk/src/domains/org/audit.rs @@ -8,7 +8,8 @@ //! org-membership case, reading the revocation position from the org KEL. use auths_id::keri::types::Prefix; -use serde::{Deserialize, Serialize}; + +pub use auths_verifier::org_bundle::AuthorityAtSigning; use crate::context::AuthsContext; use crate::domains::org::delegation::{OrgKelSnapshot, list_members}; @@ -17,51 +18,6 @@ use crate::domains::org::offboarding::{ SignedOffboardingRecord, find_revocation_event, load_offboarding_record, }; -/// A member's authority at the moment an artifact was signed — a closed sum ordered -/// strictly by **KEL position** (never wall-clock). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "authority_at_signing", rename_all = "snake_case")] -pub enum AuthorityAtSigning { - /// The member's authority was live at the signing position — the artifact was - /// signed strictly before the revocation, or the member was never revoked. - AuthorizedBeforeRevocation, - /// The artifact was signed at or after the org revoked the member, by KEL - /// position. Carries the exact revocation position. - RejectedAfterRevocation { - /// The KEL position at which the org anchored the revocation. - #[serde(with = "u128_str")] - revoked_at: u128, - }, - /// The org revoked the member but the artifact carries no in-band signing - /// position, so it cannot be ordered — conservatively rejected (mirrors the - /// verifier's no-position default). - RejectedRevokedPositionUnknown { - /// The KEL position at which the org anchored the revocation. - #[serde(with = "u128_str")] - revoked_at: u128, - }, - /// The org never delegated this member — there is no authority to classify. - NeverDelegated, -} - -/// (De)serialize a `u128` KEL position as a decimal string. -/// -/// JSON numbers lose precision above 2^53, and serde's internally-tagged-enum -/// buffer cannot round-trip 128-bit integers — so KEL positions travel as strings -/// (the same convention [`auths_keri::KeriSequence`] uses for its hex form). -mod u128_str { - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(value: &u128, serializer: S) -> Result { - serializer.serialize_str(&value.to_string()) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) - } -} - /// Classify a member's authority at an artifact's signing position, ordered by KEL /// position relative to the org's revocation. /// diff --git a/crates/auths-sdk/src/domains/org/bundle.rs b/crates/auths-sdk/src/domains/org/bundle.rs index 30b98d45..562047e2 100644 --- a/crates/auths-sdk/src/domains/org/bundle.rs +++ b/crates/auths-sdk/src/domains/org/bundle.rs @@ -1,4 +1,4 @@ -//! Air-gapped org provenance bundle — a self-contained, URL-free artifact. +//! Air-gapped org provenance bundle — the registry-walking **builder**. //! //! Packs everything an **offline** verifier needs to reproduce a first-party //! org/member provenance verdict with the network cable unplugged: the org KEL @@ -6,133 +6,34 @@ //! and revocation seals), each delegated member's own KEL, the durable off-boarding //! records ([`crate::domains::org::offboarding`]), and the pinned trust roots. //! -//! Distinct in name and type from the transparency-log [`OfflineBundle`] — that one +//! The wire types ([`AirGappedOrgBundle`], [`BundledKel`]) and the pure +//! verification live in [`auths_verifier::org_bundle`] so every surface — +//! native, FFI, browser WASM — shares one contract; this module re-exports +//! them and keeps only the builder, which needs a live registry. +//! +//! Distinct in name and type from the transparency-log `OfflineBundle` — that one //! is Merkle/inclusion-proof based and built **server-side by the log registry**; //! this one is **first-party**, KEL/delegation based, and has **zero log-server //! dependency**. Do not conflate them in docs or the deployment kit. //! -//! **URL-free:** the bundle contains no registry / OOBI / witness URLs — air-gapped -//! verification cannot phone home (enforced by tests). Identities are typed -//! ([`IdentityDID`] / [`Prefix`]) so a tampered or malformed identifier fails closed -//! at deserialization rather than flowing through as an opaque string. -//! //! **Size:** [`build_org_bundle`] loads each KEL fully into memory (it reuses the //! same `visit_events` collection [`crate::domains::org::delegation`] uses). For a //! very large org this is O(total KEL bytes) resident; streaming/segmented bundles //! are a tracked follow-up. Typical design-partner orgs are well within memory. -//! -//! [`OfflineBundle`]: https://docs.rs/auths-transparency use std::ops::ControlFlow; -use auths_id::keri::Event; use auths_id::keri::types::Prefix; use auths_verifier::types::IdentityDID; -use serde::{Deserialize, Serialize}; + +pub use auths_verifier::org_bundle::{ + AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION, AirGappedOrgBundle, BundledKel, +}; use crate::context::AuthsContext; use crate::domains::org::audit::list_offboarding_records; use crate::domains::org::delegation::list_members; use crate::domains::org::error::OrgError; -use crate::domains::org::offboarding::SignedOffboardingRecord; -use crate::domains::org::offline_verify::authenticate_bundled_kel; - -/// Schema version of the air-gapped org bundle wire format. Bump on any -/// breaking change to [`AirGappedOrgBundle`]. -pub const AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION: u32 = 1; - -/// One identifier's KEL plus its per-event signature attachments. -/// -/// `attachments[i]` is the hex-encoded CESR attachment for `events[i]` (empty when -/// the backend exposes none for that position). Carried so an offline verifier can -/// check signatures, not just recompute SAIDs. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct BundledKel { - /// The identifier's KEL prefix (validated on deserialize). - pub prefix: Prefix, - /// The KEL events, oldest first. - pub events: Vec, - /// Hex-encoded CESR signature attachments, parallel to `events`. - pub attachments: Vec, -} - -/// A self-contained, URL-free bundle for offline first-party org/member provenance. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AirGappedOrgBundle { - /// Wire-format schema version ([`AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION`]). - pub schema_version: u32, - /// The org's `did:keri:` (validated on deserialize). - pub org_did: IdentityDID, - /// The org's KEL prefix. - pub org_prefix: Prefix, - /// The org KEL sequence at build time — the offline verifier reports - /// "verified as-of KEL position X". - pub built_at_org_seq: u128, - /// Build timestamp (RFC 3339). Provenance only — authority ordering is by KEL - /// position, never this wall-clock value. - pub built_at: String, - /// The org's KEL (delegation, scope, and revocation seals all ride here). - pub org_kel: BundledKel, - /// Each delegated member's own KEL (live and revoked members both included so - /// the verifier can classify any artifact). - pub member_kels: Vec, - /// Durable, signed off-boarding records ([`crate::domains::org::offboarding`]). - pub offboarding_records: Vec, - /// Pinned trust roots — for a first-party org bundle, the org itself. The - /// verifier trusts these DIDs and reads their keys from the bundled KEL. - pub pinned_roots: Vec, -} - -impl AirGappedOrgBundle { - /// Serialize the bundle to deterministic, canonical JSON (RFC 8785 via - /// `json-canon`) — the on-disk/wire form. - /// - /// Usage: - /// ```ignore - /// std::fs::write(out, bundle.to_canonical_json()?)?; - /// ``` - pub fn to_canonical_json(&self) -> Result { - json_canon::to_string(self) - .map_err(|e| OrgError::Signing(format!("canonicalize org bundle: {e}"))) - } - - /// Parse a bundle from its JSON form. Typed identifiers fail closed on malformed - /// input. - /// - /// Usage: - /// ```ignore - /// let bundle = AirGappedOrgBundle::from_json(&std::fs::read_to_string(path)?)?; - /// ``` - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| OrgError::Signing(format!("parse org bundle: {e}"))) - } - - /// The org's authenticated key state, derived from the bundled org KEL alone. - /// - /// Authenticates the embedded org KEL (RT-002 — every event SAID-correct AND - /// signed by the controlling key-state, not merely structurally valid) and - /// returns the resolved [`auths_keri::KeyState`]. The org KEL is the root of - /// trust, so it authenticates with no delegator lookup. - /// - /// This is the one public call for "give me the org's authenticated key state - /// from evidence alone" — the only trust-rooted source of the org's *current* - /// verkey available offline (e.g. to verify a DSSE envelope the org signed). - /// Every downstream verifier (CI gate, browser widget, third-party audit tool) - /// shares it instead of re-implementing the authenticate-then-resolve path. - /// - /// Fails closed ([`OrgError::BundleIntegrity`]) on a tampered event, a length - /// mismatch between events and attachments, an unparseable attachment, or a - /// signature that does not verify. - /// - /// Usage: - /// ```ignore - /// let state = bundle.authenticated_org_state()?; - /// let org_verkey = state.current_key(); - /// ``` - pub fn authenticated_org_state(&self) -> Result { - authenticate_bundled_kel(&self.org_kel, None) - } -} /// Collect one identifier's KEL and per-event attachments into a [`BundledKel`]. fn collect_bundled_kel(ctx: &AuthsContext, prefix: &Prefix) -> Result { diff --git a/crates/auths-sdk/src/domains/org/error.rs b/crates/auths-sdk/src/domains/org/error.rs index f8528207..7d247c9e 100644 --- a/crates/auths-sdk/src/domains/org/error.rs +++ b/crates/auths-sdk/src/domains/org/error.rs @@ -129,31 +129,11 @@ pub enum OrgError { #[error("failed to create admin attestation: {0}")] Attestation(#[source] auths_verifier::error::AttestationError), - /// An air-gapped bundle event failed its self-addressing integrity check — - /// recomputing the SAID did not match the stored `d` (the bundle was tampered). - #[error("bundle integrity failure for '{id}': {reason}")] - BundleIntegrity { - /// The identifier whose KEL failed integrity. - id: String, - /// Why integrity failed. - reason: String, - }, - - /// The bundle delegates a member on the org KEL but omits that member's own KEL — - /// the bundle is incomplete and cannot be verified. Fail closed. - #[error("bundle is missing the KEL for delegated member '{member}'")] - BundleMissingMemberKel { - /// The member's `did:keri:`. - member: String, - }, - - /// The queried member has no delegation seal in the org KEL — the org never - /// delegated it, so there is no authority to verify. Fail closed. - #[error("member '{member}' has no delegation seal in the org KEL")] - BundleMissingDelegatorSeal { - /// The member's `did:keri:`. - member: String, - }, + /// An air-gapped bundle failed offline verification (tampered event, missing + /// member KEL, missing delegation seal, or an invalid off-boarding record). + /// The fail-closed detail is the typed [`auths_verifier::org_bundle::OrgBundleError`]. + #[error(transparent)] + Bundle(#[from] auths_verifier::org_bundle::OrgBundleError), /// The supplied org policy did not parse or compile (invalid JSON, an invalid /// DID/capability/glob, or it exceeds the policy size/complexity bounds). A @@ -230,9 +210,7 @@ impl AuthsErrorInfo for OrgError { Self::IdentityExists { .. } => "AUTHS-E5615", Self::IdentityInit(_) => "AUTHS-E5616", Self::Attestation(_) => "AUTHS-E5617", - Self::BundleIntegrity { .. } => "AUTHS-E5619", - Self::BundleMissingMemberKel { .. } => "AUTHS-E5620", - Self::BundleMissingDelegatorSeal { .. } => "AUTHS-E5621", + Self::Bundle(e) => e.error_code(), Self::PolicyCompile { .. } => "AUTHS-E5622", Self::PolicyBlobMissing { .. } => "AUTHS-E5623", Self::PolicyIntegrity { .. } => "AUTHS-E5624", @@ -293,12 +271,7 @@ impl AuthsErrorInfo for OrgError { Self::Attestation(_) => Some( "Failed to sign the admin attestation; check your key access with `auths key list`", ), - Self::BundleIntegrity { .. } => Some( - "The bundle was modified after it was produced; obtain a fresh, untampered bundle", - ), - Self::BundleMissingMemberKel { .. } | Self::BundleMissingDelegatorSeal { .. } => { - Some("The bundle is incomplete; re-produce it with `auths org bundle`") - } + Self::Bundle(e) => e.suggestion(), Self::PolicyCompile { .. } => Some( "Fix the policy JSON (a serialized `Expr`); see `auths org policy show` for the current policy", ), diff --git a/crates/auths-sdk/src/domains/org/offboarding.rs b/crates/auths-sdk/src/domains/org/offboarding.rs index da111512..7e85805f 100644 --- a/crates/auths-sdk/src/domains/org/offboarding.rs +++ b/crates/auths-sdk/src/domains/org/offboarding.rs @@ -12,16 +12,23 @@ //! the on-KEL revocation seal fails [`verify_offboarding_record`]. The surface is //! named "offboarding" to avoid colliding with `auths audit` (commit-compliance) and //! the SIEM `AuditEvent` stream. +//! +//! The wire types and the pure verification live in +//! [`auths_verifier::org_bundle`] (so any offline verifier — native, FFI, +//! browser WASM — checks a record from evidence alone); this module keeps the +//! I/O half: signing with the org keychain and persisting to the registry. use std::sync::Arc; use auths_core::signing::{SecureSigner, StorageSigner}; use auths_core::storage::keychain::KeyAlias; use auths_crypto::CurveType; +use auths_id::keri::Said; use auths_id::keri::types::Prefix; -use auths_id::keri::{Event, Said, Seal}; -use auths_keri::KeriPublicKey; -use serde::{Deserialize, Serialize}; + +pub use auths_verifier::org_bundle::{ + OffboardingRecord, SignedOffboardingRecord, find_revocation_event, verify_offboarding_record, +}; use crate::context::AuthsContext; use crate::domains::org::error::OrgError; @@ -35,75 +42,6 @@ fn curve_tag(curve: CurveType) -> &'static str { } } -/// Parse a curve tag back to a [`CurveType`]; unknown/missing defaults to P-256 -/// (the workspace default, per the wire-format rule). -fn curve_from_tag(tag: &str) -> CurveType { - match tag { - "ed25519" => CurveType::Ed25519, - _ => CurveType::P256, - } -} - -/// A typed off-boarding record — the durable evidence emitted alongside a member -/// revocation. Ordering is by **KEL position** (`revoked_at_seq`), never wall-clock. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OffboardingRecord { - /// The org's `did:keri:` (the delegator that off-boarded the member). - pub org_did: String, - /// The off-boarded member's `did:keri:`. - pub member_did: String, - /// The KEL sequence of the org's revocation event — the exact position after - /// which the member's authority is gone. Causal, not wall-clock. - pub revoked_at_seq: u128, - /// The SAID of the org KEL event carrying the revocation seal (anti-forgery - /// binding: the record is only valid against this on-KEL event). - pub revocation_seal_said: String, - /// Optional operator-supplied reason for the off-boarding. - pub reason: Option, - /// The DID that authored the revocation. For a `kt=1` org this is the org DID. - pub operator_did: String, - /// The role the member held at the revocation position (what they lost). - pub prior_role: Option, - /// The capabilities the member held at the revocation position (what they lost). - pub prior_caps: Vec, - /// When the record was recorded (RFC 3339, injected clock). Provenance only — - /// authority ordering is by `revoked_at_seq`, never this timestamp. - pub recorded_at: String, -} - -/// An [`OffboardingRecord`] plus the org's signature over its canonical form. The -/// signature's curve travels in-band (`org_curve`) per the wire-format rule. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignedOffboardingRecord { - /// The signed record payload. - pub record: OffboardingRecord, - /// In-band curve tag for `signature` (`"ed25519"` / `"p256"`). - pub org_curve: String, - /// Hex-encoded org signature over `json_canon(record)`. - pub signature: String, -} - -/// Locate the org KEL event that carries the revocation seal for `member_prefix`. -/// -/// Returns `(seal_event_said, sequence)` for the latest such event, or `None` if the -/// org never revoked the member. The revocation seal is a `Seal::Digest` whose digest -/// equals the member prefix (the convention [`revoke_delegated_device`] writes). -/// -/// [`revoke_delegated_device`]: auths_id::keri::delegation::revoke_delegated_device -pub fn find_revocation_event(org_kel: &[Event], member_prefix: &Prefix) -> Option<(String, u128)> { - let mut found = None; - for event in org_kel { - for seal in event.anchors() { - if let Seal::Digest { d } = seal - && d.as_str() == member_prefix.as_str() - { - found = Some((event.said().as_str().to_string(), event.sequence().value())); - } - } - } - found -} - /// Sign an [`OffboardingRecord`] with the org key, producing tamper-evident evidence. /// /// Signs the `json_canon` of `record` with the org's signing key; the signature's @@ -193,62 +131,3 @@ pub fn load_offboarding_record( None => Ok(None), } } - -/// Verify a signed off-boarding record: the org signature is valid **and** the record -/// is bound to a matching revocation event on the org KEL. -/// -/// Fails closed if the signature does not verify (record tampered) or no org KEL -/// event with the record's `revocation_seal_said` revokes the member at -/// `revoked_at_seq` (seal tampered / record forged). -/// -/// Args: -/// * `signed`: The signed record to verify. -/// * `org_public_key`: The org's current verkey bytes (resolved from its KEL). -/// * `org_curve`: The org key's curve. -/// * `org_kel`: The org's KEL events (oldest first). -/// -/// Usage: -/// ```ignore -/// verify_offboarding_record(&signed, &org_pk, org_curve, &org_kel)?; -/// ``` -pub fn verify_offboarding_record( - signed: &SignedOffboardingRecord, - org_public_key: &[u8], - org_curve: CurveType, - org_kel: &[Event], -) -> Result<(), OrgError> { - if curve_from_tag(&signed.org_curve) != org_curve { - return Err(OrgError::Signing( - "offboarding record curve tag does not match the org key curve".to_string(), - )); - } - - let canonical = json_canon::to_string(&signed.record) - .map_err(|e| OrgError::Signing(format!("canonicalize offboarding record: {e}")))?; - let sig = hex::decode(&signed.signature) - .map_err(|e| OrgError::Signing(format!("decode offboarding signature: {e}")))?; - let key = KeriPublicKey::from_verkey_bytes(org_public_key, org_curve) - .map_err(|e| OrgError::InvalidPublicKey(e.to_string()))?; - key.verify_signature(canonical.as_bytes(), &sig) - .map_err(|e| OrgError::Signing(format!("offboarding signature invalid: {e}")))?; - - let member_prefix = Prefix::new_unchecked( - signed - .record - .member_did - .strip_prefix("did:keri:") - .unwrap_or(&signed.record.member_did) - .to_string(), - ); - match find_revocation_event(org_kel, &member_prefix) { - Some((said, seq)) - if said == signed.record.revocation_seal_said - && seq == signed.record.revoked_at_seq => - { - Ok(()) - } - _ => Err(OrgError::Signing( - "offboarding record is not bound to a matching org KEL revocation seal".to_string(), - )), - } -} diff --git a/crates/auths-sdk/src/domains/org/offline_verify.rs b/crates/auths-sdk/src/domains/org/offline_verify.rs index 378ec7e3..382ed86a 100644 --- a/crates/auths-sdk/src/domains/org/offline_verify.rs +++ b/crates/auths-sdk/src/domains/org/offline_verify.rs @@ -1,273 +1,13 @@ -//! Offline verification of an air-gapped org bundle — a pure, zero-network function -//! of the bundle's contents. +//! Offline verification of an air-gapped org bundle — re-exported from +//! [`auths_verifier::org_bundle`]. //! -//! Reproduces the live org/member provenance verdict from a fn-154.5 -//! [`AirGappedOrgBundle`] with the network cable unplugged: it recomputes every -//! bundled event's SAID (tamper-evident), confirms the org is a **pinned** root, -//! flags KEL duplicity, and classifies a member's authority at a signing position -//! **by KEL position, never wall-clock** (same ordering as -//! [`crate::domains::org::audit::classify_authority_at_signing`], read from the -//! bundle instead of a live registry). -//! -//! Fail-closed: a tampered event (SAID mismatch), a delegated member whose KEL is -//! missing, or a queried member with no delegation seal each yield a **named hard -//! error**, never "valid." A wrong/non-delegating pinned root is reported as -//! `root_pinned = false` (unauthorized), not an error. - -use auths_id::keri::types::Prefix; -use auths_id::keri::{Event, Seal, verify_event_said}; -use auths_verifier::duplicity::{KelEventRef, detect_duplicity}; -use auths_verifier::types::IdentityDID; -use serde::Serialize; - -use crate::domains::org::audit::AuthorityAtSigning; -use crate::domains::org::bundle::{AirGappedOrgBundle, BundledKel}; -use crate::domains::org::error::OrgError; -use crate::domains::org::offboarding::find_revocation_event; - -/// The result of verifying an air-gapped org bundle offline. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct OfflineVerifyReport { - /// The org the bundle is for. - pub org_did: IdentityDID, - /// The org KEL position the bundle (and therefore this verdict) is verified - /// as-of — "verified as-of KEL position X." - pub as_of_org_seq: u128, - /// Whether the org is in the caller's pinned trust roots. `false` = - /// unauthorized (the verdict cannot be trusted), not an error. - pub root_pinned: bool, - /// Whether the org KEL shows duplicity (same seq, divergent SAIDs). Flagged, - /// never silently accepted. - pub duplicity_detected: bool, - /// The queried member's authority at the signing position, when a member + - /// position were supplied. Ordered by KEL position, never wall-clock. - pub authority: Option, -} - -/// Recompute and check every event's SAID in a bundled KEL (tamper detection). -fn check_kel_integrity(kel: &BundledKel) -> Result<(), OrgError> { - for event in &kel.events { - verify_event_said(event).map_err(|e| OrgError::BundleIntegrity { - id: kel.prefix.as_str().to_string(), - reason: e.to_string(), - })?; - } - Ok(()) -} - -/// Authenticate a bundled KEL (RT-002): verify every event is SIGNED by the -/// controlling key-state, not just SAID-correct. Reconstructs `SignedEvent`s from -/// the parallel `events` × hex `attachments` and replays them through -/// `validate_signed_kel`. Fails closed on a length mismatch, an unparseable -/// attachment, or a signature that doesn't verify. -/// -/// Returns the authenticated [`auths_keri::KeyState`] — the only trust-rooted -/// source of the controller's *current* verkey available offline (e.g. to verify -/// a DSSE envelope signed by the org whose KEL the evidence embeds). -/// -/// This is the shared ecosystem primitive: a downstream verifier (CI gate, -/// browser widget, third-party audit tool) holding a [`BundledKel`] gets the -/// controller's authenticated key state from evidence alone in one call, rather -/// than reconstructing `SignedEvent`s and replaying `validate_signed_kel` itself. -/// For an [`AirGappedOrgBundle`], prefer -/// [`AirGappedOrgBundle::authenticated_org_state`]. -pub fn authenticate_bundled_kel( - kel: &BundledKel, - lookup: Option<&dyn auths_keri::DelegatorKelLookup>, -) -> Result { - let integrity_err = |reason: String| OrgError::BundleIntegrity { - id: kel.prefix.as_str().to_string(), - reason, - }; - if kel.attachments.len() != kel.events.len() { - return Err(integrity_err(format!( - "{} events but {} signature attachments — cannot authenticate", - kel.events.len(), - kel.attachments.len() - ))); - } - let mut signed = Vec::with_capacity(kel.events.len()); - for (event, att_hex) in kel.events.iter().zip(kel.attachments.iter()) { - let bytes = - hex::decode(att_hex).map_err(|e| integrity_err(format!("non-hex attachment: {e}")))?; - // A delegated (dip/drt) event's attachment is `-A ++ -G `; - // a plain event's is just `-A `. `parse_delegated_attachment` handles both. - let (sigs, seals) = auths_keri::parse_delegated_attachment(&bytes) - .map_err(|e| integrity_err(format!("unparseable attachment: {e}")))?; - // A dip/drt JSON body carries no source seal (it lives in the attachment); - // restore it so the delegation binding can be authenticated. - let event = rehydrate_event_source_seal(event.clone(), seals.into_iter().next()); - signed.push(auths_keri::SignedEvent::new(event, sigs)); - } - auths_keri::validate_signed_kel(&signed, lookup) - .map_err(|e| integrity_err(format!("KEL signature authentication failed (RT-002): {e}"))) -} - -/// Re-attach a delegated event's source seal from its parsed attachment — the JSON -/// body of a `dip`/`drt` carries none (it lives in the `-G` group). Mirrors the -/// storage layer's `rehydrate_source_seal`. -fn rehydrate_event_source_seal(event: Event, seal: Option) -> Event { - let Some(seal) = seal else { - return event; - }; - match event { - Event::Dip(mut e) => { - e.source_seal = Some(seal); - Event::Dip(e) - } - Event::Drt(mut e) => { - e.source_seal = Some(seal); - Event::Drt(e) - } - other => other, - } -} - -/// Does the org KEL anchor a delegation `KeyEvent` seal for `member_prefix`? -fn is_delegated(org_kel: &[Event], member_prefix: &Prefix) -> bool { - org_kel.iter().any(|event| { - event.anchors().iter().any( - |seal| matches!(seal, Seal::KeyEvent { i, .. } if i.as_str() == member_prefix.as_str()), - ) - }) -} - -/// Classify a member's authority at `signed_at` from an air-gapped bundle's org KEL — -/// the offline mirror of [`crate::domains::org::audit::classify_authority_at_signing`], -/// for re-deriving compliance evidence-pack rows with zero network. -/// -/// Args: -/// * `bundle`: The air-gapped org bundle (its org KEL is the authority source). -/// * `member_prefix`: The member to classify. -/// * `signed_at`: The artifact's in-band signing position, if any. -/// -/// Usage: -/// ```ignore -/// let verdict = classify_authority_in_bundle(&bundle, &member, Some(41)); -/// ``` -pub fn classify_authority_in_bundle( - bundle: &AirGappedOrgBundle, - member_prefix: &Prefix, - signed_at: Option, -) -> AuthorityAtSigning { - classify_from_bundle(&bundle.org_kel.events, member_prefix, signed_at) -} - -/// Classify a member's authority at `signed_at`, read purely from the bundle's org -/// KEL — the offline mirror of [`crate::domains::org::audit::classify_authority_at_signing`]. -fn classify_from_bundle( - org_kel: &[Event], - member_prefix: &Prefix, - signed_at: Option, -) -> AuthorityAtSigning { - if !is_delegated(org_kel, member_prefix) { - return AuthorityAtSigning::NeverDelegated; - } - match find_revocation_event(org_kel, member_prefix) { - None => AuthorityAtSigning::AuthorizedBeforeRevocation, - Some((_, revoked_at)) => match signed_at { - None => AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }, - Some(seq) if seq < revoked_at => AuthorityAtSigning::AuthorizedBeforeRevocation, - Some(_) => AuthorityAtSigning::RejectedAfterRevocation { revoked_at }, - }, - } -} - -/// Verify an air-gapped org bundle offline. -/// -/// Pure and network-free. Checks bundle integrity (every event's SAID), confirms the -/// org is pinned, flags org-KEL duplicity, and — when `query` supplies a member and -/// optional signing position — classifies that member's authority by KEL position. -/// -/// Fail-closed errors (never "valid"): [`OrgError::BundleIntegrity`] (a tampered -/// event), [`OrgError::BundleMissingMemberKel`] (a delegated member's KEL is absent), -/// [`OrgError::BundleMissingDelegatorSeal`] (a queried member was never delegated). -/// -/// Args: -/// * `bundle`: The air-gapped bundle to verify. -/// * `pinned_roots`: The caller's pinned trust roots (e.g. from `.auths/roots`). -/// * `query`: Optional `(member_prefix, signed_at)` to classify a specific artifact. -/// -/// Usage: -/// ```ignore -/// let report = verify_org_bundle(&bundle, &roots, Some((&member, Some(41))))?; -/// assert!(report.root_pinned); -/// ``` -pub fn verify_org_bundle( - bundle: &AirGappedOrgBundle, - pinned_roots: &[IdentityDID], - query: Option<(&Prefix, Option)>, -) -> Result { - // 1. Integrity + AUTHENTICATION (RT-002): every bundled event must self-address - // (SAID) AND be signed by the controlling key-state — not just structurally - // valid. The org KEL is the root of trust (no delegator); member KELs are - // authenticated against the org as delegator. - check_kel_integrity(&bundle.org_kel)?; - authenticate_bundled_kel(&bundle.org_kel, None)?; - let org_lookup = auths_keri::KelSealIndex::from_events(&bundle.org_kel.events); - for member_kel in &bundle.member_kels { - check_kel_integrity(member_kel)?; - authenticate_bundled_kel(member_kel, Some(&org_lookup))?; - } - - // 2. Completeness: every member the org delegated must ship its own KEL. - for event in &bundle.org_kel.events { - for seal in event.anchors() { - if let Seal::KeyEvent { i, .. } = seal { - let present = bundle - .member_kels - .iter() - .any(|m| m.prefix.as_str() == i.as_str()); - if !present { - return Err(OrgError::BundleMissingMemberKel { - member: format!("did:keri:{}", i.as_str()), - }); - } - } - } - } - - // 3. Trust: is the org a pinned root? (false = unauthorized, not an error.) - let root_pinned = pinned_roots - .iter() - .any(|r| r.as_str() == bundle.org_did.as_str()); - - // 4. Duplicity: same-seq divergent SAIDs on the org KEL — flag, never accept. - let org_did_str = bundle.org_did.as_str().to_string(); - let refs: Vec> = bundle - .org_kel - .events - .iter() - .map(|e| KelEventRef { - prefix: org_did_str.as_str(), - seq: e.sequence().value() as u64, - said: e.said().as_str(), - }) - .collect(); - let duplicity_detected = detect_duplicity(&refs).is_diverging(); - - // 5. Authority classification for the queried member, by KEL position. - let authority = match query { - Some((member_prefix, signed_at)) => { - if !is_delegated(&bundle.org_kel.events, member_prefix) { - return Err(OrgError::BundleMissingDelegatorSeal { - member: format!("did:keri:{}", member_prefix.as_str()), - }); - } - Some(classify_from_bundle( - &bundle.org_kel.events, - member_prefix, - signed_at, - )) - } - None => None, - }; - - Ok(OfflineVerifyReport { - org_did: bundle.org_did.clone(), - as_of_org_seq: bundle.built_at_org_seq, - root_pinned, - duplicity_detected, - authority, - }) -} +//! The verification core is pure and network-free, so it lives in the leaf +//! verifier crate where every surface (native CLI, FFI, browser WASM) shares +//! one implementation. This module re-exports it for SDK callers; the bundle +//! *builder* (which walks a live registry) is in +//! [`crate::domains::org::bundle`]. + +pub use auths_verifier::org_bundle::{ + OfflineVerifyReport, OrgBundleError, authenticate_bundled_kel, classify_authority_in_bundle, + verify_org_bundle, +}; diff --git a/crates/auths-sdk/tests/cases/org_delegation.rs b/crates/auths-sdk/tests/cases/org_delegation.rs index a976d175..7932b2da 100644 --- a/crates/auths-sdk/tests/cases/org_delegation.rs +++ b/crates/auths-sdk/tests/cases/org_delegation.rs @@ -25,6 +25,7 @@ use auths_sdk::domains::identity::types::{ CreateDeveloperIdentityConfig, IdentityConfig, InitializeResult, }; use auths_sdk::domains::org::error::OrgError; +use auths_sdk::domains::org::offline_verify::OrgBundleError; use auths_sdk::domains::org::{ AuthorityAtSigning, add_existing_member, add_member, classify_authority_at_signing, create_org, list_members, list_offboarding_records, load_offboarding_record, member_policy_context, @@ -663,8 +664,8 @@ fn offline_verify_reproduces_live_verdict_and_fails_closed() { let err = verify_org_bundle(&partial, &roots, None) .expect_err("an incomplete bundle must fail closed, never 'valid'"); assert!( - matches!(err, OrgError::BundleMissingMemberKel { .. }), - "expected BundleMissingMemberKel, got {err:?}" + matches!(err, OrgBundleError::MissingMemberKel { .. }), + "expected MissingMemberKel, got {err:?}" ); } @@ -702,8 +703,8 @@ fn offline_verify_rejects_forged_kel_signature() { let err = verify_org_bundle(&bundle, &roots, None) .expect_err("a bundle with a forged KEL signature must fail closed (RT-002)"); assert!( - matches!(err, OrgError::BundleIntegrity { .. }), - "expected BundleIntegrity, got {err:?}" + matches!(err, OrgBundleError::Integrity { .. }), + "expected Integrity, got {err:?}" ); } @@ -738,8 +739,8 @@ fn authenticated_org_state_resolves_org_verkey_from_evidence_alone() { .authenticated_org_state() .expect_err("a forged org KEL signature must fail closed"); assert!( - matches!(err, OrgError::BundleIntegrity { .. }), - "expected BundleIntegrity, got {err:?}" + matches!(err, OrgBundleError::Integrity { .. }), + "expected Integrity, got {err:?}" ); } diff --git a/crates/auths-transparency/src/checkpoint.rs b/crates/auths-transparency/src/checkpoint.rs index 42e47943..072354f4 100644 --- a/crates/auths-transparency/src/checkpoint.rs +++ b/crates/auths-transparency/src/checkpoint.rs @@ -1,153 +1,7 @@ -use auths_verifier::{Ed25519PublicKey, Ed25519Signature}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +//! Log checkpoints (signed tree heads) and witness cosignatures. +//! +//! The types live in `auths_verifier::tlog` so offline verifiers can parse +//! and check checkpoints without linking the log; this module re-exports +//! them for the log-construction side. -use crate::types::{LogOrigin, MerkleHash}; - -/// An unsigned transparency log checkpoint. -/// -/// Args: -/// * `origin` — Log origin string (e.g., "auths.dev/log"). -/// * `size` — Number of entries in the log at this checkpoint. -/// * `root` — Merkle root hash of the log at this size. -/// * `timestamp` — When the checkpoint was created. -/// -/// Usage: -/// ```ignore -/// let cp = Checkpoint { -/// origin: LogOrigin::new("auths.dev/log")?, -/// size: 42, -/// root: merkle_root, -/// timestamp: Utc::now(), -/// }; -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow(missing_docs)] -pub struct Checkpoint { - pub origin: LogOrigin, - pub size: u64, - pub root: MerkleHash, - pub timestamp: DateTime, -} - -impl Checkpoint { - /// Serialize to the C2SP checkpoint body format (three lines: origin, size, base64 hash). - pub fn to_note_body(&self) -> String { - format!( - "{}\n{}\n{}\n", - self.origin, - self.size, - self.root.to_base64() - ) - } - - /// Parse from C2SP checkpoint body lines. - pub fn from_note_body( - body: &str, - timestamp: DateTime, - ) -> Result { - let lines: Vec<&str> = body.lines().collect(); - if lines.len() < 3 { - return Err(crate::error::TransparencyError::InvalidNote( - "checkpoint body must have at least 3 lines".into(), - )); - } - let origin = LogOrigin::new(lines[0])?; - let size: u64 = lines[1].parse().map_err(|e: std::num::ParseIntError| { - crate::error::TransparencyError::InvalidNote(e.to_string()) - })?; - let root = MerkleHash::from_base64(lines[2])?; - Ok(Self { - origin, - size, - root, - timestamp, - }) - } -} - -/// A checkpoint signed by the log operator (and optionally witnesses). -/// -/// Args: -/// * `checkpoint` — The unsigned checkpoint data. -/// * `log_signature` — Ed25519 signature from the log's signing key. -/// * `log_public_key` — The log operator's public key. -/// * `witnesses` — Optional witness cosignatures. -/// -/// Usage: -/// ```ignore -/// let signed = SignedCheckpoint { -/// checkpoint, -/// log_signature: sig, -/// log_public_key: log_pk, -/// witnesses: vec![], -/// }; -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow(missing_docs)] -pub struct SignedCheckpoint { - pub checkpoint: Checkpoint, - pub log_signature: Ed25519Signature, - pub log_public_key: Ed25519PublicKey, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub witnesses: Vec, - /// ECDSA P-256 checkpoint signature (DER-encoded). Present when the log - /// uses ECDSA instead of Ed25519 (e.g., Rekor production shard). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ecdsa_checkpoint_signature: Option, - /// ECDSA P-256 public key for checkpoint verification (PKIX DER). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ecdsa_checkpoint_key: Option, -} - -/// A witness cosignature on a checkpoint. -/// -/// Witnesses independently verify the checkpoint and add their signature -/// to increase trust in the log's consistency claims. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow(missing_docs)] -pub struct WitnessCosignature { - pub witness_name: String, - pub witness_public_key: Ed25519PublicKey, - pub signature: Ed25519Signature, - pub timestamp: DateTime, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn checkpoint_note_body_roundtrip() { - let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let cp = Checkpoint { - origin: LogOrigin::new("auths.dev/log").unwrap(), - size: 42, - root: MerkleHash::from_bytes([0xab; 32]), - timestamp: ts, - }; - let body = cp.to_note_body(); - let parsed = Checkpoint::from_note_body(&body, ts).unwrap(); - assert_eq!(cp.origin, parsed.origin); - assert_eq!(cp.size, parsed.size); - assert_eq!(cp.root, parsed.root); - } - - #[test] - fn checkpoint_json_roundtrip() { - let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let cp = Checkpoint { - origin: LogOrigin::new("auths.dev/log").unwrap(), - size: 100, - root: MerkleHash::from_bytes([0x01; 32]), - timestamp: ts, - }; - let json = serde_json::to_string(&cp).unwrap(); - let back: Checkpoint = serde_json::from_str(&json).unwrap(); - assert_eq!(cp, back); - } -} +pub use auths_verifier::tlog::{Checkpoint, SignedCheckpoint, WitnessCosignature}; diff --git a/crates/auths-transparency/src/error.rs b/crates/auths-transparency/src/error.rs index d614f54a..7aecad79 100644 --- a/crates/auths-transparency/src/error.rs +++ b/crates/auths-transparency/src/error.rs @@ -1,43 +1,10 @@ -/// Errors from transparency log operations. -#[derive(Debug, Clone, thiserror::Error)] -#[allow(missing_docs)] -pub enum TransparencyError { - /// Invalid Merkle proof structure. - #[error("invalid proof: {0}")] - InvalidProof(String), +//! The transparency-log error contract. +//! +//! The type itself lives in `auths_verifier::tlog` — the leaf verification +//! crate every surface shares — so offline proof verification (native, FFI, +//! browser WASM) and log construction/storage report through one error type. - /// Merkle root mismatch during verification. - #[error("root mismatch: expected {expected}, got {actual}")] - RootMismatch { expected: String, actual: String }, - - /// Invalid signed note format. - #[error("invalid note: {0}")] - InvalidNote(String), - - /// Signature verification failed on a checkpoint note. - #[error("invalid checkpoint signature")] - InvalidCheckpointSignature, - - /// Tile path encoding error. - #[error("invalid tile path: {0}")] - InvalidTilePath(String), - - /// Invalid log origin string. - #[error("invalid log origin: {0}")] - InvalidOrigin(String), - - /// Entry serialization or deserialization failure. - #[error("entry error: {0}")] - EntryError(String), - - /// Consistency proof verification failed. - #[error("consistency check failed: {0}")] - ConsistencyError(String), - - /// Storage backend error. - #[error("store error: {0}")] - StoreError(String), -} +pub use auths_verifier::tlog::TransparencyError; /// Convenience alias for transparency operations. pub type Result = std::result::Result; diff --git a/crates/auths-transparency/src/merkle.rs b/crates/auths-transparency/src/merkle.rs index 26943eae..0d5877ca 100644 --- a/crates/auths-transparency/src/merkle.rs +++ b/crates/auths-transparency/src/merkle.rs @@ -1,603 +1,9 @@ -use sha2::{Digest, Sha256}; - -use crate::error::TransparencyError; -use crate::types::MerkleHash; - -/// RFC 6962 leaf domain separator. -const LEAF_PREFIX: u8 = 0x00; -/// RFC 6962 interior node domain separator. -const NODE_PREFIX: u8 = 0x01; - -/// Hash a leaf value with RFC 6962 domain separation: `SHA-256(0x00 || data)`. -/// -/// Args: -/// * `data` — Raw leaf bytes (typically canonical JSON of an entry). -/// -/// Usage: -/// ```ignore -/// let leaf = hash_leaf(b"entry data"); -/// ``` -pub fn hash_leaf(data: &[u8]) -> MerkleHash { - let mut hasher = Sha256::new(); - hasher.update([LEAF_PREFIX]); - hasher.update(data); - let digest = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest); - MerkleHash::from_bytes(out) -} - -/// Hash two child nodes with RFC 6962 domain separation: `SHA-256(0x01 || left || right)`. -/// -/// Args: -/// * `left` — Left child hash. -/// * `right` — Right child hash. -/// -/// Usage: -/// ```ignore -/// let parent = hash_children(&left_hash, &right_hash); -/// ``` -pub fn hash_children(left: &MerkleHash, right: &MerkleHash) -> MerkleHash { - let mut hasher = Sha256::new(); - hasher.update([NODE_PREFIX]); - hasher.update(left.as_bytes()); - hasher.update(right.as_bytes()); - let digest = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest); - MerkleHash::from_bytes(out) -} - -/// Verify a Merkle inclusion proof for a leaf at a given index in a tree of `size` leaves. -/// -/// Uses RFC 6962 proof verification: walk from the leaf hash up to the root, -/// combining with proof hashes left or right depending on the index bits. -/// -/// Args: -/// * `leaf_hash` — The hash of the leaf being proven. -/// * `index` — Zero-based index of the leaf. -/// * `size` — Total number of leaves in the tree. -/// * `proof` — Ordered list of sibling hashes from leaf to root. -/// * `root` — Expected Merkle root. -/// -/// Usage: -/// ```ignore -/// verify_inclusion(&leaf_hash, 5, 16, &proof_hashes, &expected_root)?; -/// ``` -pub fn verify_inclusion( - leaf_hash: &MerkleHash, - index: u64, - size: u64, - proof: &[MerkleHash], - root: &MerkleHash, -) -> Result<(), TransparencyError> { - if size == 0 { - return Err(TransparencyError::InvalidProof("tree size is 0".into())); - } - if index >= size { - return Err(TransparencyError::InvalidProof(format!( - "index {index} >= size {size}" - ))); - } - - let (computed, _) = root_from_inclusion_proof(leaf_hash, index, size, proof)?; - - if computed != *root { - return Err(TransparencyError::RootMismatch { - expected: root.to_string(), - actual: computed.to_string(), - }); - } - Ok(()) -} - -/// Compute the root hash from an inclusion proof. -/// -/// Returns `(root, proof_elements_consumed)`. -fn root_from_inclusion_proof( - leaf_hash: &MerkleHash, - index: u64, - size: u64, - proof: &[MerkleHash], -) -> Result<(MerkleHash, usize), TransparencyError> { - let expected_len = inclusion_proof_length(index, size); - if proof.len() != expected_len { - return Err(TransparencyError::InvalidProof(format!( - "expected {expected_len} proof elements, got {}", - proof.len() - ))); - } - - let mut hash = *leaf_hash; - let mut idx = index; - let mut level_size = size; - let mut pos = 0; - - while level_size > 1 { - if pos >= proof.len() { - return Err(TransparencyError::InvalidProof("proof too short".into())); - } - if idx & 1 == 1 || idx + 1 == level_size { - if idx & 1 == 1 { - hash = hash_children(&proof[pos], &hash); - pos += 1; - } - } else { - hash = hash_children(&hash, &proof[pos]); - pos += 1; - } - idx >>= 1; - level_size = (level_size + 1) >> 1; - } - - Ok((hash, pos)) -} - -/// Compute the expected number of proof elements for an inclusion proof. -fn inclusion_proof_length(index: u64, size: u64) -> usize { - if size <= 1 { - return 0; - } - let mut length = 0; - let mut idx = index; - let mut level_size = size; - while level_size > 1 { - if idx & 1 == 1 || idx + 1 < level_size { - length += 1; - } - idx >>= 1; - level_size = (level_size + 1) >> 1; - } - length -} - -/// Verify a consistency proof between an old tree of `old_size` and a new tree of `new_size`. -/// -/// Ensures the new tree is an append-only extension of the old tree. -/// -/// Args: -/// * `old_size` — Number of leaves in the older tree. -/// * `new_size` — Number of leaves in the newer tree. -/// * `proof` — Ordered consistency proof hashes. -/// * `old_root` — Root of the older tree. -/// * `new_root` — Root of the newer tree. -/// -/// Usage: -/// ```ignore -/// verify_consistency(8, 16, &proof, &old_root, &new_root)?; -/// ``` -pub fn verify_consistency( - old_size: u64, - new_size: u64, - proof: &[MerkleHash], - old_root: &MerkleHash, - new_root: &MerkleHash, -) -> Result<(), TransparencyError> { - if old_size == 0 { - if proof.is_empty() { - return Ok(()); - } - return Err(TransparencyError::ConsistencyError( - "non-empty proof for empty old tree".into(), - )); - } - if old_size > new_size { - return Err(TransparencyError::ConsistencyError(format!( - "old size {old_size} > new size {new_size}" - ))); - } - if old_size == new_size { - if !proof.is_empty() { - return Err(TransparencyError::ConsistencyError( - "non-empty proof for equal sizes".into(), - )); - } - if old_root != new_root { - return Err(TransparencyError::RootMismatch { - expected: old_root.to_string(), - actual: new_root.to_string(), - }); - } - return Ok(()); - } - - // Reconstruct new root from the consistency proof while implicitly verifying old root. - // For power-of-2 old_size, old_root is used directly as the starting hash. - // For non-power-of-2, proof elements reconstruct old_root via the bit-walking algorithm. - let new_computed = new_root_from_consistency_proof(old_size, new_size, proof, old_root)?; - - if new_computed != *new_root { - return Err(TransparencyError::RootMismatch { - expected: new_root.to_string(), - actual: new_computed.to_string(), - }); - } - Ok(()) -} - -/// Reconstruct new root from an RFC 6962 SUBPROOF-format consistency proof. -/// -/// The proof is produced by the SUBPROOF(m, D[0:n], b=true) algorithm from -/// RFC 6962 Section 2.1.2. Verification walks the bit pattern of (old_size - 1) -/// to reconstruct both old_root (for validation) and new_root. -/// -/// Phase 1 (decomposition): each bit of (old_size - 1) determines whether a -/// proof element is a left sibling (set bit → combines into both roots) or -/// a right sibling (unset bit → combines into new root only). -/// -/// Phase 2 (extension): remaining proof elements extend the accumulator to -/// the new root. -fn new_root_from_consistency_proof( - old_size: u64, - new_size: u64, - proof: &[MerkleHash], - old_root: &MerkleHash, -) -> Result { - let _ = new_size; // used only in debug assertions via caller - - let (mut fn_hash, mut fr_hash, start) = if old_size.is_power_of_two() { - // Old tree is a single complete subtree — no decomposition needed - (*old_root, *old_root, 0) - } else { - if proof.is_empty() { - return Err(TransparencyError::ConsistencyError( - "proof too short".into(), - )); - } - (proof[0], proof[0], 1) - }; - - let mut pos = start; - - // Phase 1: walk bits of (old_size - 1) to decompose/reconstruct old root - if !old_size.is_power_of_two() { - let mut bit = old_size - 1; - while bit > 0 { - if pos >= proof.len() { - return Err(TransparencyError::ConsistencyError( - "proof too short during decomposition".into(), - )); - } - if bit & 1 != 0 { - fn_hash = hash_children(&proof[pos], &fn_hash); - fr_hash = hash_children(&proof[pos], &fr_hash); - } else { - fr_hash = hash_children(&fr_hash, &proof[pos]); - } - pos += 1; - bit >>= 1; - } - - if fn_hash != *old_root { - return Err(TransparencyError::RootMismatch { - expected: old_root.to_string(), - actual: fn_hash.to_string(), - }); - } - } - - // Phase 2: extension elements build up to the new root - while pos < proof.len() { - fr_hash = hash_children(&fr_hash, &proof[pos]); - pos += 1; - } - - Ok(fr_hash) -} - -/// Compute the Merkle root of a list of leaf hashes per RFC 6962 Section 2.1. -/// -/// Recursively splits at the largest power of 2 less than `n`: -/// `MTH(D[0:n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))` where `k = 2^(floor(log2(n-1)))`. -/// -/// Args: -/// * `leaves` — Slice of leaf hashes. Empty input returns `MerkleHash::EMPTY`. -/// -/// Usage: -/// ```ignore -/// let root = compute_root(&leaf_hashes); -/// ``` -pub fn compute_root(leaves: &[MerkleHash]) -> MerkleHash { - match leaves.len() { - 0 => MerkleHash::EMPTY, - 1 => leaves[0], - n => { - let k = largest_power_of_2_lt(n as u64) as usize; - let left = compute_root(&leaves[..k]); - let right = compute_root(&leaves[k..]); - hash_children(&left, &right) - } - } -} - -/// Largest power of 2 strictly less than `n` (for n > 1). -fn largest_power_of_2_lt(n: u64) -> u64 { - debug_assert!(n > 1); - if n.is_power_of_two() { - n / 2 - } else { - 1u64 << (63 - n.leading_zeros()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hash_leaf_domain_separation() { - let data = b"test data"; - let h = hash_leaf(data); - - // Manually compute SHA-256(0x00 || "test data") - let mut hasher = Sha256::new(); - hasher.update([0x00]); - hasher.update(data); - let expected = hasher.finalize(); - - assert_eq!(h.as_bytes(), expected.as_slice()); - } - - #[test] - fn hash_children_domain_separation() { - let left = MerkleHash::from_bytes([0x11; 32]); - let right = MerkleHash::from_bytes([0x22; 32]); - let h = hash_children(&left, &right); - - let mut hasher = Sha256::new(); - hasher.update([0x01]); - hasher.update([0x11; 32]); - hasher.update([0x22; 32]); - let expected = hasher.finalize(); - - assert_eq!(h.as_bytes(), expected.as_slice()); - } - - #[test] - fn leaf_and_children_produce_different_hashes() { - let data = [0xab; 64]; - let leaf = hash_leaf(&data); - - let left = MerkleHash::from_bytes(data[..32].try_into().unwrap()); - let right = MerkleHash::from_bytes(data[32..].try_into().unwrap()); - let node = hash_children(&left, &right); - - assert_ne!(leaf, node); - } - - #[test] - fn compute_root_single_leaf() { - let h = MerkleHash::from_bytes([0x42; 32]); - assert_eq!(compute_root(&[h]), h); - } - - #[test] - fn compute_root_empty() { - assert_eq!(compute_root(&[]), MerkleHash::EMPTY); - } - - #[test] - fn compute_root_two_leaves() { - let a = hash_leaf(b"a"); - let b = hash_leaf(b"b"); - let root = compute_root(&[a, b]); - assert_eq!(root, hash_children(&a, &b)); - } - - #[test] - fn inclusion_proof_single_leaf() { - let leaf = hash_leaf(b"only leaf"); - let root = leaf; - verify_inclusion(&leaf, 0, 1, &[], &root).unwrap(); - } - - #[test] - fn inclusion_proof_two_leaves() { - let a = hash_leaf(b"a"); - let b = hash_leaf(b"b"); - let root = hash_children(&a, &b); - - // Prove leaf 0 (a) with sibling b - verify_inclusion(&a, 0, 2, &[b], &root).unwrap(); - // Prove leaf 1 (b) with sibling a - verify_inclusion(&b, 1, 2, &[a], &root).unwrap(); - } - - #[test] - fn inclusion_proof_four_leaves() { - let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); - let root = compute_root(&leaves); - - // Prove leaf 0: needs leaf 1 as sibling, then hash(leaf2, leaf3) as uncle - let ab = hash_children(&leaves[0], &leaves[1]); - let cd = hash_children(&leaves[2], &leaves[3]); - let _ = hash_children(&ab, &cd); - - verify_inclusion(&leaves[0], 0, 4, &[leaves[1], cd], &root).unwrap(); - verify_inclusion(&leaves[1], 1, 4, &[leaves[0], cd], &root).unwrap(); - verify_inclusion(&leaves[2], 2, 4, &[leaves[3], ab], &root).unwrap(); - verify_inclusion(&leaves[3], 3, 4, &[leaves[2], ab], &root).unwrap(); - } - - #[test] - fn inclusion_proof_rejects_wrong_root() { - let a = hash_leaf(b"a"); - let b = hash_leaf(b"b"); - let _root = hash_children(&a, &b); - let wrong = MerkleHash::from_bytes([0xff; 32]); - - let err = verify_inclusion(&a, 0, 2, &[b], &wrong); - assert!(err.is_err()); - } - - #[test] - fn inclusion_proof_three_leaves() { - let leaves: Vec = (0..3u8).map(|i| hash_leaf(&[i])).collect(); - let root = compute_root(&leaves); - - let ab = hash_children(&leaves[0], &leaves[1]); - - // Leaf 0: sibling = leaf[1], then uncle = leaf[2] - verify_inclusion(&leaves[0], 0, 3, &[leaves[1], leaves[2]], &root).unwrap(); - // Leaf 1: sibling = leaf[0], then uncle = leaf[2] - verify_inclusion(&leaves[1], 1, 3, &[leaves[0], leaves[2]], &root).unwrap(); - // Leaf 2: sibling = ab (promoted, no right sibling at level 0) - verify_inclusion(&leaves[2], 2, 3, &[ab], &root).unwrap(); - } - - #[test] - fn inclusion_proof_five_leaves() { - let leaves: Vec = (0..5u8).map(|i| hash_leaf(&[i])).collect(); - let root = compute_root(&leaves); - - let h01 = hash_children(&leaves[0], &leaves[1]); - let h23 = hash_children(&leaves[2], &leaves[3]); - let h0123 = hash_children(&h01, &h23); - - // Leaf 4: it's the last leaf (unpaired), needs h0123 as sibling - verify_inclusion(&leaves[4], 4, 5, &[h0123], &root).unwrap(); - // Leaf 0: sibling leaf[1], uncle h23, then uncle leaf[4] - verify_inclusion(&leaves[0], 0, 5, &[leaves[1], h23, leaves[4]], &root).unwrap(); - } - - #[test] - fn inclusion_proof_seven_leaves() { - let leaves: Vec = (0..7u8).map(|i| hash_leaf(&[i])).collect(); - let root = compute_root(&leaves); - - let h01 = hash_children(&leaves[0], &leaves[1]); - let h23 = hash_children(&leaves[2], &leaves[3]); - let h45 = hash_children(&leaves[4], &leaves[5]); - let h0123 = hash_children(&h01, &h23); - let h456 = hash_children(&h45, &leaves[6]); - - // Leaf 6: unpaired at level 0, sibling is h45, then uncle is h0123 - verify_inclusion(&leaves[6], 6, 7, &[h45, h0123], &root).unwrap(); - // Leaf 0: sibling leaf[1], uncle h23, then uncle h456 - verify_inclusion(&leaves[0], 0, 7, &[leaves[1], h23, h456], &root).unwrap(); - } - - #[test] - fn inclusion_proof_rejects_index_out_of_range() { - let a = hash_leaf(b"a"); - let root = a; - let err = verify_inclusion(&a, 1, 1, &[], &root); - assert!(err.is_err()); - } - - #[test] - fn consistency_proof_same_size() { - let root = MerkleHash::from_bytes([0x42; 32]); - verify_consistency(5, 5, &[], &root, &root).unwrap(); - } - - #[test] - fn consistency_proof_empty_old() { - let new_root = MerkleHash::from_bytes([0x42; 32]); - let old_root = MerkleHash::EMPTY; - verify_consistency(0, 5, &[], &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_2_to_4() { - let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..2]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..2], &leaves); - verify_consistency(2, 4, &proof, &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_3_to_5() { - let leaves: Vec = (0..5u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..3]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..3], &leaves); - verify_consistency(3, 5, &proof, &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_4_to_8() { - let leaves: Vec = (0..8u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..4]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..4], &leaves); - verify_consistency(4, 8, &proof, &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_7_to_15() { - let leaves: Vec = (0..15u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..7]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..7], &leaves); - verify_consistency(7, 15, &proof, &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_1_to_4() { - let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..1]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..1], &leaves); - verify_consistency(1, 4, &proof, &old_root, &new_root).unwrap(); - } - - #[test] - fn consistency_proof_rejects_wrong_old_root() { - let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); - let wrong_old = MerkleHash::from_bytes([0xff; 32]); - let new_root = compute_root(&leaves); - let proof = build_consistency_proof(&leaves[..3], &leaves); - assert!(verify_consistency(3, 4, &proof, &wrong_old, &new_root).is_err()); - } - - #[test] - fn consistency_proof_rejects_wrong_new_root() { - let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); - let old_root = compute_root(&leaves[..3]); - let wrong_new = MerkleHash::from_bytes([0xff; 32]); - let proof = build_consistency_proof(&leaves[..3], &leaves); - assert!(verify_consistency(3, 4, &proof, &old_root, &wrong_new).is_err()); - } - - /// Build a consistency proof using RFC 6962 SUBPROOF decomposition. Test-only. - fn build_consistency_proof( - old_leaves: &[MerkleHash], - new_leaves: &[MerkleHash], - ) -> Vec { - assert!(old_leaves.len() <= new_leaves.len()); - subproof(old_leaves.len() as u64, new_leaves, true) - } - - /// RFC 6962 Section 2.1.2 SUBPROOF(m, D[0:n], b). - fn subproof(m: u64, leaves: &[MerkleHash], b: bool) -> Vec { - let n = leaves.len() as u64; - if m == n { - if b { - return vec![]; - } - return vec![compute_root(leaves)]; - } - let k = largest_power_of_2_lt(n) as usize; - if m <= k as u64 { - let mut proof = subproof(m, &leaves[..k], b); - proof.push(compute_root(&leaves[k..])); - proof - } else { - let mut proof = subproof(m - k as u64, &leaves[k..], false); - proof.push(compute_root(&leaves[..k])); - proof - } - } - - #[test] - fn largest_pow2_lt() { - assert_eq!(largest_power_of_2_lt(2), 1); - assert_eq!(largest_power_of_2_lt(3), 2); - assert_eq!(largest_power_of_2_lt(4), 2); - assert_eq!(largest_power_of_2_lt(5), 4); - assert_eq!(largest_power_of_2_lt(8), 4); - assert_eq!(largest_power_of_2_lt(9), 8); - } -} +//! RFC 6962 Merkle tree hashing and proof verification. +//! +//! The math lives in `auths_verifier::tlog::merkle` — one implementation +//! shared by the log builder here and every offline verifier surface +//! (native, FFI, browser WASM). This module re-exports it. + +pub use auths_verifier::tlog::merkle::{ + compute_root, hash_children, hash_leaf, verify_consistency, verify_inclusion, +}; diff --git a/crates/auths-transparency/src/proof.rs b/crates/auths-transparency/src/proof.rs index 325f3fe8..6bd84619 100644 --- a/crates/auths-transparency/src/proof.rs +++ b/crates/auths-transparency/src/proof.rs @@ -1,125 +1,7 @@ -use serde::{Deserialize, Serialize}; +//! Merkle inclusion and consistency proofs. +//! +//! The proof types and their verification live in `auths_verifier::tlog` +//! (one RFC 6962 implementation for every surface, including browser WASM); +//! this module re-exports them for the log-construction side. -use crate::types::MerkleHash; - -/// Merkle inclusion proof for a single entry in the log. -/// -/// Proves that a leaf at `index` is included in the tree of `size` leaves -/// with the given `root`. -/// -/// Args: -/// * `index` — Zero-based leaf index. -/// * `size` — Tree size (number of leaves) when the proof was generated. -/// * `root` — Merkle root at tree size `size`. -/// * `hashes` — Sibling hashes from leaf to root. -/// -/// Usage: -/// ```ignore -/// let proof = InclusionProof { index: 5, size: 16, root, hashes: vec![...] }; -/// proof.verify(&leaf_hash)?; -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow(missing_docs)] -pub struct InclusionProof { - pub index: u64, - pub size: u64, - pub root: MerkleHash, - pub hashes: Vec, -} - -impl InclusionProof { - /// Verify that `leaf_hash` is included in the tree. - pub fn verify(&self, leaf_hash: &MerkleHash) -> Result<(), crate::error::TransparencyError> { - crate::merkle::verify_inclusion(leaf_hash, self.index, self.size, &self.hashes, &self.root) - } -} - -/// Merkle consistency proof between two tree sizes. -/// -/// Proves that the tree at `old_size` is a prefix of the tree at `new_size`. -/// -/// Args: -/// * `old_size` — Earlier tree size. -/// * `new_size` — Later tree size. -/// * `old_root` — Root at `old_size`. -/// * `new_root` — Root at `new_size`. -/// * `hashes` — Consistency proof hashes. -/// -/// Usage: -/// ```ignore -/// let proof = ConsistencyProof { old_size: 8, new_size: 16, .. }; -/// proof.verify()?; -/// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow(missing_docs)] -pub struct ConsistencyProof { - pub old_size: u64, - pub new_size: u64, - pub old_root: MerkleHash, - pub new_root: MerkleHash, - pub hashes: Vec, -} - -impl ConsistencyProof { - /// Verify that the old tree is a prefix of the new tree. - pub fn verify(&self) -> Result<(), crate::error::TransparencyError> { - crate::merkle::verify_consistency( - self.old_size, - self.new_size, - &self.hashes, - &self.old_root, - &self.new_root, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::merkle::{hash_children, hash_leaf}; - - #[test] - fn inclusion_proof_verify() { - let a = hash_leaf(b"a"); - let b = hash_leaf(b"b"); - let root = hash_children(&a, &b); - - let proof = InclusionProof { - index: 0, - size: 2, - root, - hashes: vec![b], - }; - proof.verify(&a).unwrap(); - } - - #[test] - fn inclusion_proof_json_roundtrip() { - let proof = InclusionProof { - index: 3, - size: 8, - root: MerkleHash::from_bytes([0xaa; 32]), - hashes: vec![ - MerkleHash::from_bytes([0xbb; 32]), - MerkleHash::from_bytes([0xcc; 32]), - ], - }; - let json = serde_json::to_string(&proof).unwrap(); - let back: InclusionProof = serde_json::from_str(&json).unwrap(); - assert_eq!(proof, back); - } - - #[test] - fn consistency_proof_json_roundtrip() { - let proof = ConsistencyProof { - old_size: 4, - new_size: 8, - old_root: MerkleHash::from_bytes([0x11; 32]), - new_root: MerkleHash::from_bytes([0x22; 32]), - hashes: vec![MerkleHash::from_bytes([0x33; 32])], - }; - let json = serde_json::to_string(&proof).unwrap(); - let back: ConsistencyProof = serde_json::from_str(&json).unwrap(); - assert_eq!(proof, back); - } -} +pub use auths_verifier::tlog::{ConsistencyProof, InclusionProof}; diff --git a/crates/auths-transparency/src/types.rs b/crates/auths-transparency/src/types.rs index 98454967..e509ad95 100644 --- a/crates/auths-transparency/src/types.rs +++ b/crates/auths-transparency/src/types.rs @@ -1,211 +1,7 @@ -use std::fmt; +//! Identifier and hash newtypes for the transparency-log wire contract. +//! +//! The types live in `auths_verifier::tlog` so every verifier surface +//! (native, FFI, browser WASM) shares one implementation; this module +//! re-exports them for the log-construction side. -use base64::{Engine, engine::general_purpose::STANDARD}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use sha2::{Digest, Sha256}; - -use crate::error::TransparencyError; - -/// SHA-256 Merkle hash (32 bytes). -/// -/// Args: -/// * Inner `[u8; 32]` — raw SHA-256 digest. -/// -/// Usage: -/// ```ignore -/// let hash = MerkleHash::from_bytes([0u8; 32]); -/// let hex_str = hash.to_string(); // lowercase hex -/// ``` -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct MerkleHash([u8; 32]); - -impl MerkleHash { - /// The all-zero hash, used as a sentinel for empty trees. - pub const EMPTY: Self = Self([0u8; 32]); - - /// Wrap raw bytes. - pub fn from_bytes(bytes: [u8; 32]) -> Self { - Self(bytes) - } - - /// Construct from a hex string. - pub fn from_hex(s: &str) -> Result { - let bytes = hex::decode(s).map_err(|e| TransparencyError::InvalidProof(e.to_string()))?; - let arr: [u8; 32] = bytes - .try_into() - .map_err(|_| TransparencyError::InvalidProof("hash must be 32 bytes".into()))?; - Ok(Self(arr)) - } - - /// Raw bytes. - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - - /// Encode as standard base64 (with padding). Used in C2SP checkpoint note body. - pub fn to_base64(&self) -> String { - STANDARD.encode(self.0) - } - - /// Decode from standard base64 (with padding). - pub fn from_base64(s: &str) -> Result { - let bytes = STANDARD - .decode(s) - .map_err(|e| TransparencyError::InvalidProof(format!("base64 decode: {e}")))?; - let arr: [u8; 32] = bytes - .try_into() - .map_err(|_| TransparencyError::InvalidProof("hash must be 32 bytes".into()))?; - Ok(Self(arr)) - } - - /// Plain SHA-256 (no domain separation). Used for key-ID computation. - pub fn sha256(data: &[u8]) -> Self { - let digest = Sha256::digest(data); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest); - Self(out) - } -} - -impl fmt::Debug for MerkleHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "MerkleHash({})", self) - } -} - -impl fmt::Display for MerkleHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for b in &self.0 { - write!(f, "{b:02x}")?; - } - Ok(()) - } -} - -impl Serialize for MerkleHash { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for MerkleHash { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Self::from_hex(&s).map_err(serde::de::Error::custom) - } -} - -impl AsRef<[u8]> for MerkleHash { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -/// Validated log origin string (e.g., `"auths.dev/log"`). -/// -/// Must be non-empty ASCII with no control characters. -/// -/// Args: -/// * Inner `String` — validated ASCII origin. -/// -/// Usage: -/// ```ignore -/// let origin = LogOrigin::new("auths.dev/log")?; -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct LogOrigin(String); - -impl LogOrigin { - /// Create a new log origin, validating that it is non-empty ASCII. - pub fn new(s: &str) -> Result { - if s.is_empty() { - return Err(TransparencyError::InvalidOrigin("must not be empty".into())); - } - if !s.is_ascii() { - return Err(TransparencyError::InvalidOrigin("must be ASCII".into())); - } - if s.bytes().any(|b| b < 0x20) { - return Err(TransparencyError::InvalidOrigin( - "must not contain control characters".into(), - )); - } - Ok(Self(s.to_string())) - } - - /// Create from a compile-time constant. Panics if invalid. - /// - /// Only for use in `default_config()` and similar contexts where the - /// string is a known-good constant. - #[allow(clippy::expect_used)] // INVARIANT: only called with compile-time ASCII constants - pub fn new_unchecked(s: &str) -> Self { - Self::new(s).expect("LogOrigin::new_unchecked called with invalid origin") - } - - /// The inner string. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for LogOrigin { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl TryFrom for LogOrigin { - type Error = TransparencyError; - fn try_from(s: String) -> Result { - Self::new(&s) - } -} - -impl From for String { - fn from(o: LogOrigin) -> Self { - o.0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn merkle_hash_hex_roundtrip() { - let bytes = [0xabu8; 32]; - let h = MerkleHash::from_bytes(bytes); - let hex_str = h.to_string(); - let h2 = MerkleHash::from_hex(&hex_str).unwrap(); - assert_eq!(h, h2); - } - - #[test] - fn merkle_hash_json_roundtrip() { - let h = MerkleHash::from_bytes([0x42u8; 32]); - let json = serde_json::to_string(&h).unwrap(); - let h2: MerkleHash = serde_json::from_str(&json).unwrap(); - assert_eq!(h, h2); - } - - #[test] - fn log_origin_rejects_empty() { - assert!(LogOrigin::new("").is_err()); - } - - #[test] - fn log_origin_rejects_non_ascii() { - assert!(LogOrigin::new("日本語").is_err()); - } - - #[test] - fn log_origin_rejects_control_chars() { - assert!(LogOrigin::new("auths\x00log").is_err()); - } - - #[test] - fn log_origin_valid() { - let o = LogOrigin::new("auths.dev/log").unwrap(); - assert_eq!(o.as_str(), "auths.dev/log"); - } -} +pub use auths_verifier::tlog::{LogOrigin, MerkleHash}; diff --git a/crates/auths-verifier/src/evidence_pack.rs b/crates/auths-verifier/src/evidence_pack.rs new file mode 100644 index 00000000..ea924ce5 --- /dev/null +++ b/crates/auths-verifier/src/evidence_pack.rs @@ -0,0 +1,582 @@ +//! Offline verification of a compliance evidence pack — zero network, pure +//! function of the pack's bytes. +//! +//! An evidence pack is the org's append-only history rendered as a +//! deterministic, offline-verifiable document: one row per release, each +//! answering "who signed this artifact, and were they authorized **at +//! release time**?" by KEL position, never wall-clock. An **offline** pack +//! embeds the org's KEL material as an +//! [`AirGappedOrgBundle`](crate::org_bundle::AirGappedOrgBundle) plus, per +//! row, the transparency-log inclusion (and consistency) proof. +//! +//! This module owns the pack's wire types and [`verify_evidence_pack_offline`] +//! — the verification half. The *build* half (which classifies releases +//! against a live registry) lives in `auths-sdk` and re-exports these types, +//! so a CI gate, audit tool, or **browser** (via the WASM exports) replays +//! the same verdict from the pack file alone. + +use auths_keri::Prefix; +use auths_keri::witness::independence::HonestyCeiling; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::org_bundle::{ + AirGappedOrgBundle, AuthorityAtSigning, classify_authority_in_bundle, verify_org_bundle, +}; +use crate::tlog::{ConsistencyProof, InclusionProof, MerkleHash, SignedCheckpoint}; +use crate::types::IdentityDID; + +/// The current evidence-pack schema version. +pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1; + +/// Maximum accepted JSON input for the JSON/WASM surface (16 MiB) — a pack +/// embeds whole KELs and Merkle proofs, so the ceiling matches the org-bundle +/// contract's. +pub const MAX_PACK_JSON_BYTES: usize = 16 * 1024 * 1024; + +/// A typed failure parsing or verifying a compliance evidence pack. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum EvidencePackError { + /// Canonical serialization (`json-canon`) failed. + #[error("canonicalization failed: {0}")] + Canonicalize(String), + /// The pack (or a JSON input) could not be decoded. + #[error("decode failed: {0}")] + Decode(String), + /// Offline pack verification failed (tampered KEL, unpinned root, or a + /// transparency proof that did not check out). + #[error("offline verification failed: {0}")] + OfflineVerification(String), +} + +impl auths_crypto::AuthsErrorInfo for EvidencePackError { + fn error_code(&self) -> &'static str { + match self { + Self::Canonicalize(_) => "AUTHS-E2301", + Self::Decode(_) => "AUTHS-E2302", + Self::OfflineVerification(_) => "AUTHS-E2303", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::Canonicalize(_) | Self::Decode(_) => Some( + "The file is not a valid evidence pack; re-export it with `auths compliance report`", + ), + Self::OfflineVerification(_) => Some( + "The pack failed offline verification; obtain a fresh, untampered pack from the org", + ), + } + } +} + +/// The compliance framework a report targets. Each variant selects the +/// predicate the report builder renders (SLSA provenance + VSA / SPDX SBOM / +/// CRA→SSDF / SOC 2 TSC / ISO 27001 Annex-A); here it only tags the pack. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ComplianceFramework { + /// SLSA provenance. + Slsa, + /// SPDX software bill of materials. + Sbom, + /// EU Cyber Resilience Act obligation mapping. + Cra, + /// SOC 2 Trust Services Criteria (TSC) control mapping. + Soc2, + /// ISO/IEC 27001:2022 Annex-A control mapping. + Iso27001, +} + +/// The transparency-log evidence that proves an artifact's entry is in the log, +/// bundled so a row verifies offline. +/// +/// `inclusion_proof` proves the `leaf_hash` is in the tree at size N (root +/// `inclusion_proof.root`). When the inclusion was taken at a tree size **older** +/// than the embedded `signed_checkpoint`, a `consistency_proof` (N→M) proves the +/// older root is a prefix of the checkpoint root. With no consistency proof the +/// inclusion must be **against** the checkpoint (same root and size). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TransparencyInclusion { + /// The artifact's transparency-log leaf hash (the value `inclusion_proof` proves). + pub leaf_hash: MerkleHash, + /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`. + pub inclusion_proof: InclusionProof, + /// The signed checkpoint the inclusion is anchored to (directly, or via the + /// consistency proof). Its signature trust requires a **pinned log key** — + /// a separate axis from this offline Merkle check. + pub signed_checkpoint: SignedCheckpoint, + /// Consistency proof from the inclusion's tree size to the checkpoint's, present + /// only when the inclusion was taken at an earlier size than the checkpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consistency_proof: Option, +} + +/// One row of compliance evidence: the signer's authority **at release**. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvidenceRow { + /// The artifact content digest. + pub artifact_digest: String, + /// The signing member's self-certifying identity. + pub signer: IdentityDID, + /// The signer's authority at the signing position, by KEL order. + pub authority_at_release: AuthorityAtSigning, + /// The artifact's in-band signing position, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signed_at: Option, + /// Transparency-log inclusion evidence, when supplied — lets the row prove the + /// artifact's log membership offline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transparency: Option, +} + +/// A deterministic, offline-verifiable compliance evidence pack. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvidencePack { + /// Schema version. + pub schema_version: u32, + /// The org whose history this pack covers. + pub org: IdentityDID, + /// The reporting period (free-form, e.g. `2026-Q3`). + pub period: String, + /// The framework this pack targets. + pub framework: ComplianceFramework, + /// The honest witness-diversity verdict — NEVER a bare `non_equivocation` + /// flag. With only self-run/placeholder witnesses this is `policy_met == + /// false` ("single-operator — not yet independent"). + pub equivocation_visibility: HonestyCeiling, + /// When the pack was generated (injected clock; never `Utc::now()` in domain + /// code). Two runs with the same inputs and timestamp are byte-identical. + pub generated_at: DateTime, + /// One row per classified release. + pub rows: Vec, + /// The embedded, URL-free KEL material (org + member KELs + off-boarding + /// records + pinned root) that makes authority re-derivable offline. `None` + /// for a non-offline pack. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_bundle: Option, +} + +impl EvidencePack { + /// Canonicalize with `json-canon` — the byte-exact, reproducible form an + /// auditor re-derives and the org signs. + pub fn canonicalize(&self) -> Result { + json_canon::to_string(self).map_err(|e| EvidencePackError::Canonicalize(e.to_string())) + } + + /// Parse a pack back from its canonical JSON form. Typed identifiers fail + /// closed on malformed input. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| EvidencePackError::Decode(e.to_string())) + } + + /// Render as a canonical in-toto Statement: `subject` = the artifact digests, + /// `predicate` = this pack. The org DSSE signature over these bytes lives in + /// the SDK's compliance DSSE module. + pub fn to_intoto_statement(&self) -> Result { + let subject: Vec = self + .rows + .iter() + .map(|r| { + let digest = r + .artifact_digest + .strip_prefix("sha256:") + .unwrap_or(&r.artifact_digest); + serde_json::json!({ + "name": r.artifact_digest, + "digest": { "sha256": digest }, + }) + }) + .collect(); + + let statement = serde_json::json!({ + "_type": "https://in-toto.io/Statement/v1", + "subject": subject, + "predicateType": "https://auths.dev/compliance/evidence/v1", + "predicate": self, + }); + + json_canon::to_string(&statement) + .map_err(|e| EvidencePackError::Canonicalize(e.to_string())) + } +} + +/// The offline-verification verdict for one evidence row. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RowVerdict { + /// The artifact this row covers. + pub artifact_digest: String, + /// The row's signer. + pub signer: IdentityDID, + /// The authority recorded in the row. + pub authority_at_release: AuthorityAtSigning, + /// Whether re-deriving authority from the embedded KEL matches the row's + /// recorded verdict (a tampered row flips this to `false`). + pub authority_consistent: bool, + /// Whether the row's transparency inclusion/consistency proof verified, or + /// `None` when the row carries no transparency evidence. + pub transparency_verified: Option, +} + +/// Verify the transparency inclusion (and consistency) of one row, offline. +pub fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), EvidencePackError> { + t.inclusion_proof.verify(&t.leaf_hash).map_err(|e| { + EvidencePackError::OfflineVerification(format!("inclusion proof did not verify: {e}")) + })?; + + let checkpoint_root = t.signed_checkpoint.checkpoint.root; + let checkpoint_size = t.signed_checkpoint.checkpoint.size; + + match &t.consistency_proof { + Some(c) => { + if c.old_root != t.inclusion_proof.root || c.old_size != t.inclusion_proof.size { + return Err(EvidencePackError::OfflineVerification( + "consistency proof old root/size does not match the inclusion proof".into(), + )); + } + if c.new_root != checkpoint_root || c.new_size != checkpoint_size { + return Err(EvidencePackError::OfflineVerification( + "consistency proof new root/size does not match the signed checkpoint".into(), + )); + } + c.verify().map_err(|e| { + EvidencePackError::OfflineVerification(format!( + "consistency proof did not verify: {e}" + )) + })?; + } + None => { + if t.inclusion_proof.root != checkpoint_root + || t.inclusion_proof.size != checkpoint_size + { + return Err(EvidencePackError::OfflineVerification( + "inclusion proof is not against the signed checkpoint and no consistency proof was provided".into(), + )); + } + } + } + Ok(()) +} + +/// Verify an offline evidence pack with **zero network**. +/// +/// Checks the embedded [`AirGappedOrgBundle`] integrity (every event +/// self-addresses AND is signed by the controlling key-state), confirms the org +/// is a pinned root, flags KEL duplicity, then for each row re-derives +/// authority-at-release from the embedded KEL (tamper check) and verifies any +/// transparency inclusion/consistency proof. The checkpoint **signature** trust +/// (that the log operator signed the root) is a separate axis requiring a +/// pinned log key; this function proves the Merkle membership, not the log +/// operator's identity. +/// +/// Args: +/// * `pack`: The pack to verify (must embed an org bundle). +/// * `pinned_roots`: The verifier's pinned trust roots. +/// +/// Usage: +/// ```ignore +/// let verdicts = verify_evidence_pack_offline(&pack, &roots)?; +/// assert!(verdicts.iter().all(|v| v.authority_consistent)); +/// ``` +pub fn verify_evidence_pack_offline( + pack: &EvidencePack, + pinned_roots: &[IdentityDID], +) -> Result, EvidencePackError> { + let bundle = pack.org_bundle.as_ref().ok_or_else(|| { + EvidencePackError::OfflineVerification( + "pack carries no embedded org bundle — not an offline-verifiable pack".into(), + ) + })?; + + let report = verify_org_bundle(bundle, pinned_roots, None) + .map_err(|e| EvidencePackError::OfflineVerification(e.to_string()))?; + if !report.root_pinned { + return Err(EvidencePackError::OfflineVerification(format!( + "org {} is not in the pinned trust roots", + bundle.org_did.as_str() + ))); + } + if report.duplicity_detected { + return Err(EvidencePackError::OfflineVerification( + "org KEL shows duplicity (same-seq divergent SAIDs)".into(), + )); + } + + let mut verdicts = Vec::with_capacity(pack.rows.len()); + for row in &pack.rows { + let signer_prefix = Prefix::new_unchecked( + row.signer + .as_str() + .strip_prefix("did:keri:") + .unwrap_or(row.signer.as_str()) + .to_string(), + ); + let rederived = classify_authority_in_bundle(bundle, &signer_prefix, row.signed_at); + let authority_consistent = rederived == row.authority_at_release; + + let transparency_verified = row + .transparency + .as_ref() + .map(|t| verify_transparency_inclusion(t).is_ok()); + + verdicts.push(RowVerdict { + artifact_digest: row.artifact_digest.clone(), + signer: row.signer.clone(), + authority_at_release: row.authority_at_release.clone(), + authority_consistent, + transparency_verified, + }); + } + Ok(verdicts) +} + +// ── JSON contract (the WASM/FFI-facing form) ─────────────────────────────── + +/// The tagged verdict envelope for [`verify_evidence_pack_offline_json`]. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum PackVerdictJson { + /// Verification ran to completion; one verdict per evidence row. + #[serde(rename = "verdicts")] + Verdicts { + /// Per-row offline verdicts, in pack order. + rows: Vec, + }, + /// Verification failed closed (tampered pack, unpinned root, bad input). + #[serde(rename = "error")] + Error { + /// The stable `AUTHS-Exxxx` code. + code: String, + /// Human-readable detail. + message: String, + }, +} + +/// A last-resort verdict used only if envelope serialization itself fails. +const SERIALIZE_FALLBACK: &str = + r#"{"kind":"error","code":"AUTHS-E2301","message":"verdict serialization failed"}"#; + +/// Verify an offline evidence pack from its JSON wire forms — the +/// string-in/string-out contract the WASM surface exposes. +/// +/// Panic-free and synchronous: malformed or oversize input returns a tagged +/// `error` envelope, never an exception. The verdict is a discriminated union +/// (`kind`: `"verdicts"` | `"error"`), never a bare bool. +/// +/// Args: +/// * `pack_json`: The [`EvidencePack`] JSON (the `.evidence` file). +/// * `pinned_roots_json`: JSON array of the verifier's pinned `did:keri:` roots. +/// +/// Usage: +/// ```ignore +/// let verdict = verify_evidence_pack_offline_json(&pack, r#"["did:keri:EOrg"]"#); +/// ``` +pub fn verify_evidence_pack_offline_json(pack_json: &str, pinned_roots_json: &str) -> String { + use auths_crypto::AuthsErrorInfo; + let envelope = match verify_pack_json_inner(pack_json, pinned_roots_json) { + Ok(rows) => PackVerdictJson::Verdicts { rows }, + Err(e) => PackVerdictJson::Error { + code: e.error_code().to_string(), + message: e.to_string(), + }, + }; + serde_json::to_string(&envelope).unwrap_or_else(|_| SERIALIZE_FALLBACK.to_string()) +} + +fn verify_pack_json_inner( + pack_json: &str, + pinned_roots_json: &str, +) -> Result, EvidencePackError> { + if pack_json.len() > MAX_PACK_JSON_BYTES { + return Err(EvidencePackError::Decode(format!( + "pack JSON too large: {} bytes, max {}", + pack_json.len(), + MAX_PACK_JSON_BYTES + ))); + } + let pack = EvidencePack::from_json(pack_json)?; + let pinned_roots: Vec = serde_json::from_str(pinned_roots_json) + .map_err(|e| EvidencePackError::Decode(format!("pinned roots: {e}")))?; + verify_evidence_pack_offline(&pack, &pinned_roots) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::core::{Ed25519PublicKey, Ed25519Signature}; + use crate::tlog::merkle::{hash_children, hash_leaf}; + use crate::tlog::{Checkpoint, LogOrigin}; + use auths_keri::witness::independence::EquivocationDetection; + + fn fixed_now() -> DateTime { + DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + fn single_operator_ceiling() -> HonestyCeiling { + // The reality today: no independent commons → not policy_met. + HonestyCeiling { + distinct_operators: 1, + distinct_organizations: 1, + distinct_jurisdictions: 1, + distinct_infra_zones: 1, + policy_met: false, + equivocation: EquivocationDetection::Sampled, + shortfalls: vec!["no independent witness commons".into()], + label: "single-operator — not yet independent".into(), + } + } + + fn sample_pack() -> EvidencePack { + EvidencePack { + schema_version: EVIDENCE_PACK_SCHEMA_VERSION, + org: IdentityDID::new_unchecked("did:keri:EOrg"), + period: "2026-Q3".into(), + framework: ComplianceFramework::Slsa, + equivocation_visibility: single_operator_ceiling(), + generated_at: fixed_now(), + rows: vec![ + EvidenceRow { + artifact_digest: "sha256:aa".into(), + signer: IdentityDID::new_unchecked("did:keri:EAlice"), + authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation, + signed_at: Some(7), + transparency: None, + }, + EvidenceRow { + artifact_digest: "sha256:bb".into(), + signer: IdentityDID::new_unchecked("did:keri:EBob"), + authority_at_release: AuthorityAtSigning::RejectedRevokedPositionUnknown { + revoked_at: 12, + }, + signed_at: None, + transparency: None, + }, + ], + org_bundle: None, + } + } + + #[test] + fn canonicalize_is_deterministic() { + let a = sample_pack().canonicalize().unwrap(); + let b = sample_pack().canonicalize().unwrap(); + assert_eq!( + a, b, + "same inputs must produce byte-identical canonical bytes" + ); + } + + #[test] + fn pack_round_trips_through_json() { + let pack = sample_pack(); + let json = pack.canonicalize().unwrap(); + let back = EvidencePack::from_json(&json).unwrap(); + assert_eq!( + json, + back.canonicalize().unwrap(), + "canonical JSON must round-trip byte-identically" + ); + } + + #[test] + fn position_unknown_is_represented_honestly_not_authorized() { + let json = sample_pack().canonicalize().unwrap(); + assert!(json.contains("rejected_revoked_position_unknown")); + // The unclassifiable row must NOT be silently rendered as authorized. + let bob_authorized = json.matches("authorized_before_revocation").count(); + assert_eq!( + bob_authorized, 1, + "only Alice is authorized-before-revocation" + ); + } + + #[test] + fn intoto_statement_carries_subjects_and_predicate_type() { + let stmt = sample_pack().to_intoto_statement().unwrap(); + assert!(stmt.contains("https://in-toto.io/Statement/v1")); + assert!(stmt.contains("https://auths.dev/compliance/evidence/v1")); + assert!(stmt.contains("\"sha256\":\"aa\"")); + // The predicate carries the authority verdicts. + assert!(stmt.contains("authority_at_signing")); + } + + #[test] + fn pack_without_bundle_fails_offline_verification_closed() { + let pack = sample_pack(); + let roots = vec![IdentityDID::new_unchecked("did:keri:EOrg")]; + let err = verify_evidence_pack_offline(&pack, &roots).unwrap_err(); + assert!(err.to_string().contains("no embedded org bundle")); + } + + #[test] + fn pack_json_contract_reports_errors_as_tagged_envelopes() { + let verdict = verify_evidence_pack_offline_json("not json", "[]"); + let v: serde_json::Value = serde_json::from_str(&verdict).unwrap(); + assert_eq!(v["kind"], "error"); + assert_eq!(v["code"], "AUTHS-E2302"); + } + + fn signed_checkpoint_at(size: u64, root: MerkleHash) -> SignedCheckpoint { + SignedCheckpoint { + checkpoint: Checkpoint { + origin: LogOrigin::new("auths.dev/log").unwrap(), + size, + root, + timestamp: fixed_now(), + }, + log_signature: Ed25519Signature::from_bytes([0u8; 64]), + log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), + witnesses: vec![], + ecdsa_checkpoint_signature: None, + ecdsa_checkpoint_key: None, + } + } + + #[test] + fn transparency_inclusion_against_checkpoint_verifies() { + let a = hash_leaf(b"artifact-a"); + let b = hash_leaf(b"artifact-b"); + let root = hash_children(&a, &b); + + let t = TransparencyInclusion { + leaf_hash: a, + inclusion_proof: InclusionProof { + index: 0, + size: 2, + root, + hashes: vec![b], + }, + signed_checkpoint: signed_checkpoint_at(2, root), + consistency_proof: None, + }; + verify_transparency_inclusion(&t).expect("inclusion against the checkpoint verifies"); + } + + #[test] + fn transparency_inclusion_mismatched_checkpoint_fails() { + let a = hash_leaf(b"artifact-a"); + let b = hash_leaf(b"artifact-b"); + let root = hash_children(&a, &b); + + let t = TransparencyInclusion { + leaf_hash: a, + inclusion_proof: InclusionProof { + index: 0, + size: 2, + root, + hashes: vec![b], + }, + // A checkpoint over a DIFFERENT root with no consistency proof must fail. + signed_checkpoint: signed_checkpoint_at(2, MerkleHash::from_bytes([0x99; 32])), + consistency_proof: None, + }; + assert!( + verify_transparency_inclusion(&t).is_err(), + "inclusion not anchored to the checkpoint must fail closed" + ); + } +} diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 0911c848..3983198c 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -58,14 +58,20 @@ pub mod core; pub mod credential; pub mod duplicity; pub mod error; +/// Offline verification of compliance evidence packs. +pub mod evidence_pack; /// C-compatible FFI bindings for attestation and chain verification. #[cfg(feature = "ffi")] pub mod ffi; /// OIDC-subject policy and the verify-time join for keyless CI signing. pub mod oidc_policy; +/// Offline verification of air-gapped org provenance bundles. +pub mod org_bundle; pub mod presentation; mod software_verify; pub mod ssh_sig; +/// Transparency-log verification primitives (Merkle proofs, checkpoints). +pub mod tlog; pub mod types; pub mod verifier; pub mod verify; diff --git a/crates/auths-verifier/src/org_bundle/bundle.rs b/crates/auths-verifier/src/org_bundle/bundle.rs new file mode 100644 index 00000000..5fcd06e7 --- /dev/null +++ b/crates/auths-verifier/src/org_bundle/bundle.rs @@ -0,0 +1,118 @@ +//! Air-gapped org provenance bundle — a self-contained, URL-free artifact. +//! +//! Packs everything an **offline** verifier needs to reproduce a first-party +//! org/member provenance verdict with the network cable unplugged: the org KEL +//! (which carries the delegation `KeyEvent` seals, delegator-anchored scope seals, +//! and revocation seals), each delegated member's own KEL, the durable off-boarding +//! records, and the pinned trust roots. The *builder* (which walks a live registry) +//! lives in `auths-sdk`; the wire types and their pure methods live here so every +//! verifier surface — native, FFI, browser WASM — shares one contract. +//! +//! **URL-free:** the bundle contains no registry / OOBI / witness URLs — air-gapped +//! verification cannot phone home. Identities are typed ([`IdentityDID`] / +//! [`Prefix`]) so a tampered or malformed identifier fails closed at +//! deserialization rather than flowing through as an opaque string. + +use auths_keri::{Event, Prefix}; +use serde::{Deserialize, Serialize}; + +use super::error::OrgBundleError; +use super::record::SignedOffboardingRecord; +use crate::types::IdentityDID; + +/// Schema version of the air-gapped org bundle wire format. Bump on any +/// breaking change to [`AirGappedOrgBundle`]. +pub const AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION: u32 = 1; + +/// One identifier's KEL plus its per-event signature attachments. +/// +/// `attachments[i]` is the hex-encoded CESR attachment for `events[i]` (empty when +/// the backend exposes none for that position). Carried so an offline verifier can +/// check signatures, not just recompute SAIDs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BundledKel { + /// The identifier's KEL prefix (validated on deserialize). + pub prefix: Prefix, + /// The KEL events, oldest first. + pub events: Vec, + /// Hex-encoded CESR signature attachments, parallel to `events`. + pub attachments: Vec, +} + +/// A self-contained, URL-free bundle for offline first-party org/member provenance. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AirGappedOrgBundle { + /// Wire-format schema version ([`AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION`]). + pub schema_version: u32, + /// The org's `did:keri:` (validated on deserialize). + pub org_did: IdentityDID, + /// The org's KEL prefix. + pub org_prefix: Prefix, + /// The org KEL sequence at build time — the offline verifier reports + /// "verified as-of KEL position X". + pub built_at_org_seq: u128, + /// Build timestamp (RFC 3339). Provenance only — authority ordering is by KEL + /// position, never this wall-clock value. + pub built_at: String, + /// The org's KEL (delegation, scope, and revocation seals all ride here). + pub org_kel: BundledKel, + /// Each delegated member's own KEL (live and revoked members both included so + /// the verifier can classify any artifact). + pub member_kels: Vec, + /// Durable, signed off-boarding records. + pub offboarding_records: Vec, + /// Pinned trust roots — for a first-party org bundle, the org itself. The + /// verifier trusts these DIDs and reads their keys from the bundled KEL. + pub pinned_roots: Vec, +} + +impl AirGappedOrgBundle { + /// Serialize the bundle to deterministic, canonical JSON (RFC 8785 via + /// `json-canon`) — the on-disk/wire form. + /// + /// Usage: + /// ```ignore + /// std::fs::write(out, bundle.to_canonical_json()?)?; + /// ``` + pub fn to_canonical_json(&self) -> Result { + json_canon::to_string(self) + .map_err(|e| OrgBundleError::Canonicalize(format!("org bundle: {e}"))) + } + + /// Parse a bundle from its JSON form. Typed identifiers fail closed on malformed + /// input. + /// + /// Usage: + /// ```ignore + /// let bundle = AirGappedOrgBundle::from_json(&std::fs::read_to_string(path)?)?; + /// ``` + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| OrgBundleError::Parse(format!("org bundle: {e}"))) + } + + /// The org's authenticated key state, derived from the bundled org KEL alone. + /// + /// Authenticates the embedded org KEL (RT-002 — every event SAID-correct AND + /// signed by the controlling key-state, not merely structurally valid) and + /// returns the resolved [`auths_keri::KeyState`]. The org KEL is the root of + /// trust, so it authenticates with no delegator lookup. + /// + /// This is the one public call for "give me the org's authenticated key state + /// from evidence alone" — the only trust-rooted source of the org's *current* + /// verkey available offline (e.g. to verify a DSSE envelope the org signed). + /// Every downstream verifier (CI gate, browser widget, third-party audit tool) + /// shares it instead of re-implementing the authenticate-then-resolve path. + /// + /// Fails closed ([`OrgBundleError::Integrity`]) on a tampered event, a length + /// mismatch between events and attachments, an unparseable attachment, or a + /// signature that does not verify. + /// + /// Usage: + /// ```ignore + /// let state = bundle.authenticated_org_state()?; + /// let org_verkey = state.current_key(); + /// ``` + pub fn authenticated_org_state(&self) -> Result { + super::verify::authenticate_bundled_kel(&self.org_kel, None) + } +} diff --git a/crates/auths-verifier/src/org_bundle/error.rs b/crates/auths-verifier/src/org_bundle/error.rs new file mode 100644 index 00000000..84a9afd8 --- /dev/null +++ b/crates/auths-verifier/src/org_bundle/error.rs @@ -0,0 +1,83 @@ +//! Typed failures verifying an air-gapped org bundle offline. + +use auths_crypto::AuthsErrorInfo; + +/// A failure verifying an air-gapped org bundle or off-boarding record. +/// +/// Every variant is fail-closed: none of these conditions ever yields a +/// "valid" verdict. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum OrgBundleError { + /// A bundled KEL failed integrity or authentication: an event's + /// recomputed SAID did not match its stored `d`, the signature + /// attachments could not be parsed, or a signature did not verify + /// against the controlling key-state (RT-002). + #[error("bundle integrity failure for '{id}': {reason}")] + Integrity { + /// The identifier whose KEL failed integrity. + id: String, + /// Why integrity failed. + reason: String, + }, + + /// The org KEL delegates a member whose own KEL is not in the bundle — + /// the bundle is incomplete and cannot be verified. Fail closed. + #[error("bundle is missing the KEL for delegated member '{member}'")] + MissingMemberKel { + /// The member's `did:keri:`. + member: String, + }, + + /// A queried member has no delegation seal in the org KEL — the org never + /// delegated it, so there is no authority to verify. Fail closed. + #[error("member '{member}' has no delegation seal in the org KEL")] + MissingDelegatorSeal { + /// The member's `did:keri:`. + member: String, + }, + + /// Canonical serialization (`json-canon`) of a bundle or record failed. + #[error("canonicalization failed: {0}")] + Canonicalize(String), + + /// A bundle or record could not be parsed from its JSON form. + #[error("parse failed: {0}")] + Parse(String), + + /// A signed off-boarding record failed verification: the signature did + /// not verify, the curve tag mismatched the org key, or the record is not + /// bound to a matching revocation seal on the org KEL. + #[error("offboarding record invalid: {0}")] + RecordInvalid(String), +} + +impl AuthsErrorInfo for OrgBundleError { + fn error_code(&self) -> &'static str { + match self { + Self::Integrity { .. } => "AUTHS-E2201", + Self::MissingMemberKel { .. } => "AUTHS-E2202", + Self::MissingDelegatorSeal { .. } => "AUTHS-E2203", + Self::Canonicalize(_) => "AUTHS-E2204", + Self::Parse(_) => "AUTHS-E2205", + Self::RecordInvalid(_) => "AUTHS-E2206", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::Integrity { .. } => Some( + "The bundle was modified after it was produced; obtain a fresh, untampered bundle", + ), + Self::MissingMemberKel { .. } | Self::MissingDelegatorSeal { .. } => { + Some("The bundle is incomplete; re-produce it with `auths org bundle`") + } + Self::Canonicalize(_) | Self::Parse(_) => { + Some("The file is not a valid air-gapped org bundle; re-export it") + } + Self::RecordInvalid(_) => Some( + "The off-boarding record does not match the org KEL; obtain a fresh bundle from the org", + ), + } + } +} diff --git a/crates/auths-verifier/src/org_bundle/mod.rs b/crates/auths-verifier/src/org_bundle/mod.rs new file mode 100644 index 00000000..21bbd880 --- /dev/null +++ b/crates/auths-verifier/src/org_bundle/mod.rs @@ -0,0 +1,35 @@ +//! Offline verification of an air-gapped org provenance bundle. +//! +//! The pure, zero-network core of the org/member provenance story: a +//! self-contained [`AirGappedOrgBundle`] (the org KEL, every delegated +//! member's KEL, durable off-boarding records, pinned roots) verifies with +//! the network cable unplugged — every event's SAID recomputed, every event's +//! signature authenticated against the controlling key-state (RT-002), +//! duplicity flagged, and a member's authority at a signing position +//! classified **by KEL position, never wall-clock**. +//! +//! It lives in the verifier crate — the leaf dependency every surface shares +//! — so a CI gate, a third-party audit tool, or a **browser** (via the WASM +//! exports) reproduces the same verdict from evidence alone. The bundle +//! *builder* (which needs a live registry) stays in `auths-sdk`; that crate +//! re-exports these types so there is exactly one definition of the wire +//! contract. + +/// Air-gapped bundle wire types. +pub mod bundle; +/// Typed failures for bundle and record verification. +pub mod error; +/// Durable, signed off-boarding records bound to on-KEL revocation seals. +pub mod record; +/// The offline verification engine. +pub mod verify; + +pub use bundle::{AIR_GAPPED_ORG_BUNDLE_SCHEMA_VERSION, AirGappedOrgBundle, BundledKel}; +pub use error::OrgBundleError; +pub use record::{ + OffboardingRecord, SignedOffboardingRecord, find_revocation_event, verify_offboarding_record, +}; +pub use verify::{ + AuthorityAtSigning, OfflineVerifyReport, authenticate_bundled_kel, + classify_authority_in_bundle, verify_org_bundle, verify_org_bundle_json, +}; diff --git a/crates/auths-verifier/src/org_bundle/record.rs b/crates/auths-verifier/src/org_bundle/record.rs new file mode 100644 index 00000000..9d6fba28 --- /dev/null +++ b/crates/auths-verifier/src/org_bundle/record.rs @@ -0,0 +1,144 @@ +//! Off-boarding audit records — durable, signed, seal-bound evidence. +//! +//! Revoking an org member anchors a revocation seal in the org KEL (the +//! provable event). These types turn that *action* into *evidence*: a typed +//! [`OffboardingRecord`] signed by the org key and **bound to the revocation +//! seal** — who off-boarded whom, at which **KEL position** (never +//! wall-clock), why, and a snapshot of the role + capabilities the subject +//! lost. The signing/storage side lives in `auths-sdk` (it needs a live +//! keychain and registry); the wire types and the pure verification live +//! here so any offline verifier checks a record from evidence alone. + +use auths_crypto::CurveType; +use auths_keri::{Event, KeriPublicKey, Prefix, Seal}; +use serde::{Deserialize, Serialize}; + +use super::error::OrgBundleError; + +/// Parse a curve tag back to a [`CurveType`]; unknown/missing defaults to P-256 +/// (the workspace default, per the wire-format rule). +fn curve_from_tag(tag: &str) -> CurveType { + match tag { + "ed25519" => CurveType::Ed25519, + _ => CurveType::P256, + } +} + +/// A typed off-boarding record — the durable evidence emitted alongside a member +/// revocation. Ordering is by **KEL position** (`revoked_at_seq`), never wall-clock. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OffboardingRecord { + /// The org's `did:keri:` (the delegator that off-boarded the member). + pub org_did: String, + /// The off-boarded member's `did:keri:`. + pub member_did: String, + /// The KEL sequence of the org's revocation event — the exact position after + /// which the member's authority is gone. Causal, not wall-clock. + pub revoked_at_seq: u128, + /// The SAID of the org KEL event carrying the revocation seal (anti-forgery + /// binding: the record is only valid against this on-KEL event). + pub revocation_seal_said: String, + /// Optional operator-supplied reason for the off-boarding. + pub reason: Option, + /// The DID that authored the revocation. For a `kt=1` org this is the org DID. + pub operator_did: String, + /// The role the member held at the revocation position (what they lost). + pub prior_role: Option, + /// The capabilities the member held at the revocation position (what they lost). + pub prior_caps: Vec, + /// When the record was recorded (RFC 3339, injected clock). Provenance only — + /// authority ordering is by `revoked_at_seq`, never this timestamp. + pub recorded_at: String, +} + +/// An [`OffboardingRecord`] plus the org's signature over its canonical form. The +/// signature's curve travels in-band (`org_curve`) per the wire-format rule. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedOffboardingRecord { + /// The signed record payload. + pub record: OffboardingRecord, + /// In-band curve tag for `signature` (`"ed25519"` / `"p256"`). + pub org_curve: String, + /// Hex-encoded org signature over `json_canon(record)`. + pub signature: String, +} + +/// Locate the org KEL event that carries the revocation seal for `member_prefix`. +/// +/// Returns `(seal_event_said, sequence)` for the latest such event, or `None` if the +/// org never revoked the member. The revocation seal is a `Seal::Digest` whose digest +/// equals the member prefix (the convention the revocation writer uses). +pub fn find_revocation_event(org_kel: &[Event], member_prefix: &Prefix) -> Option<(String, u128)> { + let mut found = None; + for event in org_kel { + for seal in event.anchors() { + if let Seal::Digest { d } = seal + && d.as_str() == member_prefix.as_str() + { + found = Some((event.said().as_str().to_string(), event.sequence().value())); + } + } + } + found +} + +/// Verify a signed off-boarding record: the org signature is valid **and** the record +/// is bound to a matching revocation event on the org KEL. +/// +/// Fails closed if the signature does not verify (record tampered) or no org KEL +/// event with the record's `revocation_seal_said` revokes the member at +/// `revoked_at_seq` (seal tampered / record forged). +/// +/// Args: +/// * `signed`: The signed record to verify. +/// * `org_public_key`: The org's current verkey bytes (resolved from its KEL). +/// * `org_curve`: The org key's curve. +/// * `org_kel`: The org's KEL events (oldest first). +/// +/// Usage: +/// ```ignore +/// verify_offboarding_record(&signed, &org_pk, org_curve, &org_kel)?; +/// ``` +pub fn verify_offboarding_record( + signed: &SignedOffboardingRecord, + org_public_key: &[u8], + org_curve: CurveType, + org_kel: &[Event], +) -> Result<(), OrgBundleError> { + if curve_from_tag(&signed.org_curve) != org_curve { + return Err(OrgBundleError::RecordInvalid( + "offboarding record curve tag does not match the org key curve".to_string(), + )); + } + + let canonical = json_canon::to_string(&signed.record) + .map_err(|e| OrgBundleError::Canonicalize(format!("offboarding record: {e}")))?; + let sig = hex::decode(&signed.signature) + .map_err(|e| OrgBundleError::RecordInvalid(format!("decode offboarding signature: {e}")))?; + let key = KeriPublicKey::from_verkey_bytes(org_public_key, org_curve) + .map_err(|e| OrgBundleError::RecordInvalid(format!("invalid org public key: {e}")))?; + key.verify_signature(canonical.as_bytes(), &sig) + .map_err(|e| { + OrgBundleError::RecordInvalid(format!("offboarding signature invalid: {e}")) + })?; + + let member_prefix = Prefix::new_unchecked( + signed + .record + .member_did + .strip_prefix("did:keri:") + .unwrap_or(&signed.record.member_did) + .to_string(), + ); + match find_revocation_event(org_kel, &member_prefix) { + Some((said, seq)) + if said == signed.record.revocation_seal_said + && seq == signed.record.revoked_at_seq => + { + Ok(()) + } + _ => Err(OrgBundleError::RecordInvalid( + "offboarding record is not bound to a matching org KEL revocation seal".to_string(), + )), + } +} diff --git a/crates/auths-verifier/src/org_bundle/verify.rs b/crates/auths-verifier/src/org_bundle/verify.rs new file mode 100644 index 00000000..4ca7e6fd --- /dev/null +++ b/crates/auths-verifier/src/org_bundle/verify.rs @@ -0,0 +1,422 @@ +//! Offline verification of an air-gapped org bundle — a pure, zero-network function +//! of the bundle's contents. +//! +//! Reproduces the live org/member provenance verdict from an +//! [`AirGappedOrgBundle`] with the network cable unplugged: it recomputes every +//! bundled event's SAID (tamper-evident), authenticates every event's signature +//! against the controlling key-state (RT-002), confirms the org is a **pinned** +//! root, flags KEL duplicity, and classifies a member's authority at a signing +//! position **by KEL position, never wall-clock**. +//! +//! Fail-closed: a tampered event (SAID mismatch), a delegated member whose KEL is +//! missing, or a queried member with no delegation seal each yield a **named hard +//! error**, never "valid." A wrong/non-delegating pinned root is reported as +//! `root_pinned = false` (unauthorized), not an error. + +use auths_keri::{Event, Prefix, Seal, verify_event_said}; +use serde::Serialize; + +use super::bundle::{AirGappedOrgBundle, BundledKel}; +use super::error::OrgBundleError; +use super::record::find_revocation_event; +use crate::duplicity::{KelEventRef, detect_duplicity}; +use crate::types::IdentityDID; + +/// Maximum accepted JSON input for the JSON/WASM surface (16 MiB) — a bundle +/// carries whole KELs, so the ceiling is higher than the attestation contract's. +pub const MAX_BUNDLE_JSON_BYTES: usize = 16 * 1024 * 1024; + +/// A member's authority at the moment an artifact was signed — a closed sum ordered +/// strictly by **KEL position** (never wall-clock). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(tag = "authority_at_signing", rename_all = "snake_case")] +pub enum AuthorityAtSigning { + /// The member's authority was live at the signing position — the artifact was + /// signed strictly before the revocation, or the member was never revoked. + AuthorizedBeforeRevocation, + /// The artifact was signed at or after the org revoked the member, by KEL + /// position. Carries the exact revocation position. + RejectedAfterRevocation { + /// The KEL position at which the org anchored the revocation. + #[serde(with = "u128_str")] + revoked_at: u128, + }, + /// The org revoked the member but the artifact carries no in-band signing + /// position, so it cannot be ordered — conservatively rejected (mirrors the + /// verifier's no-position default). + RejectedRevokedPositionUnknown { + /// The KEL position at which the org anchored the revocation. + #[serde(with = "u128_str")] + revoked_at: u128, + }, + /// The org never delegated this member — there is no authority to classify. + NeverDelegated, +} + +/// (De)serialize a `u128` KEL position as a decimal string. +/// +/// JSON numbers lose precision above 2^53, and serde's internally-tagged-enum +/// buffer cannot round-trip 128-bit integers — so KEL positions travel as strings +/// (the same convention [`auths_keri::KeriSequence`] uses for its hex form). +mod u128_str { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &u128, serializer: S) -> Result { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// The result of verifying an air-gapped org bundle offline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OfflineVerifyReport { + /// The org the bundle is for. + pub org_did: IdentityDID, + /// The org KEL position the bundle (and therefore this verdict) is verified + /// as-of — "verified as-of KEL position X." + pub as_of_org_seq: u128, + /// Whether the org is in the caller's pinned trust roots. `false` = + /// unauthorized (the verdict cannot be trusted), not an error. + pub root_pinned: bool, + /// Whether the org KEL shows duplicity (same seq, divergent SAIDs). Flagged, + /// never silently accepted. + pub duplicity_detected: bool, + /// The queried member's authority at the signing position, when a member + + /// position were supplied. Ordered by KEL position, never wall-clock. + pub authority: Option, +} + +/// Recompute and check every event's SAID in a bundled KEL (tamper detection). +fn check_kel_integrity(kel: &BundledKel) -> Result<(), OrgBundleError> { + for event in &kel.events { + verify_event_said(event).map_err(|e| OrgBundleError::Integrity { + id: kel.prefix.as_str().to_string(), + reason: e.to_string(), + })?; + } + Ok(()) +} + +/// Authenticate a bundled KEL (RT-002): verify every event is SIGNED by the +/// controlling key-state, not just SAID-correct. Reconstructs `SignedEvent`s from +/// the parallel `events` × hex `attachments` and replays them through +/// `validate_signed_kel`. Fails closed on a length mismatch, an unparseable +/// attachment, or a signature that doesn't verify. +/// +/// Returns the authenticated [`auths_keri::KeyState`] — the only trust-rooted +/// source of the controller's *current* verkey available offline (e.g. to verify +/// a DSSE envelope signed by the org whose KEL the evidence embeds). +/// +/// This is the shared ecosystem primitive: a downstream verifier (CI gate, +/// browser widget, third-party audit tool) holding a [`BundledKel`] gets the +/// controller's authenticated key state from evidence alone in one call, rather +/// than reconstructing `SignedEvent`s and replaying `validate_signed_kel` itself. +/// For an [`AirGappedOrgBundle`], prefer +/// [`AirGappedOrgBundle::authenticated_org_state`]. +pub fn authenticate_bundled_kel( + kel: &BundledKel, + lookup: Option<&dyn auths_keri::DelegatorKelLookup>, +) -> Result { + let integrity_err = |reason: String| OrgBundleError::Integrity { + id: kel.prefix.as_str().to_string(), + reason, + }; + if kel.attachments.len() != kel.events.len() { + return Err(integrity_err(format!( + "{} events but {} signature attachments — cannot authenticate", + kel.events.len(), + kel.attachments.len() + ))); + } + let mut signed = Vec::with_capacity(kel.events.len()); + for (event, att_hex) in kel.events.iter().zip(kel.attachments.iter()) { + let bytes = + hex::decode(att_hex).map_err(|e| integrity_err(format!("non-hex attachment: {e}")))?; + // A delegated (dip/drt) event's attachment is `-A ++ -G `; + // a plain event's is just `-A `. `parse_delegated_attachment` handles both. + let (sigs, seals) = auths_keri::parse_delegated_attachment(&bytes) + .map_err(|e| integrity_err(format!("unparseable attachment: {e}")))?; + // A dip/drt JSON body carries no source seal (it lives in the attachment); + // restore it so the delegation binding can be authenticated. + let event = rehydrate_event_source_seal(event.clone(), seals.into_iter().next()); + signed.push(auths_keri::SignedEvent::new(event, sigs)); + } + auths_keri::validate_signed_kel(&signed, lookup) + .map_err(|e| integrity_err(format!("KEL signature authentication failed (RT-002): {e}"))) +} + +/// Re-attach a delegated event's source seal from its parsed attachment — the JSON +/// body of a `dip`/`drt` carries none (it lives in the `-G` group). Mirrors the +/// storage layer's `rehydrate_source_seal`. +fn rehydrate_event_source_seal(event: Event, seal: Option) -> Event { + let Some(seal) = seal else { + return event; + }; + match event { + Event::Dip(mut e) => { + e.source_seal = Some(seal); + Event::Dip(e) + } + Event::Drt(mut e) => { + e.source_seal = Some(seal); + Event::Drt(e) + } + other => other, + } +} + +/// Does the org KEL anchor a delegation `KeyEvent` seal for `member_prefix`? +fn is_delegated(org_kel: &[Event], member_prefix: &Prefix) -> bool { + org_kel.iter().any(|event| { + event.anchors().iter().any( + |seal| matches!(seal, Seal::KeyEvent { i, .. } if i.as_str() == member_prefix.as_str()), + ) + }) +} + +/// Classify a member's authority at `signed_at` from an air-gapped bundle's org KEL — +/// the offline mirror of the live registry classifier, for re-deriving compliance +/// evidence-pack rows with zero network. +/// +/// Args: +/// * `bundle`: The air-gapped org bundle (its org KEL is the authority source). +/// * `member_prefix`: The member to classify. +/// * `signed_at`: The artifact's in-band signing position, if any. +/// +/// Usage: +/// ```ignore +/// let verdict = classify_authority_in_bundle(&bundle, &member, Some(41)); +/// ``` +pub fn classify_authority_in_bundle( + bundle: &AirGappedOrgBundle, + member_prefix: &Prefix, + signed_at: Option, +) -> AuthorityAtSigning { + classify_from_bundle(&bundle.org_kel.events, member_prefix, signed_at) +} + +/// Classify a member's authority at `signed_at`, read purely from the bundle's org +/// KEL. +fn classify_from_bundle( + org_kel: &[Event], + member_prefix: &Prefix, + signed_at: Option, +) -> AuthorityAtSigning { + if !is_delegated(org_kel, member_prefix) { + return AuthorityAtSigning::NeverDelegated; + } + match find_revocation_event(org_kel, member_prefix) { + None => AuthorityAtSigning::AuthorizedBeforeRevocation, + Some((_, revoked_at)) => match signed_at { + None => AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }, + Some(seq) if seq < revoked_at => AuthorityAtSigning::AuthorizedBeforeRevocation, + Some(_) => AuthorityAtSigning::RejectedAfterRevocation { revoked_at }, + }, + } +} + +/// Verify an air-gapped org bundle offline. +/// +/// Pure and network-free. Checks bundle integrity (every event's SAID), +/// authenticates every event's signature (RT-002), confirms the org is pinned, +/// flags org-KEL duplicity, and — when `query` supplies a member and optional +/// signing position — classifies that member's authority by KEL position. +/// +/// Fail-closed errors (never "valid"): [`OrgBundleError::Integrity`] (a tampered +/// event), [`OrgBundleError::MissingMemberKel`] (a delegated member's KEL is +/// absent), [`OrgBundleError::MissingDelegatorSeal`] (a queried member was never +/// delegated). +/// +/// Args: +/// * `bundle`: The air-gapped bundle to verify. +/// * `pinned_roots`: The caller's pinned trust roots (e.g. from `.auths/roots`). +/// * `query`: Optional `(member_prefix, signed_at)` to classify a specific artifact. +/// +/// Usage: +/// ```ignore +/// let report = verify_org_bundle(&bundle, &roots, Some((&member, Some(41))))?; +/// assert!(report.root_pinned); +/// ``` +pub fn verify_org_bundle( + bundle: &AirGappedOrgBundle, + pinned_roots: &[IdentityDID], + query: Option<(&Prefix, Option)>, +) -> Result { + // 1. Integrity + AUTHENTICATION (RT-002): every bundled event must self-address + // (SAID) AND be signed by the controlling key-state — not just structurally + // valid. The org KEL is the root of trust (no delegator); member KELs are + // authenticated against the org as delegator. + check_kel_integrity(&bundle.org_kel)?; + authenticate_bundled_kel(&bundle.org_kel, None)?; + let org_lookup = auths_keri::KelSealIndex::from_events(&bundle.org_kel.events); + for member_kel in &bundle.member_kels { + check_kel_integrity(member_kel)?; + authenticate_bundled_kel(member_kel, Some(&org_lookup))?; + } + + // 2. Completeness: every member the org delegated must ship its own KEL. + for event in &bundle.org_kel.events { + for seal in event.anchors() { + if let Seal::KeyEvent { i, .. } = seal { + let present = bundle + .member_kels + .iter() + .any(|m| m.prefix.as_str() == i.as_str()); + if !present { + return Err(OrgBundleError::MissingMemberKel { + member: format!("did:keri:{}", i.as_str()), + }); + } + } + } + } + + // 3. Trust: is the org a pinned root? (false = unauthorized, not an error.) + let root_pinned = pinned_roots + .iter() + .any(|r| r.as_str() == bundle.org_did.as_str()); + + // 4. Duplicity: same-seq divergent SAIDs on the org KEL — flag, never accept. + let org_did_str = bundle.org_did.as_str().to_string(); + let refs: Vec> = bundle + .org_kel + .events + .iter() + .map(|e| KelEventRef { + prefix: org_did_str.as_str(), + seq: e.sequence().value() as u64, + said: e.said().as_str(), + }) + .collect(); + let duplicity_detected = detect_duplicity(&refs).is_diverging(); + + // 5. Authority classification for the queried member, by KEL position. + let authority = match query { + Some((member_prefix, signed_at)) => { + if !is_delegated(&bundle.org_kel.events, member_prefix) { + return Err(OrgBundleError::MissingDelegatorSeal { + member: format!("did:keri:{}", member_prefix.as_str()), + }); + } + Some(classify_from_bundle( + &bundle.org_kel.events, + member_prefix, + signed_at, + )) + } + None => None, + }; + + Ok(OfflineVerifyReport { + org_did: bundle.org_did.clone(), + as_of_org_seq: bundle.built_at_org_seq, + root_pinned, + duplicity_detected, + authority, + }) +} + +// ── JSON contract (the WASM/FFI-facing form) ─────────────────────────────── + +/// The tagged verdict envelope for [`verify_org_bundle_json`]. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum BundleVerdictJson { + /// Verification ran to completion; the report carries the verdict axes. + #[serde(rename = "report")] + Report { + /// The offline verification report. + report: OfflineVerifyReport, + }, + /// Verification failed closed (tampered bundle, incomplete bundle, bad input). + #[serde(rename = "error")] + Error { + /// The stable `AUTHS-Exxxx` code. + code: String, + /// Human-readable detail. + message: String, + }, +} + +/// A last-resort verdict used only if envelope serialization itself fails. +const SERIALIZE_FALLBACK: &str = + r#"{"kind":"error","code":"AUTHS-E2204","message":"verdict serialization failed"}"#; + +fn envelope_to_string(envelope: &BundleVerdictJson) -> String { + serde_json::to_string(envelope).unwrap_or_else(|_| SERIALIZE_FALLBACK.to_string()) +} + +fn error_envelope(e: &OrgBundleError) -> String { + use auths_crypto::AuthsErrorInfo; + envelope_to_string(&BundleVerdictJson::Error { + code: e.error_code().to_string(), + message: e.to_string(), + }) +} + +/// Verify an air-gapped org bundle from its JSON wire forms — the +/// string-in/string-out contract the WASM surface exposes. +/// +/// Panic-free and synchronous: malformed or oversize input returns a tagged +/// `error` envelope, never an exception. The verdict is a discriminated union +/// (`kind`: `"report"` | `"error"`), never a bare bool. +/// +/// Args: +/// * `bundle_json`: The [`AirGappedOrgBundle`] JSON (the `.auths-offline` file). +/// * `pinned_roots_json`: JSON array of the verifier's pinned `did:keri:` roots. +/// * `member_did`: Optional member to classify (`did:keri:` or bare prefix). +/// * `signed_at`: Optional in-band signing KEL position, as a decimal string. +/// +/// Usage: +/// ```ignore +/// let verdict = verify_org_bundle_json(&bundle, r#"["did:keri:EOrg"]"#, None, None); +/// ``` +pub fn verify_org_bundle_json( + bundle_json: &str, + pinned_roots_json: &str, + member_did: Option<&str>, + signed_at: Option<&str>, +) -> String { + match verify_org_bundle_json_inner(bundle_json, pinned_roots_json, member_did, signed_at) { + Ok(report) => envelope_to_string(&BundleVerdictJson::Report { report }), + Err(e) => error_envelope(&e), + } +} + +fn verify_org_bundle_json_inner( + bundle_json: &str, + pinned_roots_json: &str, + member_did: Option<&str>, + signed_at: Option<&str>, +) -> Result { + if bundle_json.len() > MAX_BUNDLE_JSON_BYTES { + return Err(OrgBundleError::Parse(format!( + "bundle JSON too large: {} bytes, max {}", + bundle_json.len(), + MAX_BUNDLE_JSON_BYTES + ))); + } + let bundle = AirGappedOrgBundle::from_json(bundle_json)?; + let pinned_roots: Vec = serde_json::from_str(pinned_roots_json) + .map_err(|e| OrgBundleError::Parse(format!("pinned roots: {e}")))?; + + let member_prefix = member_did + .map(|m| { + Prefix::new(m.strip_prefix("did:keri:").unwrap_or(m).to_string()) + .map_err(|e| OrgBundleError::Parse(format!("member prefix: {e}"))) + }) + .transpose()?; + let signed_at = signed_at + .map(|s| { + s.parse::() + .map_err(|e| OrgBundleError::Parse(format!("signed_at: {e}"))) + }) + .transpose()?; + + let query = member_prefix.as_ref().map(|p| (p, signed_at)); + verify_org_bundle(&bundle, &pinned_roots, query) +} diff --git a/crates/auths-verifier/src/tlog/checkpoint.rs b/crates/auths-verifier/src/tlog/checkpoint.rs new file mode 100644 index 00000000..bf47a0f1 --- /dev/null +++ b/crates/auths-verifier/src/tlog/checkpoint.rs @@ -0,0 +1,153 @@ +//! Log checkpoints (signed tree heads) and witness cosignatures. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::error::TransparencyError; +use super::types::{LogOrigin, MerkleHash}; +use crate::core::{EcdsaP256PublicKey, EcdsaP256Signature, Ed25519PublicKey, Ed25519Signature}; + +/// An unsigned transparency log checkpoint. +/// +/// Args: +/// * `origin` — Log origin string (e.g., "auths.dev/log"). +/// * `size` — Number of entries in the log at this checkpoint. +/// * `root` — Merkle root hash of the log at this size. +/// * `timestamp` — When the checkpoint was created. +/// +/// Usage: +/// ```ignore +/// let cp = Checkpoint { +/// origin: LogOrigin::new("auths.dev/log")?, +/// size: 42, +/// root: merkle_root, +/// timestamp: Utc::now(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct Checkpoint { + pub origin: LogOrigin, + pub size: u64, + pub root: MerkleHash, + pub timestamp: DateTime, +} + +impl Checkpoint { + /// Serialize to the C2SP checkpoint body format (three lines: origin, size, base64 hash). + pub fn to_note_body(&self) -> String { + format!( + "{}\n{}\n{}\n", + self.origin, + self.size, + self.root.to_base64() + ) + } + + /// Parse from C2SP checkpoint body lines. + pub fn from_note_body(body: &str, timestamp: DateTime) -> Result { + let lines: Vec<&str> = body.lines().collect(); + if lines.len() < 3 { + return Err(TransparencyError::InvalidNote( + "checkpoint body must have at least 3 lines".into(), + )); + } + let origin = LogOrigin::new(lines[0])?; + let size: u64 = lines[1] + .parse() + .map_err(|e: std::num::ParseIntError| TransparencyError::InvalidNote(e.to_string()))?; + let root = MerkleHash::from_base64(lines[2])?; + Ok(Self { + origin, + size, + root, + timestamp, + }) + } +} + +/// A checkpoint signed by the log operator (and optionally witnesses). +/// +/// Args: +/// * `checkpoint` — The unsigned checkpoint data. +/// * `log_signature` — Ed25519 signature from the log's signing key. +/// * `log_public_key` — The log operator's public key. +/// * `witnesses` — Optional witness cosignatures. +/// +/// Usage: +/// ```ignore +/// let signed = SignedCheckpoint { +/// checkpoint, +/// log_signature: sig, +/// log_public_key: log_pk, +/// witnesses: vec![], +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct SignedCheckpoint { + pub checkpoint: Checkpoint, + pub log_signature: Ed25519Signature, + pub log_public_key: Ed25519PublicKey, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub witnesses: Vec, + /// ECDSA P-256 checkpoint signature (DER-encoded). Present when the log + /// uses ECDSA instead of Ed25519 (e.g., Rekor production shard). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ecdsa_checkpoint_signature: Option, + /// ECDSA P-256 public key for checkpoint verification (PKIX DER). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ecdsa_checkpoint_key: Option, +} + +/// A witness cosignature on a checkpoint. +/// +/// Witnesses independently verify the checkpoint and add their signature +/// to increase trust in the log's consistency claims. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct WitnessCosignature { + pub witness_name: String, + pub witness_public_key: Ed25519PublicKey, + pub signature: Ed25519Signature, + pub timestamp: DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checkpoint_note_body_roundtrip() { + let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let cp = Checkpoint { + origin: LogOrigin::new("auths.dev/log").unwrap(), + size: 42, + root: MerkleHash::from_bytes([0xab; 32]), + timestamp: ts, + }; + let body = cp.to_note_body(); + let parsed = Checkpoint::from_note_body(&body, ts).unwrap(); + assert_eq!(cp.origin, parsed.origin); + assert_eq!(cp.size, parsed.size); + assert_eq!(cp.root, parsed.root); + } + + #[test] + fn checkpoint_json_roundtrip() { + let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let cp = Checkpoint { + origin: LogOrigin::new("auths.dev/log").unwrap(), + size: 100, + root: MerkleHash::from_bytes([0x01; 32]), + timestamp: ts, + }; + let json = serde_json::to_string(&cp).unwrap(); + let back: Checkpoint = serde_json::from_str(&json).unwrap(); + assert_eq!(cp, back); + } +} diff --git a/crates/auths-verifier/src/tlog/error.rs b/crates/auths-verifier/src/tlog/error.rs new file mode 100644 index 00000000..1a9c147d --- /dev/null +++ b/crates/auths-verifier/src/tlog/error.rs @@ -0,0 +1,43 @@ +//! The transparency-log error contract, shared by proof verification (here) +//! and log construction/storage (`auths-transparency`). + +/// Errors from transparency log operations. +#[derive(Debug, Clone, thiserror::Error)] +#[allow(missing_docs)] +pub enum TransparencyError { + /// Invalid Merkle proof structure. + #[error("invalid proof: {0}")] + InvalidProof(String), + + /// Merkle root mismatch during verification. + #[error("root mismatch: expected {expected}, got {actual}")] + RootMismatch { expected: String, actual: String }, + + /// Invalid signed note format. + #[error("invalid note: {0}")] + InvalidNote(String), + + /// Signature verification failed on a checkpoint note. + #[error("invalid checkpoint signature")] + InvalidCheckpointSignature, + + /// Tile path encoding error. + #[error("invalid tile path: {0}")] + InvalidTilePath(String), + + /// Invalid log origin string. + #[error("invalid log origin: {0}")] + InvalidOrigin(String), + + /// Entry serialization or deserialization failure. + #[error("entry error: {0}")] + EntryError(String), + + /// Consistency proof verification failed. + #[error("consistency check failed: {0}")] + ConsistencyError(String), + + /// Storage backend error. + #[error("store error: {0}")] + StoreError(String), +} diff --git a/crates/auths-verifier/src/tlog/merkle.rs b/crates/auths-verifier/src/tlog/merkle.rs new file mode 100644 index 00000000..e78d05ae --- /dev/null +++ b/crates/auths-verifier/src/tlog/merkle.rs @@ -0,0 +1,605 @@ +//! RFC 6962 Merkle tree hashing and proof verification. + +use sha2::{Digest, Sha256}; + +use super::error::TransparencyError; +use super::types::MerkleHash; + +/// RFC 6962 leaf domain separator. +const LEAF_PREFIX: u8 = 0x00; +/// RFC 6962 interior node domain separator. +const NODE_PREFIX: u8 = 0x01; + +/// Hash a leaf value with RFC 6962 domain separation: `SHA-256(0x00 || data)`. +/// +/// Args: +/// * `data` — Raw leaf bytes (typically canonical JSON of an entry). +/// +/// Usage: +/// ```ignore +/// let leaf = hash_leaf(b"entry data"); +/// ``` +pub fn hash_leaf(data: &[u8]) -> MerkleHash { + let mut hasher = Sha256::new(); + hasher.update([LEAF_PREFIX]); + hasher.update(data); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + MerkleHash::from_bytes(out) +} + +/// Hash two child nodes with RFC 6962 domain separation: `SHA-256(0x01 || left || right)`. +/// +/// Args: +/// * `left` — Left child hash. +/// * `right` — Right child hash. +/// +/// Usage: +/// ```ignore +/// let parent = hash_children(&left_hash, &right_hash); +/// ``` +pub fn hash_children(left: &MerkleHash, right: &MerkleHash) -> MerkleHash { + let mut hasher = Sha256::new(); + hasher.update([NODE_PREFIX]); + hasher.update(left.as_bytes()); + hasher.update(right.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + MerkleHash::from_bytes(out) +} + +/// Verify a Merkle inclusion proof for a leaf at a given index in a tree of `size` leaves. +/// +/// Uses RFC 6962 proof verification: walk from the leaf hash up to the root, +/// combining with proof hashes left or right depending on the index bits. +/// +/// Args: +/// * `leaf_hash` — The hash of the leaf being proven. +/// * `index` — Zero-based index of the leaf. +/// * `size` — Total number of leaves in the tree. +/// * `proof` — Ordered list of sibling hashes from leaf to root. +/// * `root` — Expected Merkle root. +/// +/// Usage: +/// ```ignore +/// verify_inclusion(&leaf_hash, 5, 16, &proof_hashes, &expected_root)?; +/// ``` +pub fn verify_inclusion( + leaf_hash: &MerkleHash, + index: u64, + size: u64, + proof: &[MerkleHash], + root: &MerkleHash, +) -> Result<(), TransparencyError> { + if size == 0 { + return Err(TransparencyError::InvalidProof("tree size is 0".into())); + } + if index >= size { + return Err(TransparencyError::InvalidProof(format!( + "index {index} >= size {size}" + ))); + } + + let (computed, _) = root_from_inclusion_proof(leaf_hash, index, size, proof)?; + + if computed != *root { + return Err(TransparencyError::RootMismatch { + expected: root.to_string(), + actual: computed.to_string(), + }); + } + Ok(()) +} + +/// Compute the root hash from an inclusion proof. +/// +/// Returns `(root, proof_elements_consumed)`. +fn root_from_inclusion_proof( + leaf_hash: &MerkleHash, + index: u64, + size: u64, + proof: &[MerkleHash], +) -> Result<(MerkleHash, usize), TransparencyError> { + let expected_len = inclusion_proof_length(index, size); + if proof.len() != expected_len { + return Err(TransparencyError::InvalidProof(format!( + "expected {expected_len} proof elements, got {}", + proof.len() + ))); + } + + let mut hash = *leaf_hash; + let mut idx = index; + let mut level_size = size; + let mut pos = 0; + + while level_size > 1 { + if pos >= proof.len() { + return Err(TransparencyError::InvalidProof("proof too short".into())); + } + if idx & 1 == 1 || idx + 1 == level_size { + if idx & 1 == 1 { + hash = hash_children(&proof[pos], &hash); + pos += 1; + } + } else { + hash = hash_children(&hash, &proof[pos]); + pos += 1; + } + idx >>= 1; + level_size = (level_size + 1) >> 1; + } + + Ok((hash, pos)) +} + +/// Compute the expected number of proof elements for an inclusion proof. +fn inclusion_proof_length(index: u64, size: u64) -> usize { + if size <= 1 { + return 0; + } + let mut length = 0; + let mut idx = index; + let mut level_size = size; + while level_size > 1 { + if idx & 1 == 1 || idx + 1 < level_size { + length += 1; + } + idx >>= 1; + level_size = (level_size + 1) >> 1; + } + length +} + +/// Verify a consistency proof between an old tree of `old_size` and a new tree of `new_size`. +/// +/// Ensures the new tree is an append-only extension of the old tree. +/// +/// Args: +/// * `old_size` — Number of leaves in the older tree. +/// * `new_size` — Number of leaves in the newer tree. +/// * `proof` — Ordered consistency proof hashes. +/// * `old_root` — Root of the older tree. +/// * `new_root` — Root of the newer tree. +/// +/// Usage: +/// ```ignore +/// verify_consistency(8, 16, &proof, &old_root, &new_root)?; +/// ``` +pub fn verify_consistency( + old_size: u64, + new_size: u64, + proof: &[MerkleHash], + old_root: &MerkleHash, + new_root: &MerkleHash, +) -> Result<(), TransparencyError> { + if old_size == 0 { + if proof.is_empty() { + return Ok(()); + } + return Err(TransparencyError::ConsistencyError( + "non-empty proof for empty old tree".into(), + )); + } + if old_size > new_size { + return Err(TransparencyError::ConsistencyError(format!( + "old size {old_size} > new size {new_size}" + ))); + } + if old_size == new_size { + if !proof.is_empty() { + return Err(TransparencyError::ConsistencyError( + "non-empty proof for equal sizes".into(), + )); + } + if old_root != new_root { + return Err(TransparencyError::RootMismatch { + expected: old_root.to_string(), + actual: new_root.to_string(), + }); + } + return Ok(()); + } + + // Reconstruct new root from the consistency proof while implicitly verifying old root. + // For power-of-2 old_size, old_root is used directly as the starting hash. + // For non-power-of-2, proof elements reconstruct old_root via the bit-walking algorithm. + let new_computed = new_root_from_consistency_proof(old_size, new_size, proof, old_root)?; + + if new_computed != *new_root { + return Err(TransparencyError::RootMismatch { + expected: new_root.to_string(), + actual: new_computed.to_string(), + }); + } + Ok(()) +} + +/// Reconstruct new root from an RFC 6962 SUBPROOF-format consistency proof. +/// +/// The proof is produced by the SUBPROOF(m, D[0:n], b=true) algorithm from +/// RFC 6962 Section 2.1.2. Verification walks the bit pattern of (old_size - 1) +/// to reconstruct both old_root (for validation) and new_root. +/// +/// Phase 1 (decomposition): each bit of (old_size - 1) determines whether a +/// proof element is a left sibling (set bit → combines into both roots) or +/// a right sibling (unset bit → combines into new root only). +/// +/// Phase 2 (extension): remaining proof elements extend the accumulator to +/// the new root. +fn new_root_from_consistency_proof( + old_size: u64, + new_size: u64, + proof: &[MerkleHash], + old_root: &MerkleHash, +) -> Result { + let _ = new_size; // used only in debug assertions via caller + + let (mut fn_hash, mut fr_hash, start) = if old_size.is_power_of_two() { + // Old tree is a single complete subtree — no decomposition needed + (*old_root, *old_root, 0) + } else { + if proof.is_empty() { + return Err(TransparencyError::ConsistencyError( + "proof too short".into(), + )); + } + (proof[0], proof[0], 1) + }; + + let mut pos = start; + + // Phase 1: walk bits of (old_size - 1) to decompose/reconstruct old root + if !old_size.is_power_of_two() { + let mut bit = old_size - 1; + while bit > 0 { + if pos >= proof.len() { + return Err(TransparencyError::ConsistencyError( + "proof too short during decomposition".into(), + )); + } + if bit & 1 != 0 { + fn_hash = hash_children(&proof[pos], &fn_hash); + fr_hash = hash_children(&proof[pos], &fr_hash); + } else { + fr_hash = hash_children(&fr_hash, &proof[pos]); + } + pos += 1; + bit >>= 1; + } + + if fn_hash != *old_root { + return Err(TransparencyError::RootMismatch { + expected: old_root.to_string(), + actual: fn_hash.to_string(), + }); + } + } + + // Phase 2: extension elements build up to the new root + while pos < proof.len() { + fr_hash = hash_children(&fr_hash, &proof[pos]); + pos += 1; + } + + Ok(fr_hash) +} + +/// Compute the Merkle root of a list of leaf hashes per RFC 6962 Section 2.1. +/// +/// Recursively splits at the largest power of 2 less than `n`: +/// `MTH(D[0:n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))` where `k = 2^(floor(log2(n-1)))`. +/// +/// Args: +/// * `leaves` — Slice of leaf hashes. Empty input returns `MerkleHash::EMPTY`. +/// +/// Usage: +/// ```ignore +/// let root = compute_root(&leaf_hashes); +/// ``` +pub fn compute_root(leaves: &[MerkleHash]) -> MerkleHash { + match leaves.len() { + 0 => MerkleHash::EMPTY, + 1 => leaves[0], + n => { + let k = largest_power_of_2_lt(n as u64) as usize; + let left = compute_root(&leaves[..k]); + let right = compute_root(&leaves[k..]); + hash_children(&left, &right) + } + } +} + +/// Largest power of 2 strictly less than `n` (for n > 1). +fn largest_power_of_2_lt(n: u64) -> u64 { + debug_assert!(n > 1); + if n.is_power_of_two() { + n / 2 + } else { + 1u64 << (63 - n.leading_zeros()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_leaf_domain_separation() { + let data = b"test data"; + let h = hash_leaf(data); + + // Manually compute SHA-256(0x00 || "test data") + let mut hasher = Sha256::new(); + hasher.update([0x00]); + hasher.update(data); + let expected = hasher.finalize(); + + assert_eq!(h.as_bytes(), expected.as_slice()); + } + + #[test] + fn hash_children_domain_separation() { + let left = MerkleHash::from_bytes([0x11; 32]); + let right = MerkleHash::from_bytes([0x22; 32]); + let h = hash_children(&left, &right); + + let mut hasher = Sha256::new(); + hasher.update([0x01]); + hasher.update([0x11; 32]); + hasher.update([0x22; 32]); + let expected = hasher.finalize(); + + assert_eq!(h.as_bytes(), expected.as_slice()); + } + + #[test] + fn leaf_and_children_produce_different_hashes() { + let data = [0xab; 64]; + let leaf = hash_leaf(&data); + + let left = MerkleHash::from_bytes(data[..32].try_into().unwrap()); + let right = MerkleHash::from_bytes(data[32..].try_into().unwrap()); + let node = hash_children(&left, &right); + + assert_ne!(leaf, node); + } + + #[test] + fn compute_root_single_leaf() { + let h = MerkleHash::from_bytes([0x42; 32]); + assert_eq!(compute_root(&[h]), h); + } + + #[test] + fn compute_root_empty() { + assert_eq!(compute_root(&[]), MerkleHash::EMPTY); + } + + #[test] + fn compute_root_two_leaves() { + let a = hash_leaf(b"a"); + let b = hash_leaf(b"b"); + let root = compute_root(&[a, b]); + assert_eq!(root, hash_children(&a, &b)); + } + + #[test] + fn inclusion_proof_single_leaf() { + let leaf = hash_leaf(b"only leaf"); + let root = leaf; + verify_inclusion(&leaf, 0, 1, &[], &root).unwrap(); + } + + #[test] + fn inclusion_proof_two_leaves() { + let a = hash_leaf(b"a"); + let b = hash_leaf(b"b"); + let root = hash_children(&a, &b); + + // Prove leaf 0 (a) with sibling b + verify_inclusion(&a, 0, 2, &[b], &root).unwrap(); + // Prove leaf 1 (b) with sibling a + verify_inclusion(&b, 1, 2, &[a], &root).unwrap(); + } + + #[test] + fn inclusion_proof_four_leaves() { + let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); + let root = compute_root(&leaves); + + // Prove leaf 0: needs leaf 1 as sibling, then hash(leaf2, leaf3) as uncle + let ab = hash_children(&leaves[0], &leaves[1]); + let cd = hash_children(&leaves[2], &leaves[3]); + let _ = hash_children(&ab, &cd); + + verify_inclusion(&leaves[0], 0, 4, &[leaves[1], cd], &root).unwrap(); + verify_inclusion(&leaves[1], 1, 4, &[leaves[0], cd], &root).unwrap(); + verify_inclusion(&leaves[2], 2, 4, &[leaves[3], ab], &root).unwrap(); + verify_inclusion(&leaves[3], 3, 4, &[leaves[2], ab], &root).unwrap(); + } + + #[test] + fn inclusion_proof_rejects_wrong_root() { + let a = hash_leaf(b"a"); + let b = hash_leaf(b"b"); + let _root = hash_children(&a, &b); + let wrong = MerkleHash::from_bytes([0xff; 32]); + + let err = verify_inclusion(&a, 0, 2, &[b], &wrong); + assert!(err.is_err()); + } + + #[test] + fn inclusion_proof_three_leaves() { + let leaves: Vec = (0..3u8).map(|i| hash_leaf(&[i])).collect(); + let root = compute_root(&leaves); + + let ab = hash_children(&leaves[0], &leaves[1]); + + // Leaf 0: sibling = leaf[1], then uncle = leaf[2] + verify_inclusion(&leaves[0], 0, 3, &[leaves[1], leaves[2]], &root).unwrap(); + // Leaf 1: sibling = leaf[0], then uncle = leaf[2] + verify_inclusion(&leaves[1], 1, 3, &[leaves[0], leaves[2]], &root).unwrap(); + // Leaf 2: sibling = ab (promoted, no right sibling at level 0) + verify_inclusion(&leaves[2], 2, 3, &[ab], &root).unwrap(); + } + + #[test] + fn inclusion_proof_five_leaves() { + let leaves: Vec = (0..5u8).map(|i| hash_leaf(&[i])).collect(); + let root = compute_root(&leaves); + + let h01 = hash_children(&leaves[0], &leaves[1]); + let h23 = hash_children(&leaves[2], &leaves[3]); + let h0123 = hash_children(&h01, &h23); + + // Leaf 4: it's the last leaf (unpaired), needs h0123 as sibling + verify_inclusion(&leaves[4], 4, 5, &[h0123], &root).unwrap(); + // Leaf 0: sibling leaf[1], uncle h23, then uncle leaf[4] + verify_inclusion(&leaves[0], 0, 5, &[leaves[1], h23, leaves[4]], &root).unwrap(); + } + + #[test] + fn inclusion_proof_seven_leaves() { + let leaves: Vec = (0..7u8).map(|i| hash_leaf(&[i])).collect(); + let root = compute_root(&leaves); + + let h01 = hash_children(&leaves[0], &leaves[1]); + let h23 = hash_children(&leaves[2], &leaves[3]); + let h45 = hash_children(&leaves[4], &leaves[5]); + let h0123 = hash_children(&h01, &h23); + let h456 = hash_children(&h45, &leaves[6]); + + // Leaf 6: unpaired at level 0, sibling is h45, then uncle is h0123 + verify_inclusion(&leaves[6], 6, 7, &[h45, h0123], &root).unwrap(); + // Leaf 0: sibling leaf[1], uncle h23, then uncle h456 + verify_inclusion(&leaves[0], 0, 7, &[leaves[1], h23, h456], &root).unwrap(); + } + + #[test] + fn inclusion_proof_rejects_index_out_of_range() { + let a = hash_leaf(b"a"); + let root = a; + let err = verify_inclusion(&a, 1, 1, &[], &root); + assert!(err.is_err()); + } + + #[test] + fn consistency_proof_same_size() { + let root = MerkleHash::from_bytes([0x42; 32]); + verify_consistency(5, 5, &[], &root, &root).unwrap(); + } + + #[test] + fn consistency_proof_empty_old() { + let new_root = MerkleHash::from_bytes([0x42; 32]); + let old_root = MerkleHash::EMPTY; + verify_consistency(0, 5, &[], &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_2_to_4() { + let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..2]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..2], &leaves); + verify_consistency(2, 4, &proof, &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_3_to_5() { + let leaves: Vec = (0..5u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..3]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..3], &leaves); + verify_consistency(3, 5, &proof, &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_4_to_8() { + let leaves: Vec = (0..8u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..4]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..4], &leaves); + verify_consistency(4, 8, &proof, &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_7_to_15() { + let leaves: Vec = (0..15u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..7]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..7], &leaves); + verify_consistency(7, 15, &proof, &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_1_to_4() { + let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..1]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..1], &leaves); + verify_consistency(1, 4, &proof, &old_root, &new_root).unwrap(); + } + + #[test] + fn consistency_proof_rejects_wrong_old_root() { + let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); + let wrong_old = MerkleHash::from_bytes([0xff; 32]); + let new_root = compute_root(&leaves); + let proof = build_consistency_proof(&leaves[..3], &leaves); + assert!(verify_consistency(3, 4, &proof, &wrong_old, &new_root).is_err()); + } + + #[test] + fn consistency_proof_rejects_wrong_new_root() { + let leaves: Vec = (0..4u8).map(|i| hash_leaf(&[i])).collect(); + let old_root = compute_root(&leaves[..3]); + let wrong_new = MerkleHash::from_bytes([0xff; 32]); + let proof = build_consistency_proof(&leaves[..3], &leaves); + assert!(verify_consistency(3, 4, &proof, &old_root, &wrong_new).is_err()); + } + + /// Build a consistency proof using RFC 6962 SUBPROOF decomposition. Test-only. + fn build_consistency_proof( + old_leaves: &[MerkleHash], + new_leaves: &[MerkleHash], + ) -> Vec { + assert!(old_leaves.len() <= new_leaves.len()); + subproof(old_leaves.len() as u64, new_leaves, true) + } + + /// RFC 6962 Section 2.1.2 SUBPROOF(m, D[0:n], b). + fn subproof(m: u64, leaves: &[MerkleHash], b: bool) -> Vec { + let n = leaves.len() as u64; + if m == n { + if b { + return vec![]; + } + return vec![compute_root(leaves)]; + } + let k = largest_power_of_2_lt(n) as usize; + if m <= k as u64 { + let mut proof = subproof(m, &leaves[..k], b); + proof.push(compute_root(&leaves[k..])); + proof + } else { + let mut proof = subproof(m - k as u64, &leaves[k..], false); + proof.push(compute_root(&leaves[..k])); + proof + } + } + + #[test] + fn largest_pow2_lt() { + assert_eq!(largest_power_of_2_lt(2), 1); + assert_eq!(largest_power_of_2_lt(3), 2); + assert_eq!(largest_power_of_2_lt(4), 2); + assert_eq!(largest_power_of_2_lt(5), 4); + assert_eq!(largest_power_of_2_lt(8), 4); + assert_eq!(largest_power_of_2_lt(9), 8); + } +} diff --git a/crates/auths-verifier/src/tlog/mod.rs b/crates/auths-verifier/src/tlog/mod.rs new file mode 100644 index 00000000..83c0618c --- /dev/null +++ b/crates/auths-verifier/src/tlog/mod.rs @@ -0,0 +1,29 @@ +//! Transparency-log verification primitives — the pure, network-free core any +//! verifier needs to check Merkle proofs against a signed checkpoint. +//! +//! These types are the wire contract between a transparency log +//! (`auths-transparency`, which builds trees, tiles, and proofs server-side) +//! and every verifier surface (native, FFI, browser WASM) that must check +//! that evidence offline. They live here — in the leaf verification crate all +//! surfaces share — so a browser can verify a log proof without linking the +//! log's storage or networking, and so there is exactly one implementation of +//! the RFC 6962 proof math. `auths-transparency` re-exports everything in +//! this module; downstream code keeps importing from either crate without a +//! second copy existing anywhere. + +/// Log checkpoints (signed tree heads) and witness cosignatures. +pub mod checkpoint; +/// The transparency-log error contract. +pub mod error; +/// RFC 6962 Merkle tree hashing and proof verification. +pub mod merkle; +/// Inclusion and consistency proofs. +pub mod proof; +/// Identifier and hash newtypes. +pub mod types; + +pub use checkpoint::{Checkpoint, SignedCheckpoint, WitnessCosignature}; +pub use error::TransparencyError; +pub use merkle::{compute_root, hash_children, hash_leaf, verify_consistency, verify_inclusion}; +pub use proof::{ConsistencyProof, InclusionProof}; +pub use types::{LogOrigin, MerkleHash}; diff --git a/crates/auths-verifier/src/tlog/proof.rs b/crates/auths-verifier/src/tlog/proof.rs new file mode 100644 index 00000000..767059bd --- /dev/null +++ b/crates/auths-verifier/src/tlog/proof.rs @@ -0,0 +1,128 @@ +//! Merkle inclusion and consistency proofs. + +use serde::{Deserialize, Serialize}; + +use super::error::TransparencyError; +use super::types::MerkleHash; + +/// Merkle inclusion proof for a single entry in the log. +/// +/// Proves that a leaf at `index` is included in the tree of `size` leaves +/// with the given `root`. +/// +/// Args: +/// * `index` — Zero-based leaf index. +/// * `size` — Tree size (number of leaves) when the proof was generated. +/// * `root` — Merkle root at tree size `size`. +/// * `hashes` — Sibling hashes from leaf to root. +/// +/// Usage: +/// ```ignore +/// let proof = InclusionProof { index: 5, size: 16, root, hashes: vec![...] }; +/// proof.verify(&leaf_hash)?; +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct InclusionProof { + pub index: u64, + pub size: u64, + pub root: MerkleHash, + pub hashes: Vec, +} + +impl InclusionProof { + /// Verify that `leaf_hash` is included in the tree. + pub fn verify(&self, leaf_hash: &MerkleHash) -> Result<(), TransparencyError> { + super::merkle::verify_inclusion(leaf_hash, self.index, self.size, &self.hashes, &self.root) + } +} + +/// Merkle consistency proof between two tree sizes. +/// +/// Proves that the tree at `old_size` is a prefix of the tree at `new_size`. +/// +/// Args: +/// * `old_size` — Earlier tree size. +/// * `new_size` — Later tree size. +/// * `old_root` — Root at `old_size`. +/// * `new_root` — Root at `new_size`. +/// * `hashes` — Consistency proof hashes. +/// +/// Usage: +/// ```ignore +/// let proof = ConsistencyProof { old_size: 8, new_size: 16, .. }; +/// proof.verify()?; +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow(missing_docs)] +pub struct ConsistencyProof { + pub old_size: u64, + pub new_size: u64, + pub old_root: MerkleHash, + pub new_root: MerkleHash, + pub hashes: Vec, +} + +impl ConsistencyProof { + /// Verify that the old tree is a prefix of the new tree. + pub fn verify(&self) -> Result<(), TransparencyError> { + super::merkle::verify_consistency( + self.old_size, + self.new_size, + &self.hashes, + &self.old_root, + &self.new_root, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tlog::merkle::{hash_children, hash_leaf}; + + #[test] + fn inclusion_proof_verify() { + let a = hash_leaf(b"a"); + let b = hash_leaf(b"b"); + let root = hash_children(&a, &b); + + let proof = InclusionProof { + index: 0, + size: 2, + root, + hashes: vec![b], + }; + proof.verify(&a).unwrap(); + } + + #[test] + fn inclusion_proof_json_roundtrip() { + let proof = InclusionProof { + index: 3, + size: 8, + root: MerkleHash::from_bytes([0xaa; 32]), + hashes: vec![ + MerkleHash::from_bytes([0xbb; 32]), + MerkleHash::from_bytes([0xcc; 32]), + ], + }; + let json = serde_json::to_string(&proof).unwrap(); + let back: InclusionProof = serde_json::from_str(&json).unwrap(); + assert_eq!(proof, back); + } + + #[test] + fn consistency_proof_json_roundtrip() { + let proof = ConsistencyProof { + old_size: 4, + new_size: 8, + old_root: MerkleHash::from_bytes([0x11; 32]), + new_root: MerkleHash::from_bytes([0x22; 32]), + hashes: vec![MerkleHash::from_bytes([0x33; 32])], + }; + let json = serde_json::to_string(&proof).unwrap(); + let back: ConsistencyProof = serde_json::from_str(&json).unwrap(); + assert_eq!(proof, back); + } +} diff --git a/crates/auths-verifier/src/tlog/types.rs b/crates/auths-verifier/src/tlog/types.rs new file mode 100644 index 00000000..b0ba1aa0 --- /dev/null +++ b/crates/auths-verifier/src/tlog/types.rs @@ -0,0 +1,213 @@ +//! Identifier and hash newtypes for the transparency-log wire contract. + +use std::fmt; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; + +use super::error::TransparencyError; + +/// SHA-256 Merkle hash (32 bytes). +/// +/// Args: +/// * Inner `[u8; 32]` — raw SHA-256 digest. +/// +/// Usage: +/// ```ignore +/// let hash = MerkleHash::from_bytes([0u8; 32]); +/// let hex_str = hash.to_string(); // lowercase hex +/// ``` +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MerkleHash([u8; 32]); + +impl MerkleHash { + /// The all-zero hash, used as a sentinel for empty trees. + pub const EMPTY: Self = Self([0u8; 32]); + + /// Wrap raw bytes. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Construct from a hex string. + pub fn from_hex(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|e| TransparencyError::InvalidProof(e.to_string()))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| TransparencyError::InvalidProof("hash must be 32 bytes".into()))?; + Ok(Self(arr)) + } + + /// Raw bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Encode as standard base64 (with padding). Used in C2SP checkpoint note body. + pub fn to_base64(&self) -> String { + STANDARD.encode(self.0) + } + + /// Decode from standard base64 (with padding). + pub fn from_base64(s: &str) -> Result { + let bytes = STANDARD + .decode(s) + .map_err(|e| TransparencyError::InvalidProof(format!("base64 decode: {e}")))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| TransparencyError::InvalidProof("hash must be 32 bytes".into()))?; + Ok(Self(arr)) + } + + /// Plain SHA-256 (no domain separation). Used for key-ID computation. + pub fn sha256(data: &[u8]) -> Self { + let digest = Sha256::digest(data); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + Self(out) + } +} + +impl fmt::Debug for MerkleHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "MerkleHash({})", self) + } +} + +impl fmt::Display for MerkleHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for b in &self.0 { + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + +impl Serialize for MerkleHash { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for MerkleHash { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_hex(&s).map_err(serde::de::Error::custom) + } +} + +impl AsRef<[u8]> for MerkleHash { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// Validated log origin string (e.g., `"auths.dev/log"`). +/// +/// Must be non-empty ASCII with no control characters. +/// +/// Args: +/// * Inner `String` — validated ASCII origin. +/// +/// Usage: +/// ```ignore +/// let origin = LogOrigin::new("auths.dev/log")?; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct LogOrigin(String); + +impl LogOrigin { + /// Create a new log origin, validating that it is non-empty ASCII. + pub fn new(s: &str) -> Result { + if s.is_empty() { + return Err(TransparencyError::InvalidOrigin("must not be empty".into())); + } + if !s.is_ascii() { + return Err(TransparencyError::InvalidOrigin("must be ASCII".into())); + } + if s.bytes().any(|b| b < 0x20) { + return Err(TransparencyError::InvalidOrigin( + "must not contain control characters".into(), + )); + } + Ok(Self(s.to_string())) + } + + /// Create from a compile-time constant. Panics if invalid. + /// + /// Only for use in `default_config()` and similar contexts where the + /// string is a known-good constant. + #[allow(clippy::expect_used)] // INVARIANT: only called with compile-time ASCII constants + pub fn new_unchecked(s: &str) -> Self { + Self::new(s).expect("LogOrigin::new_unchecked called with invalid origin") + } + + /// The inner string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for LogOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl TryFrom for LogOrigin { + type Error = TransparencyError; + fn try_from(s: String) -> Result { + Self::new(&s) + } +} + +impl From for String { + fn from(o: LogOrigin) -> Self { + o.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merkle_hash_hex_roundtrip() { + let bytes = [0xabu8; 32]; + let h = MerkleHash::from_bytes(bytes); + let hex_str = h.to_string(); + let h2 = MerkleHash::from_hex(&hex_str).unwrap(); + assert_eq!(h, h2); + } + + #[test] + fn merkle_hash_json_roundtrip() { + let h = MerkleHash::from_bytes([0x42u8; 32]); + let json = serde_json::to_string(&h).unwrap(); + let h2: MerkleHash = serde_json::from_str(&json).unwrap(); + assert_eq!(h, h2); + } + + #[test] + fn log_origin_rejects_empty() { + assert!(LogOrigin::new("").is_err()); + } + + #[test] + fn log_origin_rejects_non_ascii() { + assert!(LogOrigin::new("日本語").is_err()); + } + + #[test] + fn log_origin_rejects_control_chars() { + assert!(LogOrigin::new("auths\x00log").is_err()); + } + + #[test] + fn log_origin_valid() { + let o = LogOrigin::new("auths.dev/log").unwrap(); + assert_eq!(o.as_str(), "auths.dev/log"); + } +} diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index cf74dc85..27a80cb7 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -519,3 +519,52 @@ pub fn wasm_verify_presentation_json(bundle_json: &str) -> String { pub fn wasm_verify_credential_json(bundle_json: &str) -> String { crate::contract::verify_credential_json(bundle_json) } + +/// Verify an **air-gapped org bundle** offline, returning the tagged verdict +/// envelope (`kind`: `"report"` | `"error"`) as a JSON string. +/// +/// Synchronous, executor-free, and panic-free: the verify core +/// ([`crate::org_bundle::verify_org_bundle`]) is a pure function of the +/// bundle's bytes — every event's SAID recomputed, every signature +/// authenticated against the controlling key-state (RT-002), duplicity +/// flagged, and authority classified by KEL position — so the browser +/// computes the same verdict the native CLI does, with zero network. +/// +/// Args: +/// * `bundle_json`: The `AirGappedOrgBundle` JSON (the `.auths-offline` file). +/// * `pinned_roots_json`: JSON array of pinned `did:keri:` roots. +/// * `member_did`: Optional member to classify (`did:keri:` or bare prefix). +/// * `signed_at`: Optional in-band signing KEL position, as a decimal string. +#[wasm_bindgen(js_name = verifyOrgBundle)] +pub fn wasm_verify_org_bundle( + bundle_json: &str, + pinned_roots_json: &str, + member_did: Option, + signed_at: Option, +) -> String { + crate::org_bundle::verify_org_bundle_json( + bundle_json, + pinned_roots_json, + member_did.as_deref(), + signed_at.as_deref(), + ) +} + +/// Verify an **offline compliance evidence pack** with zero network, returning +/// the tagged verdict envelope (`kind`: `"verdicts"` | `"error"`) as a JSON +/// string — one verdict per evidence row. +/// +/// Synchronous, executor-free, and panic-free: the verify core +/// ([`crate::evidence_pack::verify_evidence_pack_offline`]) authenticates the +/// embedded org bundle, re-derives each row's authority-at-release from the +/// embedded KEL (tamper check), and checks each row's transparency-log +/// inclusion/consistency proof — so the dashboard computes the verdict live +/// instead of replaying a recorded native run. +/// +/// Args: +/// * `pack_json`: The `EvidencePack` JSON (the `.evidence` file). +/// * `pinned_roots_json`: JSON array of pinned `did:keri:` roots. +#[wasm_bindgen(js_name = verifyEvidencePackOffline)] +pub fn wasm_verify_evidence_pack_offline(pack_json: &str, pinned_roots_json: &str) -> String { + crate::evidence_pack::verify_evidence_pack_offline_json(pack_json, pinned_roots_json) +} diff --git a/docs/errors/AUTHS-E2201.md b/docs/errors/AUTHS-E2201.md new file mode 100644 index 00000000..060a5239 --- /dev/null +++ b/docs/errors/AUTHS-E2201.md @@ -0,0 +1,9 @@ +# AUTHS-E2201 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::Integrity` + +## Message + +bundle integrity failure for '{id}': {reason} diff --git a/docs/errors/AUTHS-E2202.md b/docs/errors/AUTHS-E2202.md new file mode 100644 index 00000000..d31c75ed --- /dev/null +++ b/docs/errors/AUTHS-E2202.md @@ -0,0 +1,9 @@ +# AUTHS-E2202 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::MissingMemberKel` + +## Message + +bundle is missing the KEL for delegated member '{member}' diff --git a/docs/errors/AUTHS-E2203.md b/docs/errors/AUTHS-E2203.md new file mode 100644 index 00000000..60151c60 --- /dev/null +++ b/docs/errors/AUTHS-E2203.md @@ -0,0 +1,9 @@ +# AUTHS-E2203 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::MissingDelegatorSeal` + +## Message + +member '{member}' has no delegation seal in the org KEL diff --git a/docs/errors/AUTHS-E2204.md b/docs/errors/AUTHS-E2204.md new file mode 100644 index 00000000..2c5c6985 --- /dev/null +++ b/docs/errors/AUTHS-E2204.md @@ -0,0 +1,9 @@ +# AUTHS-E2204 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::Canonicalize` + +## Message + +canonicalization failed: {0} diff --git a/docs/errors/AUTHS-E2205.md b/docs/errors/AUTHS-E2205.md new file mode 100644 index 00000000..507a05e7 --- /dev/null +++ b/docs/errors/AUTHS-E2205.md @@ -0,0 +1,9 @@ +# AUTHS-E2205 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::Parse` + +## Message + +parse failed: {0} diff --git a/docs/errors/AUTHS-E2206.md b/docs/errors/AUTHS-E2206.md new file mode 100644 index 00000000..b71bed76 --- /dev/null +++ b/docs/errors/AUTHS-E2206.md @@ -0,0 +1,9 @@ +# AUTHS-E2206 + +**Crate:** `auths-verifier` + +**Type:** `OrgBundleError::RecordInvalid` + +## Message + +offboarding record invalid: {0} diff --git a/docs/errors/AUTHS-E2301.md b/docs/errors/AUTHS-E2301.md new file mode 100644 index 00000000..fbd21ced --- /dev/null +++ b/docs/errors/AUTHS-E2301.md @@ -0,0 +1,9 @@ +# AUTHS-E2301 + +**Crate:** `auths-verifier` + +**Type:** `EvidencePackError::Canonicalize` + +## Message + +canonicalization failed: {0} diff --git a/docs/errors/AUTHS-E2302.md b/docs/errors/AUTHS-E2302.md new file mode 100644 index 00000000..84f0cd85 --- /dev/null +++ b/docs/errors/AUTHS-E2302.md @@ -0,0 +1,9 @@ +# AUTHS-E2302 + +**Crate:** `auths-verifier` + +**Type:** `EvidencePackError::Decode` + +## Message + +decode failed: {0} diff --git a/docs/errors/AUTHS-E2303.md b/docs/errors/AUTHS-E2303.md new file mode 100644 index 00000000..46c1a273 --- /dev/null +++ b/docs/errors/AUTHS-E2303.md @@ -0,0 +1,9 @@ +# AUTHS-E2303 + +**Crate:** `auths-verifier` + +**Type:** `EvidencePackError::OfflineVerification` + +## Message + +offline verification failed: {0} diff --git a/docs/errors/AUTHS-E5619.md b/docs/errors/AUTHS-E5619.md deleted file mode 100644 index ee5a8d56..00000000 --- a/docs/errors/AUTHS-E5619.md +++ /dev/null @@ -1,9 +0,0 @@ -# AUTHS-E5619 - -**Crate:** `auths-sdk` - -**Type:** `OrgError::BundleIntegrity` - -## Message - -bundle integrity failure for '{id}': {reason} diff --git a/docs/errors/AUTHS-E5620.md b/docs/errors/AUTHS-E5620.md deleted file mode 100644 index 672c645a..00000000 --- a/docs/errors/AUTHS-E5620.md +++ /dev/null @@ -1,9 +0,0 @@ -# AUTHS-E5620 - -**Crate:** `auths-sdk` - -**Type:** `OrgError::BundleMissingMemberKel` - -## Message - -bundle is missing the KEL for delegated member '{member}' diff --git a/docs/errors/AUTHS-E5621.md b/docs/errors/AUTHS-E5621.md deleted file mode 100644 index 67cd0454..00000000 --- a/docs/errors/AUTHS-E5621.md +++ /dev/null @@ -1,9 +0,0 @@ -# AUTHS-E5621 - -**Crate:** `auths-sdk` - -**Type:** `OrgError::BundleMissingDelegatorSeal` - -## Message - -member '{member}' has no delegation seal in the org KEL diff --git a/docs/errors/index.md b/docs/errors/index.md index 84d9ca6b..5e62d706 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -51,6 +51,15 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E2107](AUTHS-E2107.md) | `auths-verifier` | `CommitVerificationError::SignatureInvalid` | signature verification failed | | [AUTHS-E2108](AUTHS-E2108.md) | `auths-verifier` | `CommitVerificationError::UnknownSigner` | signer key not in allowed keys | | [AUTHS-E2109](AUTHS-E2109.md) | `auths-verifier` | `CommitVerificationError::CommitParseFailed` | commit parse failed: {0} | +| [AUTHS-E2201](AUTHS-E2201.md) | `auths-verifier` | `OrgBundleError::Integrity` | bundle integrity failure for '{id}': {reason} | +| [AUTHS-E2202](AUTHS-E2202.md) | `auths-verifier` | `OrgBundleError::MissingMemberKel` | bundle is missing the KEL for delegated member '{member}' | +| [AUTHS-E2203](AUTHS-E2203.md) | `auths-verifier` | `OrgBundleError::MissingDelegatorSeal` | member '{member}' has no delegation seal in the org KEL | +| [AUTHS-E2204](AUTHS-E2204.md) | `auths-verifier` | `OrgBundleError::Canonicalize` | canonicalization failed: {0} | +| [AUTHS-E2205](AUTHS-E2205.md) | `auths-verifier` | `OrgBundleError::Parse` | parse failed: {0} | +| [AUTHS-E2206](AUTHS-E2206.md) | `auths-verifier` | `OrgBundleError::RecordInvalid` | offboarding record invalid: {0} | +| [AUTHS-E2301](AUTHS-E2301.md) | `auths-verifier` | `EvidencePackError::Canonicalize` | canonicalization failed: {0} | +| [AUTHS-E2302](AUTHS-E2302.md) | `auths-verifier` | `EvidencePackError::Decode` | decode failed: {0} | +| [AUTHS-E2303](AUTHS-E2303.md) | `auths-verifier` | `EvidencePackError::OfflineVerification` | offline verification failed: {0} | | [AUTHS-E3001](AUTHS-E3001.md) | `auths-core` | `AgentError::KeyNotFound` | Key not found | | [AUTHS-E3002](AUTHS-E3002.md) | `auths-core` | `AgentError::IncorrectPassphrase` | Incorrect passphrase | | [AUTHS-E3003](AUTHS-E3003.md) | `auths-core` | `AgentError::MissingPassphrase` | Missing Passphrase | @@ -306,9 +315,6 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E5616](AUTHS-E5616.md) | `auths-sdk` | `OrgError::IdentityInit` | failed to initialize organization identity: {0} | | [AUTHS-E5617](AUTHS-E5617.md) | `auths-sdk` | `OrgError::Attestation` | failed to create admin attestation: {0} | | [AUTHS-E5618](AUTHS-E5618.md) | `auths-sdk` | `OrgError::MemberNotDelegable` | member '{did}' is not a delegated identifier of organization '{org}' | -| [AUTHS-E5619](AUTHS-E5619.md) | `auths-sdk` | `OrgError::BundleIntegrity` | bundle integrity failure for '{id}': {reason} | -| [AUTHS-E5620](AUTHS-E5620.md) | `auths-sdk` | `OrgError::BundleMissingMemberKel` | bundle is missing the KEL for delegated member '{member}' | -| [AUTHS-E5621](AUTHS-E5621.md) | `auths-sdk` | `OrgError::BundleMissingDelegatorSeal` | member '{member}' has no delegation seal in the org KEL | | [AUTHS-E5622](AUTHS-E5622.md) | `auths-sdk` | `OrgError::PolicyCompile` | invalid org policy: {reason} | | [AUTHS-E5623](AUTHS-E5623.md) | `auths-sdk` | `OrgError::PolicyBlobMissing` | org policy blob for hash '{hash}' is missing from storage | | [AUTHS-E5624](AUTHS-E5624.md) | `auths-sdk` | `OrgError::PolicyIntegrity` | org policy integrity failure: KEL committed hash '{expected}' but the stored blob hashes to '{actual}' | diff --git a/docs/errors/registry.lock b/docs/errors/registry.lock index 93ad87d1..f138fbe7 100644 --- a/docs/errors/registry.lock +++ b/docs/errors/registry.lock @@ -55,6 +55,15 @@ AUTHS-E2106 = auths-verifier::CommitVerificationError::HashAlgorithmUnsupported AUTHS-E2107 = auths-verifier::CommitVerificationError::SignatureInvalid AUTHS-E2108 = auths-verifier::CommitVerificationError::UnknownSigner AUTHS-E2109 = auths-verifier::CommitVerificationError::CommitParseFailed +AUTHS-E2201 = auths-verifier::OrgBundleError::Integrity +AUTHS-E2202 = auths-verifier::OrgBundleError::MissingMemberKel +AUTHS-E2203 = auths-verifier::OrgBundleError::MissingDelegatorSeal +AUTHS-E2204 = auths-verifier::OrgBundleError::Canonicalize +AUTHS-E2205 = auths-verifier::OrgBundleError::Parse +AUTHS-E2206 = auths-verifier::OrgBundleError::RecordInvalid +AUTHS-E2301 = auths-verifier::EvidencePackError::Canonicalize +AUTHS-E2302 = auths-verifier::EvidencePackError::Decode +AUTHS-E2303 = auths-verifier::EvidencePackError::OfflineVerification AUTHS-E3001 = auths-core::AgentError::KeyNotFound AUTHS-E3002 = auths-core::AgentError::IncorrectPassphrase AUTHS-E3003 = auths-core::AgentError::MissingPassphrase @@ -310,9 +319,6 @@ AUTHS-E5615 = auths-sdk::OrgError::IdentityExists AUTHS-E5616 = auths-sdk::OrgError::IdentityInit AUTHS-E5617 = auths-sdk::OrgError::Attestation AUTHS-E5618 = auths-sdk::OrgError::MemberNotDelegable -AUTHS-E5619 = auths-sdk::OrgError::BundleIntegrity -AUTHS-E5620 = auths-sdk::OrgError::BundleMissingMemberKel -AUTHS-E5621 = auths-sdk::OrgError::BundleMissingDelegatorSeal AUTHS-E5622 = auths-sdk::OrgError::PolicyCompile AUTHS-E5623 = auths-sdk::OrgError::PolicyBlobMissing AUTHS-E5624 = auths-sdk::OrgError::PolicyIntegrity From a65c40d39ee250ed225826af6eac2ef096e78fd8 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 16:33:16 +0100 Subject: [PATCH 09/32] =?UTF-8?q?feat(transparency):=20local=20log=20write?= =?UTF-8?q?r=20=E2=80=94=20append/prove=20with=20leaf-bound=20row=20verifi?= =?UTF-8?q?cation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transparency log grows its write half: LogWriter appends leaf hashes into C2SP level-0 tiles through the TileStore port (filesystem or S3), signs a fresh checkpoint over the C2SP note body with an Ed25519 LogSigningKey, and mints inclusion evidence directly against that checkpoint. State is re-derived from tiles and re-verified against the stored root on every operation, so a corrupted store fails closed. - prove_inclusion (RFC 6962 PATH) joins compute_root in the one shared Merkle implementation in auths_verifier::tlog; generation and verification can never drift. - Evidence-pack row verification now BINDS each transparency proof to its row: leaf_hash must re-derive from the row's own artifact digest, so a valid proof minted for a different artifact no longer counts. - CLI: auths log append/prove operate a local log dir (signing key created on first append, 0600), emitting TransparencyInclusion JSON ready to embed in release rows. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/log.rs | 173 ++++++- .../auths-sdk/tests/cases/compliance_query.rs | 83 ++++ crates/auths-transparency/src/lib.rs | 10 +- crates/auths-transparency/src/merkle.rs | 2 +- crates/auths-transparency/src/writer.rs | 463 ++++++++++++++++++ crates/auths-verifier/src/evidence_pack.rs | 25 +- crates/auths-verifier/src/tlog/error.rs | 4 + crates/auths-verifier/src/tlog/merkle.rs | 86 ++++ crates/auths-verifier/src/tlog/mod.rs | 4 +- 9 files changed, 837 insertions(+), 13 deletions(-) create mode 100644 crates/auths-transparency/src/writer.rs diff --git a/crates/auths-cli/src/commands/log.rs b/crates/auths-cli/src/commands/log.rs index 01a601fc..11dcbb2a 100644 --- a/crates/auths-cli/src/commands/log.rs +++ b/crates/auths-cli/src/commands/log.rs @@ -1,9 +1,13 @@ +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, bail}; use auths_infra_http::HttpRegistryClient; use auths_sdk::ports::RegistryClient; -use auths_transparency::SignedCheckpoint; +use auths_sdk::workflows::compliance::ArtifactDigest; +use auths_transparency::{ + FsTileStore, LogOrigin, LogSigningKey, LogWriter, SignedCheckpoint, hash_leaf, +}; use clap::{Args, Subcommand}; use serde::Serialize; @@ -11,8 +15,11 @@ use super::executable::ExecutableCommand; use crate::config::CliConfig; use crate::ux::format::{JsonResponse, is_json_mode}; +/// PKCS#8 signing-key file kept inside the local log directory. +const LOG_KEY_FILE: &str = "log.key"; + #[derive(Args, Debug, Clone)] -#[command(about = "Inspect and verify the transparency log")] +#[command(about = "Inspect, verify, and operate the transparency log")] pub struct LogCommand { #[command(subcommand)] pub command: LogSubcommand, @@ -24,6 +31,10 @@ pub enum LogSubcommand { Inspect(InspectArgs), /// Verify log consistency from the cached checkpoint Verify(VerifyArgs), + /// Append an artifact digest to a local tile-backed transparency log + Append(AppendArgs), + /// Emit offline inclusion evidence for an appended artifact digest + Prove(ProveArgs), } #[derive(Args, Debug, Clone)] @@ -43,6 +54,40 @@ pub struct VerifyArgs { pub registry: String, } +#[derive(Args, Debug, Clone)] +pub struct AppendArgs { + /// Artifact digest to log (sha256:<64 hex>) + #[clap(long)] + pub artifact: String, + + /// Directory holding the local log (tiles, checkpoint, signing key) + #[clap(long)] + pub log_dir: PathBuf, + + /// The log's origin line (its identity in every checkpoint) + #[clap(long, default_value = "auths.local/log")] + pub origin: String, +} + +#[derive(Args, Debug, Clone)] +pub struct ProveArgs { + /// Artifact digest to prove (sha256:<64 hex>) + #[clap(long)] + pub artifact: String, + + /// Directory holding the local log (tiles, checkpoint, signing key) + #[clap(long)] + pub log_dir: PathBuf, + + /// The log's origin line (must match the log's checkpoints) + #[clap(long, default_value = "auths.local/log")] + pub origin: String, + + /// Write the inclusion-evidence JSON to this file instead of stdout + #[clap(long)] + pub out: Option, +} + #[derive(Serialize)] struct VerifyResult { consistent: bool, @@ -52,6 +97,16 @@ struct VerifyResult { latest_root: String, } +#[derive(Serialize)] +struct AppendResult { + artifact_digest: String, + leaf_hash: String, + index: u64, + size: u64, + root: String, + origin: String, +} + impl ExecutableCommand for LogCommand { fn execute(&self, _ctx: &CliConfig) -> Result<()> { let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; @@ -59,6 +114,8 @@ impl ExecutableCommand for LogCommand { match &self.command { LogSubcommand::Inspect(args) => handle_inspect(args).await, LogSubcommand::Verify(args) => handle_verify(args).await, + LogSubcommand::Append(args) => handle_append(args).await, + LogSubcommand::Prove(args) => handle_prove(args).await, } }) } @@ -222,3 +279,115 @@ async fn handle_verify(args: &VerifyArgs) -> Result<()> { Ok(()) } + +/// Load the log signing key from `/log.key`, creating it on first +/// use when `create` is set (the append path); proving against a log that +/// does not exist yet is an error, not a key-generation event. +fn load_log_key(log_dir: &Path, create: bool) -> Result { + let path = log_dir.join(LOG_KEY_FILE); + match std::fs::read(&path) { + Ok(der) => LogSigningKey::from_pkcs8_der(&der) + .with_context(|| format!("Failed to parse log signing key at {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => { + let key = LogSigningKey::generate().context("Failed to generate log signing key")?; + let der = key + .to_pkcs8_der() + .context("Failed to encode log signing key")?; + std::fs::write(&path, der) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to restrict {}", path.display()))?; + } + Ok(key) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("No log at {} — append an artifact first", log_dir.display()) + } + Err(e) => { + Err(e).with_context(|| format!("Failed to read log signing key at {}", path.display())) + } + } +} + +fn local_log_writer(log_dir: &Path, origin: &str, create: bool) -> Result> { + let origin = LogOrigin::new(origin).map_err(|e| anyhow::anyhow!("invalid --origin: {e}"))?; + if create { + std::fs::create_dir_all(log_dir) + .with_context(|| format!("Failed to create {}", log_dir.display()))?; + } + let key = load_log_key(log_dir, create)?; + Ok(LogWriter::new( + FsTileStore::new(log_dir.to_path_buf()), + key, + origin, + )) +} + +#[allow(clippy::disallowed_methods)] // CLI is the presentation boundary (checkpoint timestamp) +async fn handle_append(args: &AppendArgs) -> Result<()> { + let digest = ArtifactDigest::parse(&args.artifact) + .map_err(|e| anyhow::anyhow!("invalid --artifact value: {e}"))?; + let writer = local_log_writer(&args.log_dir, &args.origin, true)?; + + let leaf_hash = hash_leaf(digest.as_str().as_bytes()); + let appended = writer.append(leaf_hash, chrono::Utc::now()).await?; + let checkpoint = &appended.signed_checkpoint.checkpoint; + + let result = AppendResult { + artifact_digest: digest.as_str().to_string(), + leaf_hash: hex::encode(leaf_hash.as_bytes()), + index: appended.index, + size: checkpoint.size, + root: hex::encode(checkpoint.root.as_bytes()), + origin: checkpoint.origin.to_string(), + }; + + if is_json_mode() { + JsonResponse::success("log append", result).print()?; + } else { + println!("Appended to transparency log"); + println!(" Artifact: {}", result.artifact_digest); + println!(" Leaf: {}", result.leaf_hash); + println!(" Index: {}", result.index); + println!(" Size: {}", result.size); + println!(" Root: {}", result.root); + println!(" Origin: {}", result.origin); + } + Ok(()) +} + +async fn handle_prove(args: &ProveArgs) -> Result<()> { + let digest = ArtifactDigest::parse(&args.artifact) + .map_err(|e| anyhow::anyhow!("invalid --artifact value: {e}"))?; + let writer = local_log_writer(&args.log_dir, &args.origin, false)?; + + let leaf_hash = hash_leaf(digest.as_str().as_bytes()); + let inclusion = writer.prove(&leaf_hash).await?; + + if let Some(out) = &args.out { + let json = serde_json::to_string_pretty(&inclusion) + .context("Failed to serialize inclusion evidence")?; + std::fs::write(out, json).with_context(|| format!("Failed to write {}", out.display()))?; + if is_json_mode() { + JsonResponse::success("log prove", &inclusion).print()?; + } else { + println!("Inclusion evidence written"); + println!(" Artifact: {}", digest.as_str()); + println!(" Index: {}", inclusion.inclusion_proof.index); + println!(" Size: {}", inclusion.inclusion_proof.size); + println!(" Out: {}", out.display()); + } + } else if is_json_mode() { + JsonResponse::success("log prove", &inclusion).print()?; + } else { + println!( + "{}", + serde_json::to_string_pretty(&inclusion) + .context("Failed to serialize inclusion evidence")? + ); + } + Ok(()) +} diff --git a/crates/auths-sdk/tests/cases/compliance_query.rs b/crates/auths-sdk/tests/cases/compliance_query.rs index 35467ada..d4c11b71 100644 --- a/crates/auths-sdk/tests/cases/compliance_query.rs +++ b/crates/auths-sdk/tests/cases/compliance_query.rs @@ -281,6 +281,89 @@ fn offline_verify_catches_a_tampered_row() { ); } +#[test] +fn offline_verify_binds_transparency_proofs_to_the_rows_artifact() { + use auths_sdk::domains::compliance::query::TransparencyInclusion; + use auths_verifier::tlog::{ + Checkpoint, InclusionProof, LogOrigin, MerkleHash, SignedCheckpoint, compute_root, + hash_leaf, prove_inclusion, + }; + use auths_verifier::{Ed25519PublicKey, Ed25519Signature}; + + let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); + let member = add_test_member(&ctx, &org_prefix, &org_alias, "judy"); + + // A two-leaf log: leaf 0 is THIS row's digest, leaf 1 is some other artifact. + let digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + let leaves = [hash_leaf(digest.as_bytes()), hash_leaf(b"sha256:other")]; + let root = compute_root(&leaves); + let signed_checkpoint = SignedCheckpoint { + checkpoint: Checkpoint { + origin: LogOrigin::new("test.example/log").unwrap(), + size: 2, + root, + timestamp: now(), + }, + log_signature: Ed25519Signature::from_bytes([0u8; 64]), + log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), + witnesses: vec![], + ecdsa_checkpoint_signature: None, + ecdsa_checkpoint_key: None, + }; + let inclusion_for = |index: u64, leaf: MerkleHash| TransparencyInclusion { + leaf_hash: leaf, + inclusion_proof: InclusionProof { + index, + size: 2, + root, + hashes: prove_inclusion(&leaves, index).unwrap(), + }, + signed_checkpoint: signed_checkpoint.clone(), + consistency_proof: None, + }; + + let releases = vec![ + ReleaseRecord { + artifact_digest: digest.into(), + signer_prefix: member.clone(), + signed_at: Some(0), + transparency: Some(inclusion_for(0, leaves[0])), + }, + // A perfectly valid Merkle proof — but over the OTHER leaf. A row must + // not borrow inclusion evidence that was minted for a different artifact. + ReleaseRecord { + artifact_digest: digest.into(), + signer_prefix: member, + signed_at: Some(0), + transparency: Some(inclusion_for(1, leaves[1])), + }, + ]; + let policy = load_witness_policy(None); + let pack = build_offline_evidence_pack( + &ctx, + org_did.clone(), + &org_prefix, + "2026-Q3", + ComplianceFramework::Slsa, + &releases, + &policy, + now(), + ) + .expect("build offline pack"); + + let verdicts = verify_evidence_pack_offline(&pack, &[org_did]).expect("offline verify"); + assert_eq!( + verdicts[0].transparency_verified, + Some(true), + "a proof over this row's own digest verifies" + ); + assert_eq!( + verdicts[1].transparency_verified, + Some(false), + "a valid proof over a different leaf must NOT count as this row's evidence" + ); +} + #[test] fn dsse_sign_and_verify_round_trips() { let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); diff --git a/crates/auths-transparency/src/lib.rs b/crates/auths-transparency/src/lib.rs index 0245ee54..29869e85 100644 --- a/crates/auths-transparency/src/lib.rs +++ b/crates/auths-transparency/src/lib.rs @@ -39,6 +39,10 @@ pub mod verify; pub mod witness; /// Typed, fail-closed witness-diversity policy loader. pub mod witness_policy; +/// The log write path: append leaves, sign checkpoints, mint inclusion +/// proofs (requires `native` feature). +#[cfg(feature = "native")] +pub mod writer; // Re-export core types pub use bundle::{ @@ -48,7 +52,9 @@ pub use bundle::{ pub use checkpoint::{Checkpoint, SignedCheckpoint, WitnessCosignature}; pub use entry::{AccessTier, Entry, EntryBody, EntryContent, EntryType}; pub use error::TransparencyError; -pub use merkle::{compute_root, hash_children, hash_leaf, verify_consistency, verify_inclusion}; +pub use merkle::{ + compute_root, hash_children, hash_leaf, prove_inclusion, verify_consistency, verify_inclusion, +}; pub use note::{ C2spSignature, NoteSignature, build_signature_line, compute_ecdsa_key_id, compute_key_id, parse_signed_note, parse_signed_note_c2sp, serialize_signed_note, @@ -72,6 +78,8 @@ pub use s3_store::S3TileStore; pub use store::TileStore; #[cfg(feature = "native")] pub use verify::{verify_bundle, verify_checkpoint_signature, verify_witness_cosignatures}; +#[cfg(feature = "native")] +pub use writer::{AppendedLeaf, LogSigningKey, LogWriter}; #[cfg(feature = "native")] pub use witness::{ diff --git a/crates/auths-transparency/src/merkle.rs b/crates/auths-transparency/src/merkle.rs index 0d5877ca..44f33b14 100644 --- a/crates/auths-transparency/src/merkle.rs +++ b/crates/auths-transparency/src/merkle.rs @@ -5,5 +5,5 @@ //! (native, FFI, browser WASM). This module re-exports it. pub use auths_verifier::tlog::merkle::{ - compute_root, hash_children, hash_leaf, verify_consistency, verify_inclusion, + compute_root, hash_children, hash_leaf, prove_inclusion, verify_consistency, verify_inclusion, }; diff --git a/crates/auths-transparency/src/writer.rs b/crates/auths-transparency/src/writer.rs new file mode 100644 index 00000000..c54151b5 --- /dev/null +++ b/crates/auths-transparency/src/writer.rs @@ -0,0 +1,463 @@ +//! The write half of the transparency log: append leaves, sign checkpoints, +//! and mint inclusion proofs a verifier can replay offline. +//! +//! [`LogWriter`] is storage-agnostic: it speaks the same [`TileStore`] port +//! the read path uses (filesystem via [`crate::FsTileStore`], S3 behind the +//! `s3` feature), persisting leaf hashes as C2SP level-0 tiles and the +//! current [`SignedCheckpoint`] (JSON) in the store's checkpoint slot. The +//! Merkle math is the shared RFC 6962 implementation in +//! `auths_verifier::tlog`, so every proof minted here is checked by the +//! exact code all verifier surfaces (native, FFI, browser WASM) run. +//! +//! Scale posture: the writer re-derives the full leaf list from tiles on +//! every call and re-verifies the stored root against it — fail-closed on a +//! corrupted store. That is O(n) per operation by design: this is the +//! local-first/operator log (release histories, org evidence), not the +//! hosted sequencer, which keeps the tree in memory. + +use auths_crypto::{CurveType, TypedSeed, TypedSignerKey}; +use auths_verifier::evidence_pack::TransparencyInclusion; +use auths_verifier::{Ed25519PublicKey, Ed25519Signature}; +use chrono::{DateTime, Utc}; + +use crate::checkpoint::{Checkpoint, SignedCheckpoint}; +use crate::error::TransparencyError; +use crate::merkle::{compute_root, prove_inclusion}; +use crate::proof::InclusionProof; +use crate::store::TileStore; +use crate::tile::{TILE_WIDTH, leaf_tile, tile_count, tile_path}; +use crate::types::{LogOrigin, MerkleHash}; + +/// The log operator's Ed25519 checkpoint-signing key, parsed and +/// curve-checked at construction so signing can never fail on curve drift. +/// The C2SP signed-note `log_signature` field is Ed25519-pinned by spec; +/// non-Ed25519 keys are rejected here, once, at the boundary. +pub struct LogSigningKey { + signer: TypedSignerKey, + public_key: Ed25519PublicKey, +} + +impl LogSigningKey { + /// Generate a fresh Ed25519 signing key from OS randomness. + /// + /// Usage: + /// ```ignore + /// let key = LogSigningKey::generate()?; + /// std::fs::write(key_path, key.to_pkcs8_der()?)?; + /// ``` + pub fn generate() -> Result { + use ring::rand::SecureRandom; + let rng = ring::rand::SystemRandom::new(); + let mut seed = [0u8; 32]; + rng.fill(&mut seed) + .map_err(|_| TransparencyError::SigningKey("OS randomness unavailable".into()))?; + let signer = TypedSignerKey::from_seed(TypedSeed::Ed25519(seed)) + .map_err(|e| TransparencyError::SigningKey(e.to_string()))?; + Self::from_signer(signer) + } + + /// Parse a signing key from PKCS#8 DER bytes. + /// + /// Args: + /// * `der` — PKCS#8 DER as previously produced by [`Self::to_pkcs8_der`]. + pub fn from_pkcs8_der(der: &[u8]) -> Result { + let signer = TypedSignerKey::from_pkcs8(der) + .map_err(|e| TransparencyError::SigningKey(e.to_string()))?; + Self::from_signer(signer) + } + + fn from_signer(signer: TypedSignerKey) -> Result { + if signer.curve() != CurveType::Ed25519 { + return Err(TransparencyError::SigningKey( + "checkpoint signing key must be Ed25519 (C2SP signed-note pins the curve)".into(), + )); + } + let public_key = Ed25519PublicKey::try_from_slice(signer.public_key()) + .map_err(|e| TransparencyError::SigningKey(e.to_string()))?; + Ok(Self { signer, public_key }) + } + + /// PKCS#8 DER bytes for persisting the key alongside the log. + pub fn to_pkcs8_der(&self) -> Result, TransparencyError> { + Ok(self + .signer + .to_pkcs8() + .map_err(|e| TransparencyError::SigningKey(e.to_string()))? + .as_ref() + .to_vec()) + } + + /// The log's public key — what verifiers pin as the log identity. + pub fn public_key(&self) -> Ed25519PublicKey { + self.public_key + } +} + +/// The outcome of appending one leaf: its assigned index and the new signed +/// checkpoint covering it. +#[derive(Debug, Clone)] +pub struct AppendedLeaf { + /// Zero-based index the leaf was sequenced at. + pub index: u64, + /// The checkpoint signed over the tree that now includes the leaf. + pub signed_checkpoint: SignedCheckpoint, +} + +/// Appends leaves to a tile-backed transparency log and mints offline +/// inclusion proofs against its signed checkpoint. +/// +/// Args: +/// * `store` — Any [`TileStore`] (e.g. [`crate::FsTileStore`]). +/// * `key` — The log operator's [`LogSigningKey`]. +/// * `origin` — The log's origin line; a store whose checkpoint carries a +/// different origin is rejected on every operation. +/// +/// Usage: +/// ```ignore +/// let writer = LogWriter::new(FsTileStore::new(dir), key, origin); +/// let leaf = hash_leaf(b"sha256:..."); +/// let appended = writer.append(leaf, now).await?; +/// let inclusion = writer.prove(&leaf).await?; +/// ``` +pub struct LogWriter { + store: S, + key: LogSigningKey, + origin: LogOrigin, +} + +impl LogWriter { + /// Create a writer over a tile store with the given signing key + origin. + pub fn new(store: S, key: LogSigningKey, origin: LogOrigin) -> Self { + Self { store, key, origin } + } + + /// Append one leaf hash: persist it to the level-0 tiles, recompute the + /// root, and sign a fresh checkpoint over the grown tree. + /// + /// Args: + /// * `leaf_hash` — The RFC 6962 leaf hash (see `hash_leaf`). + /// * `now` — Injected checkpoint timestamp (never read from a wall clock + /// here). + pub async fn append( + &self, + leaf_hash: MerkleHash, + now: DateTime, + ) -> Result { + let mut leaves = match self.read_state().await? { + Some((_, leaves)) => leaves, + None => Vec::new(), + }; + let index = leaves.len() as u64; + leaves.push(leaf_hash); + + // Persist exactly the tile the new leaf lands in. A tile reaching + // TILE_WIDTH is written at its full path (write-once); a growing + // tile at its partial path (overwritable). + let (tile_index, offset) = leaf_tile(index); + let width = offset + 1; + let start = usize::try_from(tile_index * TILE_WIDTH) + .map_err(|_| TransparencyError::StoreError("tile index out of range".into()))?; + let mut data = Vec::with_capacity((width as usize) * 32); + for leaf in &leaves[start..start + width as usize] { + data.extend_from_slice(leaf.as_bytes()); + } + let path = tile_path(0, tile_index, width % TILE_WIDTH)?; + self.store.write_tile(&path, &data).await?; + + let checkpoint = Checkpoint { + origin: self.origin.clone(), + size: leaves.len() as u64, + root: compute_root(&leaves), + timestamp: now, + }; + let signed_checkpoint = self.sign(checkpoint)?; + let bytes = serde_json::to_vec(&signed_checkpoint) + .map_err(|e| TransparencyError::StoreError(e.to_string()))?; + self.store.write_checkpoint(&bytes).await?; + + Ok(AppendedLeaf { + index, + signed_checkpoint, + }) + } + + /// Mint the offline inclusion evidence for a leaf already in the log: + /// an inclusion proof directly against the current signed checkpoint. + pub async fn prove( + &self, + leaf_hash: &MerkleHash, + ) -> Result { + let Some((signed_checkpoint, leaves)) = self.read_state().await? else { + return Err(TransparencyError::InvalidProof( + "the log is empty — nothing has been appended".into(), + )); + }; + let index = leaves + .iter() + .position(|leaf| leaf == leaf_hash) + .ok_or_else(|| { + TransparencyError::InvalidProof("leaf is not in the log — append it first".into()) + })? as u64; + + let inclusion_proof = InclusionProof { + index, + size: signed_checkpoint.checkpoint.size, + root: signed_checkpoint.checkpoint.root, + hashes: prove_inclusion(&leaves, index)?, + }; + // Never emit evidence we have not replayed through the verifier path. + inclusion_proof.verify(leaf_hash)?; + + Ok(TransparencyInclusion { + leaf_hash: *leaf_hash, + inclusion_proof, + signed_checkpoint, + consistency_proof: None, + }) + } + + /// Load and re-verify the persisted log state: `None` when no checkpoint + /// exists yet, otherwise the checkpoint plus every leaf, with the stored + /// root recomputed from the leaves — a store that disagrees with its own + /// checkpoint fails closed. + async fn read_state( + &self, + ) -> Result)>, TransparencyError> { + let Some(bytes) = self.store.read_checkpoint().await? else { + return Ok(None); + }; + let signed: SignedCheckpoint = serde_json::from_slice(&bytes) + .map_err(|e| TransparencyError::StoreError(format!("checkpoint parse: {e}")))?; + if signed.checkpoint.origin != self.origin { + return Err(TransparencyError::InvalidOrigin(format!( + "log belongs to origin '{}', not '{}'", + signed.checkpoint.origin, self.origin + ))); + } + let leaves = self.read_leaves(signed.checkpoint.size).await?; + let recomputed = compute_root(&leaves); + if recomputed != signed.checkpoint.root { + return Err(TransparencyError::RootMismatch { + expected: signed.checkpoint.root.to_string(), + actual: recomputed.to_string(), + }); + } + Ok(Some((signed, leaves))) + } + + /// Read every leaf hash for a tree of `size` from the level-0 tiles. + async fn read_leaves(&self, size: u64) -> Result, TransparencyError> { + let (full_tiles, partial_width) = tile_count(size); + let mut leaves = Vec::with_capacity(size as usize); + for tile_index in 0..full_tiles { + let path = tile_path(0, tile_index, 0)?; + let data = self.store.read_tile(&path).await?; + parse_leaf_tile(&data, TILE_WIDTH, &path, &mut leaves)?; + } + if partial_width > 0 { + let path = tile_path(0, full_tiles, partial_width)?; + let data = self.store.read_tile(&path).await?; + parse_leaf_tile(&data, partial_width, &path, &mut leaves)?; + } + Ok(leaves) + } + + fn sign(&self, checkpoint: Checkpoint) -> Result { + let body = checkpoint.to_note_body(); + let signature = self + .key + .signer + .sign(body.as_bytes()) + .map_err(|e| TransparencyError::SigningKey(e.to_string()))?; + let log_signature = Ed25519Signature::try_from_slice(&signature) + .map_err(|e| TransparencyError::SigningKey(e.to_string()))?; + Ok(SignedCheckpoint { + checkpoint, + log_signature, + log_public_key: self.key.public_key, + witnesses: Vec::new(), + ecdsa_checkpoint_signature: None, + ecdsa_checkpoint_key: None, + }) + } +} + +/// Parse one level-0 tile's bytes into leaf hashes, enforcing the exact +/// expected width so a truncated or padded tile is rejected at the boundary. +fn parse_leaf_tile( + data: &[u8], + width: u64, + path: &str, + out: &mut Vec, +) -> Result<(), TransparencyError> { + let expected = (width as usize) * 32; + if data.len() != expected { + return Err(TransparencyError::StoreError(format!( + "tile {path}: expected {expected} bytes ({width} hashes), got {}", + data.len() + ))); + } + for chunk in data.chunks_exact(32) { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(chunk); + out.push(MerkleHash::from_bytes(bytes)); + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::fs_store::FsTileStore; + use crate::merkle::hash_leaf; + use auths_verifier::evidence_pack::verify_transparency_inclusion; + + fn fixed_now() -> DateTime { + DateTime::parse_from_rfc3339("2026-06-12T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + fn writer_in(dir: &std::path::Path) -> LogWriter { + LogWriter::new( + FsTileStore::new(dir.to_path_buf()), + LogSigningKey::generate().unwrap(), + LogOrigin::new("test.example/log").unwrap(), + ) + } + + #[tokio::test] + async fn append_then_prove_roundtrips_through_the_verifier() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + + let digests = ["sha256:aa", "sha256:bb", "sha256:cc"]; + for (i, d) in digests.iter().enumerate() { + let appended = writer + .append(hash_leaf(d.as_bytes()), fixed_now()) + .await + .unwrap(); + assert_eq!(appended.index, i as u64); + assert_eq!(appended.signed_checkpoint.checkpoint.size, i as u64 + 1); + } + + for d in digests { + let leaf = hash_leaf(d.as_bytes()); + let inclusion = writer.prove(&leaf).await.unwrap(); + assert_eq!(inclusion.signed_checkpoint.checkpoint.size, 3); + verify_transparency_inclusion(&inclusion) + .expect("the verifier the browser/CLI runs must accept the writer's evidence"); + } + } + + #[tokio::test] + async fn checkpoint_signature_verifies_against_the_log_public_key() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + + let appended = writer + .append(hash_leaf(b"sha256:aa"), fixed_now()) + .await + .unwrap(); + let signed = &appended.signed_checkpoint; + + let body = signed.checkpoint.to_note_body(); + let key = ring::signature::UnparsedPublicKey::new( + &ring::signature::ED25519, + signed.log_public_key.as_bytes(), + ); + key.verify(body.as_bytes(), signed.log_signature.as_bytes()) + .expect("checkpoint must be signed over the C2SP note body"); + } + + #[tokio::test] + async fn prove_unknown_leaf_fails() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + writer + .append(hash_leaf(b"sha256:aa"), fixed_now()) + .await + .unwrap(); + + let stranger = hash_leaf(b"sha256:never-appended"); + assert!(writer.prove(&stranger).await.is_err()); + } + + #[tokio::test] + async fn origin_mismatch_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + writer + .append(hash_leaf(b"sha256:aa"), fixed_now()) + .await + .unwrap(); + + let imposter = LogWriter::new( + FsTileStore::new(dir.path().to_path_buf()), + LogSigningKey::generate().unwrap(), + LogOrigin::new("other.example/log").unwrap(), + ); + assert!(matches!( + imposter.append(hash_leaf(b"sha256:bb"), fixed_now()).await, + Err(TransparencyError::InvalidOrigin(_)) + )); + } + + #[tokio::test] + async fn tampered_tile_fails_closed_on_next_operation() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + let leaf = hash_leaf(b"sha256:aa"); + writer.append(leaf, fixed_now()).await.unwrap(); + writer + .append(hash_leaf(b"sha256:bb"), fixed_now()) + .await + .unwrap(); + + // Flip a byte in the partial level-0 tile behind the writer's back. + let tile = dir.path().join("tile/0/000.p/2"); + let mut bytes = std::fs::read(&tile).unwrap(); + bytes[0] ^= 0xff; + std::fs::write(&tile, bytes).unwrap(); + + assert!(matches!( + writer.prove(&leaf).await, + Err(TransparencyError::RootMismatch { .. }) + )); + } + + #[tokio::test] + async fn key_roundtrips_through_pkcs8() { + let key = LogSigningKey::generate().unwrap(); + let der = key.to_pkcs8_der().unwrap(); + let reloaded = LogSigningKey::from_pkcs8_der(&der).unwrap(); + assert_eq!(key.public_key(), reloaded.public_key()); + } + + #[tokio::test] + async fn appends_roll_over_a_full_tile() { + let dir = tempfile::tempdir().unwrap(); + let writer = writer_in(dir.path()); + + let count = TILE_WIDTH + 3; + for i in 0..count { + writer + .append( + hash_leaf(format!("sha256:{i:064x}").as_bytes()), + fixed_now(), + ) + .await + .unwrap(); + } + + // Tile 0 is now a full, write-once tile; tile 1 a 3-wide partial. + assert!(dir.path().join("tile/0/000").exists()); + assert!(dir.path().join("tile/0/001.p/3").exists()); + + let first = hash_leaf(format!("sha256:{:064x}", 0).as_bytes()); + let inclusion = writer.prove(&first).await.unwrap(); + assert_eq!(inclusion.signed_checkpoint.checkpoint.size, count); + verify_transparency_inclusion(&inclusion).expect("proof across tiles verifies"); + } +} diff --git a/crates/auths-verifier/src/evidence_pack.rs b/crates/auths-verifier/src/evidence_pack.rs index ea924ce5..17ffcdaa 100644 --- a/crates/auths-verifier/src/evidence_pack.rs +++ b/crates/auths-verifier/src/evidence_pack.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; use crate::org_bundle::{ AirGappedOrgBundle, AuthorityAtSigning, classify_authority_in_bundle, verify_org_bundle, }; -use crate::tlog::{ConsistencyProof, InclusionProof, MerkleHash, SignedCheckpoint}; +use crate::tlog::{ConsistencyProof, InclusionProof, MerkleHash, SignedCheckpoint, hash_leaf}; use crate::types::IdentityDID; /// The current evidence-pack schema version. @@ -99,7 +99,12 @@ pub enum ComplianceFramework { /// inclusion must be **against** the checkpoint (same root and size). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TransparencyInclusion { - /// The artifact's transparency-log leaf hash (the value `inclusion_proof` proves). + /// The artifact's transparency-log leaf hash (the value `inclusion_proof` + /// proves). For a release row the leaf data is the canonical artifact + /// digest string (`sha256:`), so `leaf_hash = + /// hash_leaf(artifact_digest)` — row verification re-derives and compares + /// it, binding the proof to *this* artifact rather than to whatever leaf + /// the prover chose to embed. pub leaf_hash: MerkleHash, /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`. pub inclusion_proof: InclusionProof, @@ -214,8 +219,9 @@ pub struct RowVerdict { /// Whether re-deriving authority from the embedded KEL matches the row's /// recorded verdict (a tampered row flips this to `false`). pub authority_consistent: bool, - /// Whether the row's transparency inclusion/consistency proof verified, or - /// `None` when the row carries no transparency evidence. + /// Whether the row's transparency evidence verified: the leaf hash + /// re-derives from the row's artifact digest AND the inclusion/consistency + /// proof checks out. `None` when the row carries no transparency evidence. pub transparency_verified: Option, } @@ -315,10 +321,13 @@ pub fn verify_evidence_pack_offline( let rederived = classify_authority_in_bundle(bundle, &signer_prefix, row.signed_at); let authority_consistent = rederived == row.authority_at_release; - let transparency_verified = row - .transparency - .as_ref() - .map(|t| verify_transparency_inclusion(t).is_ok()); + // The proof must be FOR this row's artifact: the leaf is the canonical + // digest string, so a valid proof over some other leaf is a mismatch, + // not evidence. + let transparency_verified = row.transparency.as_ref().map(|t| { + t.leaf_hash == hash_leaf(row.artifact_digest.as_bytes()) + && verify_transparency_inclusion(t).is_ok() + }); verdicts.push(RowVerdict { artifact_digest: row.artifact_digest.clone(), diff --git a/crates/auths-verifier/src/tlog/error.rs b/crates/auths-verifier/src/tlog/error.rs index 1a9c147d..e76de350 100644 --- a/crates/auths-verifier/src/tlog/error.rs +++ b/crates/auths-verifier/src/tlog/error.rs @@ -40,4 +40,8 @@ pub enum TransparencyError { /// Storage backend error. #[error("store error: {0}")] StoreError(String), + + /// Log signing-key error (parse, curve mismatch, or signing failure). + #[error("signing key error: {0}")] + SigningKey(String), } diff --git a/crates/auths-verifier/src/tlog/merkle.rs b/crates/auths-verifier/src/tlog/merkle.rs index e78d05ae..f2a36bec 100644 --- a/crates/auths-verifier/src/tlog/merkle.rs +++ b/crates/auths-verifier/src/tlog/merkle.rs @@ -322,6 +322,58 @@ fn largest_power_of_2_lt(n: u64) -> u64 { } } +/// Generate the RFC 6962 inclusion-proof path for the leaf at `index`. +/// +/// Returns the ordered sibling hashes from leaf to root — exactly the +/// `hashes` that [`verify_inclusion`] consumes against +/// `compute_root(leaves)`. This is `PATH(m, D[0:n])` from RFC 6962 +/// Section 2.1.1, the generation dual of the verification walk above; both +/// share [`hash_children`]/[`compute_root`] so no second tree shape exists. +/// +/// Args: +/// * `leaves` — Every leaf hash in the tree, in log order. +/// * `index` — Zero-based index of the leaf being proven. +/// +/// Usage: +/// ```ignore +/// let hashes = prove_inclusion(&leaf_hashes, 5)?; +/// verify_inclusion(&leaf_hashes[5], 5, leaf_hashes.len() as u64, &hashes, &root)?; +/// ``` +pub fn prove_inclusion( + leaves: &[MerkleHash], + index: u64, +) -> Result, TransparencyError> { + let size = leaves.len() as u64; + if size == 0 { + return Err(TransparencyError::InvalidProof("tree size is 0".into())); + } + if index >= size { + return Err(TransparencyError::InvalidProof(format!( + "index {index} >= size {size}" + ))); + } + Ok(inclusion_path(leaves, index)) +} + +/// RFC 6962 `PATH(m, D[0:n])`: recurse into the subtree containing the leaf, +/// then append the root of the sibling subtree. +fn inclusion_path(leaves: &[MerkleHash], index: u64) -> Vec { + let n = leaves.len(); + if n <= 1 { + return Vec::new(); + } + let k = largest_power_of_2_lt(n as u64) as usize; + if (index as usize) < k { + let mut path = inclusion_path(&leaves[..k], index); + path.push(compute_root(&leaves[k..])); + path + } else { + let mut path = inclusion_path(&leaves[k..], index - k as u64); + path.push(compute_root(&leaves[..k])); + path + } +} + #[cfg(test)] mod tests { use super::*; @@ -373,6 +425,40 @@ mod tests { assert_eq!(compute_root(&[h]), h); } + #[test] + fn prove_inclusion_roundtrips_against_verify_inclusion() { + // Every leaf of every tree size 1..=20 (covers power-of-2 and ragged + // shapes) must produce a path that verify_inclusion accepts. + for size in 1u64..=20 { + let leaves: Vec = (0..size) + .map(|i| hash_leaf(format!("leaf-{i}").as_bytes())) + .collect(); + let root = compute_root(&leaves); + for index in 0..size { + let hashes = prove_inclusion(&leaves, index).unwrap(); + verify_inclusion(&leaves[index as usize], index, size, &hashes, &root).unwrap(); + } + } + } + + #[test] + fn prove_inclusion_path_fails_for_wrong_leaf() { + let leaves: Vec = (0..7u64) + .map(|i| hash_leaf(format!("leaf-{i}").as_bytes())) + .collect(); + let root = compute_root(&leaves); + let hashes = prove_inclusion(&leaves, 3).unwrap(); + let wrong = hash_leaf(b"not-in-the-tree"); + assert!(verify_inclusion(&wrong, 3, 7, &hashes, &root).is_err()); + } + + #[test] + fn prove_inclusion_rejects_empty_tree_and_out_of_range() { + assert!(prove_inclusion(&[], 0).is_err()); + let leaves = vec![hash_leaf(b"only")]; + assert!(prove_inclusion(&leaves, 1).is_err()); + } + #[test] fn compute_root_empty() { assert_eq!(compute_root(&[]), MerkleHash::EMPTY); diff --git a/crates/auths-verifier/src/tlog/mod.rs b/crates/auths-verifier/src/tlog/mod.rs index 83c0618c..e2e9b300 100644 --- a/crates/auths-verifier/src/tlog/mod.rs +++ b/crates/auths-verifier/src/tlog/mod.rs @@ -24,6 +24,8 @@ pub mod types; pub use checkpoint::{Checkpoint, SignedCheckpoint, WitnessCosignature}; pub use error::TransparencyError; -pub use merkle::{compute_root, hash_children, hash_leaf, verify_consistency, verify_inclusion}; +pub use merkle::{ + compute_root, hash_children, hash_leaf, prove_inclusion, verify_consistency, verify_inclusion, +}; pub use proof::{ConsistencyProof, InclusionProof}; pub use types::{LogOrigin, MerkleHash}; From 7832a2e2ea51a943b893245e7d50e08417dc2ca2 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 17:24:26 +0100 Subject: [PATCH 10/32] feat(AITFC-5b): pinned log-key checkpoint attestation for offline evidence-pack verification verify_evidence_pack_offline (native/WASM/CLI) now takes a pinned log-operator key: every row's SignedCheckpoint signature is verified against it, upgrading 'in the log' from bare Merkle membership to operator-attested non-repudiation. SignedCheckpoint::verify_log_signature is the one shared implementation (the transparency bundle verifier's Ed25519 arm now delegates to it), the CLI gains 'compliance verify --log-key ', and RowVerdict reports the new checkpoint_attested axis honestly (None when no key is pinned). Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/compliance.rs | 83 +++++++++++-- .../auths-sdk/src/domains/compliance/dsse.rs | 24 ++-- .../auths-sdk/tests/cases/compliance_query.rs | 38 ++++-- crates/auths-transparency/src/verify.rs | 7 +- crates/auths-verifier/src/evidence_pack.rs | 80 ++++++++++-- crates/auths-verifier/src/tlog/checkpoint.rs | 115 ++++++++++++++++++ crates/auths-verifier/src/wasm.rs | 19 ++- 7 files changed, 320 insertions(+), 46 deletions(-) diff --git a/crates/auths-cli/src/commands/compliance.rs b/crates/auths-cli/src/commands/compliance.rs index f6310e15..71356df0 100644 --- a/crates/auths-cli/src/commands/compliance.rs +++ b/crates/auths-cli/src/commands/compliance.rs @@ -26,7 +26,7 @@ use auths_sdk::workflows::compliance::{ sign_framework_report, verify_signed_evidence_pack_offline, }; use auths_sdk::workflows::roots::parse_roots_typed; -use auths_verifier::{IdentityDID, Prefix}; +use auths_verifier::{Ed25519PublicKey, IdentityDID, Prefix}; use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; @@ -141,9 +141,12 @@ impl From for ComplianceFramework { # Build an offline-verifiable evidence pack from the # releases anchored in the org KEL (signed_at derived) - auths compliance verify --pack acme-2026Q3.evidence --roots auths-roots + auths compliance verify --pack acme-2026Q3.evidence --roots auths-roots \\ + --log-key auths-log.pub # Auditor-side: verify a signed pack offline (exit 0 - # authentic / exit 1 rejected) — no account, no network + # authentic / exit 1 rejected) — no account, no network. + # --log-key pins the log operator: every row's checkpoint + # signature must verify, not just its Merkle membership Releases file (JSON array, caller-asserted alternative to --discover): [{\"artifact_digest\":\"sha256:…\",\"signer\":\"did:keri:EMember\",\"signed_at\":41}]" @@ -246,9 +249,32 @@ pub enum ComplianceSubcommand { /// Pinned trust-roots file (one `did:keri:…` per line) — the only trust input #[arg(long)] roots: PathBuf, + + /// Pinned log-operator key file (one hex Ed25519 key, `#` comments + /// allowed). When given, every row's transparency checkpoint must be + /// SIGNED by this operator — "in the log" becomes operator-attested + /// non-repudiation, not bare Merkle membership + #[arg(long)] + log_key: Option, }, } +/// Parse a pinned log-operator key file: the first non-empty, non-comment line, +/// as a 64-hex-char Ed25519 public key. Fail-closed on anything else. +fn parse_pinned_log_key(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Failed to read pinned log-key file {path:?}"))?; + let line = raw + .lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with('#')) + .ok_or_else(|| anyhow!("pinned log-key file {path:?} contains no key"))?; + let bytes = hex::decode(line) + .map_err(|e| anyhow!("pinned log-key file {path:?} is not valid hex: {e}"))?; + Ed25519PublicKey::try_from_slice(&bytes) + .map_err(|e| anyhow!("pinned log-key file {path:?} rejected: {e}")) +} + /// Handle `auths compliance` subcommands. /// /// Args: @@ -458,31 +484,45 @@ pub fn handle_compliance( Ok(()) } - ComplianceSubcommand::Verify { pack, roots } => { + ComplianceSubcommand::Verify { + pack, + roots, + log_key, + } => { let envelope_json = fs::read_to_string(&pack) .with_context(|| format!("Failed to read evidence pack {pack:?}"))?; let roots_raw = fs::read_to_string(&roots) .with_context(|| format!("Failed to read pinned-roots file {roots:?}"))?; let pinned = parse_roots_typed(&roots_raw) .map_err(|e| anyhow!("pinned roots file rejected: {e}"))?; + let pinned_log_key = log_key.as_deref().map(parse_pinned_log_key).transpose()?; // Hard rejections (no envelope, bad DSSE signature, unpinned org, // KEL tamper, duplicity) surface here as errors → exit 1. - let verified = verify_signed_evidence_pack_offline(&envelope_json, &pinned) - .map_err(|e| anyhow!("evidence REJECTED: {e}"))?; + let verified = verify_signed_evidence_pack_offline( + &envelope_json, + &pinned, + pinned_log_key.as_ref(), + ) + .map_err(|e| anyhow!("evidence REJECTED: {e}"))?; let authentic = verified.authentic(); if is_json_mode() { JsonResponse { success: authentic, command: "compliance verify".to_string(), - data: Some(verify_verdict_json(&pack, &verified, authentic)), + data: Some(verify_verdict_json( + &pack, + &verified, + authentic, + pinned_log_key.is_some(), + )), error: (!authentic) .then(|| "a row is inconsistent with the embedded log".to_string()), } .print()?; } else { - print_verify_report(&pack, &verified, authentic); + print_verify_report(&pack, &verified, authentic, pinned_log_key.is_some()); } // A verified envelope whose rows diverge from the embedded log is @@ -511,6 +551,7 @@ fn verify_verdict_json( pack_path: &Path, verified: &VerifiedEvidencePack, authentic: bool, + log_key_pinned: bool, ) -> serde_json::Value { serde_json::json!({ "pack": pack_path, @@ -521,13 +562,19 @@ fn verify_verdict_json( "org_key_source": "authenticated embedded KEL", "org_kel_seq": verified.org_kel_seq.to_string(), "root_pinned": true, + "log_key_pinned": log_key_pinned, "rows": verified.verdicts, "authentic": authentic, }) } /// Render the auditor-facing verification report (green = proven, red = rejected). -fn print_verify_report(pack_path: &Path, verified: &VerifiedEvidencePack, authentic: bool) { +fn print_verify_report( + pack_path: &Path, + verified: &VerifiedEvidencePack, + authentic: bool, + log_key_pinned: bool, +) { let out = Output::stdout(); println!( "Offline evidence-pack verification of {}", @@ -551,15 +598,25 @@ fn print_verify_report(pack_path: &Path, verified: &VerifiedEvidencePack, authen verified.org_kel_seq )) ); + if log_key_pinned { + println!( + " {}", + out.success( + "Log-operator key pinned — checkpoint signatures verified, not just Merkle membership" + ) + ); + } println!(" Rows:"); for v in &verified.verdicts { let label = authority_label(v); let row_ok = v.authority_consistent && label.starts_with("authorized"); let mark = if v.authority_consistent { "✓" } else { "✗" }; - let transparency = match v.transparency_verified { - Some(true) => "logged", - Some(false) => "TRANSPARENCY-FAIL", - None => "unlogged", + let transparency = match (v.transparency_verified, v.checkpoint_attested) { + (Some(true), Some(true)) => "logged+operator-attested", + (Some(true), Some(false)) => "CHECKPOINT-UNATTESTED", + (Some(true), None) => "logged", + (Some(false), _) => "TRANSPARENCY-FAIL", + (None, _) => "unlogged", }; let line = format!( "{mark} {} {} {label}", diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index 60338b42..c31124a6 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -26,7 +26,7 @@ use crate::domains::compliance::frameworks::{FrameworkReport, INTOTO_STATEMENT_T use crate::domains::compliance::query::{ ComplianceQueryError, EvidencePack, RowVerdict, verify_evidence_pack_offline, }; -use auths_verifier::IdentityDID; +use auths_verifier::{Ed25519PublicKey, IdentityDID}; /// The in-toto Statement payload type carried in the DSSE envelope. pub const DSSE_INTOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; @@ -165,11 +165,14 @@ pub struct VerifiedEvidencePack { impl VerifiedEvidencePack { /// The auditor's single verdict: every row's authority re-derivation matched - /// the recorded row, and every transparency proof present verified. + /// the recorded row, every transparency proof present verified, and — when a + /// log key was pinned — every row's checkpoint signature attested. pub fn authentic(&self) -> bool { - self.verdicts - .iter() - .all(|v| v.authority_consistent && v.transparency_verified.unwrap_or(true)) + self.verdicts.iter().all(|v| { + v.authority_consistent + && v.transparency_verified.unwrap_or(true) + && v.checkpoint_attested.unwrap_or(true) + }) } } @@ -185,7 +188,9 @@ impl VerifiedEvidencePack { /// from it — never from a keychain, a server, or a config file. /// 3. Verify the DSSE signature over the PAE bytes against that verkey. /// 4. Run [`verify_evidence_pack_offline`]: org root pinned, KEL duplicity, -/// per-row authority re-derivation, transparency proofs where present. +/// per-row authority re-derivation, transparency proofs where present — +/// and, with a pinned log key, each row's checkpoint signature against the +/// pinned log operator. /// /// Fail-closed: a missing bundle, an unauthenticated KEL, a signature that does /// not verify, an unpinned org, or duplicity is an `Err` — never a verdict. @@ -193,15 +198,18 @@ impl VerifiedEvidencePack { /// Args: /// * `envelope_json`: The DSSE envelope as produced by [`sign_evidence_pack`]. /// * `pinned_roots`: The verifier's pinned trust roots (its only trust input). +/// * `pinned_log_key`: The log operator's Ed25519 key, pinned out of band; +/// `None` keeps the transparency verdict membership-only (and it says so). /// /// Usage: /// ```ignore -/// let verified = verify_signed_evidence_pack_offline(&raw, &roots)?; +/// let verified = verify_signed_evidence_pack_offline(&raw, &roots, Some(&log_key))?; /// assert!(verified.authentic()); /// ``` pub fn verify_signed_evidence_pack_offline( envelope_json: &str, pinned_roots: &[IdentityDID], + pinned_log_key: Option<&Ed25519PublicKey>, ) -> Result { let envelope = DsseEnvelope::from_json(envelope_json)?; let payload = envelope.decoded_payload()?; @@ -240,7 +248,7 @@ pub fn verify_signed_evidence_pack_offline( envelope.verify(org_key.as_bytes(), org_curve)?; // Only now is the payload trusted enough to re-derive every row from it. - let verdicts = verify_evidence_pack_offline(&pack, pinned_roots)?; + let verdicts = verify_evidence_pack_offline(&pack, pinned_roots, pinned_log_key)?; Ok(VerifiedEvidencePack { org_kel_seq: state.sequence, pack, diff --git a/crates/auths-sdk/tests/cases/compliance_query.rs b/crates/auths-sdk/tests/cases/compliance_query.rs index d4c11b71..255ece21 100644 --- a/crates/auths-sdk/tests/cases/compliance_query.rs +++ b/crates/auths-sdk/tests/cases/compliance_query.rs @@ -214,7 +214,7 @@ fn offline_pack_round_trips_and_verifies_with_zero_network() { // Serialize → deserialize → verify offline against the org as pinned root. let canonical = pack.canonicalize().unwrap(); let reloaded = EvidencePack::from_json(&canonical).expect("reload pack"); - let verdicts = verify_evidence_pack_offline(&reloaded, std::slice::from_ref(&org_did)) + let verdicts = verify_evidence_pack_offline(&reloaded, std::slice::from_ref(&org_did), None) .expect("offline verify"); assert_eq!(verdicts.len(), 1); assert!( @@ -226,7 +226,7 @@ fn offline_pack_round_trips_and_verifies_with_zero_network() { // A non-pinned root fails closed. let stranger = IdentityDID::new_unchecked("did:keri:EStranger".to_string()); assert!( - verify_evidence_pack_offline(&reloaded, &[stranger]).is_err(), + verify_evidence_pack_offline(&reloaded, &[stranger], None).is_err(), "an unpinned org must fail offline verification" ); } @@ -274,7 +274,8 @@ fn offline_verify_catches_a_tampered_row() { serde_json::json!({ "authority_at_signing": "authorized_before_revocation" }); let tampered_pack = EvidencePack::from_json(&tampered.to_string()).unwrap(); - let verdicts = verify_evidence_pack_offline(&tampered_pack, &[org_did]).expect("verify runs"); + let verdicts = + verify_evidence_pack_offline(&tampered_pack, &[org_did], None).expect("verify runs"); assert!( !verdicts[0].authority_consistent, "re-deriving authority from the embedded KEL must expose the tampered row" @@ -351,7 +352,8 @@ fn offline_verify_binds_transparency_proofs_to_the_rows_artifact() { ) .expect("build offline pack"); - let verdicts = verify_evidence_pack_offline(&pack, &[org_did]).expect("offline verify"); + let verdicts = verify_evidence_pack_offline(&pack, std::slice::from_ref(&org_did), None) + .expect("offline verify"); assert_eq!( verdicts[0].transparency_verified, Some(true), @@ -362,6 +364,27 @@ fn offline_verify_binds_transparency_proofs_to_the_rows_artifact() { Some(false), "a valid proof over a different leaf must NOT count as this row's evidence" ); + assert_eq!( + verdicts[0].checkpoint_attested, None, + "with no pinned log key the verdict honestly reports membership only" + ); + + // Pin a log key this checkpoint was NOT signed by: Merkle membership still + // verifies, but the operator axis fails — a forged/backdated checkpoint is + // cryptographically visible, never silently green. + let pinned = Ed25519PublicKey::from_bytes([3u8; 32]); + let verdicts = verify_evidence_pack_offline(&pack, &[org_did], Some(&pinned)) + .expect("offline verify with a pinned log key"); + assert_eq!( + verdicts[0].transparency_verified, + Some(true), + "membership math is unchanged by the pinned key" + ); + assert_eq!( + verdicts[0].checkpoint_attested, + Some(false), + "a checkpoint not signed by the pinned operator must fail the attestation axis" + ); } #[test] @@ -474,7 +497,7 @@ fn signed_offline_pack_verifies_end_to_end_from_envelope_and_roots_alone() { // The auditor's whole input: the envelope bytes + a pinned root. No // keychain, no registry, no context. - let verified = verify_signed_evidence_pack_offline(&raw, std::slice::from_ref(&org_did)) + let verified = verify_signed_evidence_pack_offline(&raw, std::slice::from_ref(&org_did), None) .expect("signed pack verifies offline"); assert_eq!(verified.verdicts.len(), 2); assert!( @@ -502,14 +525,15 @@ fn signed_offline_pack_verifies_end_to_end_from_envelope_and_roots_alone() { tampered.payload = engine.encode(statement.to_string().as_bytes()); let raw_tampered = tampered.to_canonical_json().unwrap(); assert!( - verify_signed_evidence_pack_offline(&raw_tampered, std::slice::from_ref(&org_did)).is_err(), + verify_signed_evidence_pack_offline(&raw_tampered, std::slice::from_ref(&org_did), None) + .is_err(), "a tampered payload must fail the DSSE signature check" ); // An auditor who pinned a different root rejects the whole pack. let stranger = IdentityDID::new_unchecked("did:keri:EStranger".to_string()); assert!( - verify_signed_evidence_pack_offline(&raw, &[stranger]).is_err(), + verify_signed_evidence_pack_offline(&raw, &[stranger], None).is_err(), "an unpinned org must fail closed" ); } diff --git a/crates/auths-transparency/src/verify.rs b/crates/auths-transparency/src/verify.rs index 2d7b2927..ec42f695 100644 --- a/crates/auths-transparency/src/verify.rs +++ b/crates/auths-transparency/src/verify.rs @@ -164,8 +164,11 @@ fn verify_checkpoint(signed: &SignedCheckpoint, trust_root: &TrustRoot) -> Check match trust_root.signature_algorithm { auths_verifier::SignatureAlgorithm::Ed25519 => { - let peer_key = UnparsedPublicKey::new(&ED25519, trust_root.log_public_key.as_bytes()); - match peer_key.verify(note_body.as_bytes(), signed.log_signature.as_bytes()) { + // One source of truth for Ed25519 checkpoint verification: the + // pinned-key check in the shared verifier core (also requires the + // checkpoint's embedded key to BE the pinned key, so a bundle + // can't carry a self-chosen operator identity). + match signed.verify_log_signature(&trust_root.log_public_key) { Ok(()) => CheckpointStatus::Verified, Err(_) => CheckpointStatus::InvalidSignature, } diff --git a/crates/auths-verifier/src/evidence_pack.rs b/crates/auths-verifier/src/evidence_pack.rs index 17ffcdaa..c4f43c2c 100644 --- a/crates/auths-verifier/src/evidence_pack.rs +++ b/crates/auths-verifier/src/evidence_pack.rs @@ -20,6 +20,7 @@ use auths_keri::witness::independence::HonestyCeiling; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::core::Ed25519PublicKey; use crate::org_bundle::{ AirGappedOrgBundle, AuthorityAtSigning, classify_authority_in_bundle, verify_org_bundle, }; @@ -109,8 +110,10 @@ pub struct TransparencyInclusion { /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`. pub inclusion_proof: InclusionProof, /// The signed checkpoint the inclusion is anchored to (directly, or via the - /// consistency proof). Its signature trust requires a **pinned log key** — - /// a separate axis from this offline Merkle check. + /// consistency proof). Its signature is verified against the verifier's + /// **pinned log key** when one is supplied to + /// [`verify_evidence_pack_offline`] — upgrading "in the log" from bare + /// Merkle membership to operator-attested non-repudiation. pub signed_checkpoint: SignedCheckpoint, /// Consistency proof from the inclusion's tree size to the checkpoint's, present /// only when the inclusion was taken at an earlier size than the checkpoint. @@ -223,6 +226,13 @@ pub struct RowVerdict { /// re-derives from the row's artifact digest AND the inclusion/consistency /// proof checks out. `None` when the row carries no transparency evidence. pub transparency_verified: Option, + /// Whether the row's signed checkpoint is **attested by the pinned log + /// operator**: `Some(true)` when the checkpoint signature verified under + /// the caller's pinned log key, `Some(false)` when it did not (a forged + /// checkpoint, or one signed by a different operator). `None` when the + /// caller pinned no log key or the row carries no transparency evidence — + /// the verdict is then Merkle membership only, and says so. + pub checkpoint_attested: Option, } /// Verify the transparency inclusion (and consistency) of one row, offline. @@ -271,23 +281,29 @@ pub fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), Ev /// self-addresses AND is signed by the controlling key-state), confirms the org /// is a pinned root, flags KEL duplicity, then for each row re-derives /// authority-at-release from the embedded KEL (tamper check) and verifies any -/// transparency inclusion/consistency proof. The checkpoint **signature** trust -/// (that the log operator signed the root) is a separate axis requiring a -/// pinned log key; this function proves the Merkle membership, not the log -/// operator's identity. +/// transparency inclusion/consistency proof. With a `pinned_log_key`, each +/// row's [`SignedCheckpoint`] signature is also verified against that pinned +/// operator key ([`SignedCheckpoint::verify_log_signature`]) — "in the log" +/// becomes operator-attested non-repudiation, and a forged or backdated +/// checkpoint surfaces as `checkpoint_attested == Some(false)`. With no pinned +/// log key the verdict is Merkle membership only, reported honestly as +/// `checkpoint_attested == None`. /// /// Args: /// * `pack`: The pack to verify (must embed an org bundle). /// * `pinned_roots`: The verifier's pinned trust roots. +/// * `pinned_log_key`: The log operator's Ed25519 key, pinned out of band; +/// `None` skips the checkpoint-signature axis (and the verdict says so). /// /// Usage: /// ```ignore -/// let verdicts = verify_evidence_pack_offline(&pack, &roots)?; +/// let verdicts = verify_evidence_pack_offline(&pack, &roots, Some(&log_key))?; /// assert!(verdicts.iter().all(|v| v.authority_consistent)); /// ``` pub fn verify_evidence_pack_offline( pack: &EvidencePack, pinned_roots: &[IdentityDID], + pinned_log_key: Option<&Ed25519PublicKey>, ) -> Result, EvidencePackError> { let bundle = pack.org_bundle.as_ref().ok_or_else(|| { EvidencePackError::OfflineVerification( @@ -329,12 +345,20 @@ pub fn verify_evidence_pack_offline( && verify_transparency_inclusion(t).is_ok() }); + // The operator axis: only decidable when the verifier pinned a log key + // AND the row anchors to a checkpoint — anything else is honestly None. + let checkpoint_attested = match (row.transparency.as_ref(), pinned_log_key) { + (Some(t), Some(key)) => Some(t.signed_checkpoint.verify_log_signature(key).is_ok()), + _ => None, + }; + verdicts.push(RowVerdict { artifact_digest: row.artifact_digest.clone(), signer: row.signer.clone(), authority_at_release: row.authority_at_release.clone(), authority_consistent, transparency_verified, + checkpoint_attested, }); } Ok(verdicts) @@ -376,14 +400,20 @@ const SERIALIZE_FALLBACK: &str = /// Args: /// * `pack_json`: The [`EvidencePack`] JSON (the `.evidence` file). /// * `pinned_roots_json`: JSON array of the verifier's pinned `did:keri:` roots. +/// * `pinned_log_key_hex`: The pinned log operator key (64 hex chars, Ed25519), +/// or `None` for a membership-only verdict. /// /// Usage: /// ```ignore -/// let verdict = verify_evidence_pack_offline_json(&pack, r#"["did:keri:EOrg"]"#); +/// let verdict = verify_evidence_pack_offline_json(&pack, r#"["did:keri:EOrg"]"#, None); /// ``` -pub fn verify_evidence_pack_offline_json(pack_json: &str, pinned_roots_json: &str) -> String { +pub fn verify_evidence_pack_offline_json( + pack_json: &str, + pinned_roots_json: &str, + pinned_log_key_hex: Option<&str>, +) -> String { use auths_crypto::AuthsErrorInfo; - let envelope = match verify_pack_json_inner(pack_json, pinned_roots_json) { + let envelope = match verify_pack_json_inner(pack_json, pinned_roots_json, pinned_log_key_hex) { Ok(rows) => PackVerdictJson::Verdicts { rows }, Err(e) => PackVerdictJson::Error { code: e.error_code().to_string(), @@ -393,9 +423,18 @@ pub fn verify_evidence_pack_offline_json(pack_json: &str, pinned_roots_json: &st serde_json::to_string(&envelope).unwrap_or_else(|_| SERIALIZE_FALLBACK.to_string()) } +/// Parse a pinned log key from its hex wire form (fail-closed on malformed input). +fn parse_log_key_hex(hex_key: &str) -> Result { + let bytes = hex::decode(hex_key.trim()) + .map_err(|e| EvidencePackError::Decode(format!("pinned log key: invalid hex: {e}")))?; + Ed25519PublicKey::try_from_slice(&bytes) + .map_err(|e| EvidencePackError::Decode(format!("pinned log key: {e}"))) +} + fn verify_pack_json_inner( pack_json: &str, pinned_roots_json: &str, + pinned_log_key_hex: Option<&str>, ) -> Result, EvidencePackError> { if pack_json.len() > MAX_PACK_JSON_BYTES { return Err(EvidencePackError::Decode(format!( @@ -407,7 +446,8 @@ fn verify_pack_json_inner( let pack = EvidencePack::from_json(pack_json)?; let pinned_roots: Vec = serde_json::from_str(pinned_roots_json) .map_err(|e| EvidencePackError::Decode(format!("pinned roots: {e}")))?; - verify_evidence_pack_offline(&pack, &pinned_roots) + let pinned_log_key = pinned_log_key_hex.map(parse_log_key_hex).transpose()?; + verify_evidence_pack_offline(&pack, &pinned_roots, pinned_log_key.as_ref()) } #[cfg(test)] @@ -517,18 +557,32 @@ mod tests { fn pack_without_bundle_fails_offline_verification_closed() { let pack = sample_pack(); let roots = vec![IdentityDID::new_unchecked("did:keri:EOrg")]; - let err = verify_evidence_pack_offline(&pack, &roots).unwrap_err(); + let err = verify_evidence_pack_offline(&pack, &roots, None).unwrap_err(); assert!(err.to_string().contains("no embedded org bundle")); } #[test] fn pack_json_contract_reports_errors_as_tagged_envelopes() { - let verdict = verify_evidence_pack_offline_json("not json", "[]"); + let verdict = verify_evidence_pack_offline_json("not json", "[]", None); let v: serde_json::Value = serde_json::from_str(&verdict).unwrap(); assert_eq!(v["kind"], "error"); assert_eq!(v["code"], "AUTHS-E2302"); } + #[test] + fn pack_json_contract_rejects_a_malformed_pinned_log_key() { + let pack_json = sample_pack().canonicalize().unwrap(); + for bad in ["not hex", "abcd"] { + let verdict = verify_evidence_pack_offline_json(&pack_json, "[]", Some(bad)); + let v: serde_json::Value = serde_json::from_str(&verdict).unwrap(); + assert_eq!( + v["kind"], "error", + "malformed log key '{bad}' must fail closed" + ); + assert_eq!(v["code"], "AUTHS-E2302"); + } + } + fn signed_checkpoint_at(size: u64, root: MerkleHash) -> SignedCheckpoint { SignedCheckpoint { checkpoint: Checkpoint { diff --git a/crates/auths-verifier/src/tlog/checkpoint.rs b/crates/auths-verifier/src/tlog/checkpoint.rs index bf47a0f1..0ca816c9 100644 --- a/crates/auths-verifier/src/tlog/checkpoint.rs +++ b/crates/auths-verifier/src/tlog/checkpoint.rs @@ -100,6 +100,51 @@ pub struct SignedCheckpoint { pub ecdsa_checkpoint_key: Option, } +impl SignedCheckpoint { + /// Verify the log operator's Ed25519 signature over this checkpoint's C2SP + /// note body against a **pinned** log key — never against the key the + /// checkpoint itself carries (a self-chosen key verifying its own forged + /// signature is the classic "trust the key I sent you" anti-pattern). + /// + /// Two checks, both fail-closed: + /// 1. the embedded `log_public_key` must BE the pinned key (constant-time + /// compare) — a checkpoint from a different operator is not this log's, + /// even if its own signature is internally consistent; + /// 2. `log_signature` must verify over [`Checkpoint::to_note_body`] under + /// the pinned key. + /// + /// Pure and synchronous (`ed25519-dalek`), so every surface — native, FFI, + /// browser WASM — shares this one implementation. + /// + /// Args: + /// * `pinned_log_key`: The log operator's Ed25519 key, obtained out of band. + /// + /// Usage: + /// ```ignore + /// signed.verify_log_signature(&pinned_log_key)?; + /// ``` + pub fn verify_log_signature( + &self, + pinned_log_key: &Ed25519PublicKey, + ) -> Result<(), TransparencyError> { + use subtle::ConstantTimeEq; + let key_is_pinned: bool = self + .log_public_key + .as_bytes() + .ct_eq(pinned_log_key.as_bytes()) + .into(); + if !key_is_pinned { + return Err(TransparencyError::InvalidCheckpointSignature); + } + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(pinned_log_key.as_bytes()) + .map_err(|_| TransparencyError::InvalidCheckpointSignature)?; + let signature = ed25519_dalek::Signature::from_bytes(self.log_signature.as_bytes()); + verifying_key + .verify_strict(self.checkpoint.to_note_body().as_bytes(), &signature) + .map_err(|_| TransparencyError::InvalidCheckpointSignature) + } +} + /// A witness cosignature on a checkpoint. /// /// Witnesses independently verify the checkpoint and add their signature @@ -135,6 +180,76 @@ mod tests { assert_eq!(cp.root, parsed.root); } + fn signed_by(signing_key: &ed25519_dalek::SigningKey) -> SignedCheckpoint { + use ed25519_dalek::Signer; + let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let checkpoint = Checkpoint { + origin: LogOrigin::new("auths.dev/log").unwrap(), + size: 42, + root: MerkleHash::from_bytes([0xab; 32]), + timestamp: ts, + }; + let signature = signing_key.sign(checkpoint.to_note_body().as_bytes()); + SignedCheckpoint { + checkpoint, + log_signature: Ed25519Signature::from_bytes(signature.to_bytes()), + log_public_key: Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()), + witnesses: vec![], + ecdsa_checkpoint_signature: None, + ecdsa_checkpoint_key: None, + } + } + + #[test] + fn log_signature_verifies_under_the_pinned_key() { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let signed = signed_by(&signing_key); + let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()); + signed + .verify_log_signature(&pinned) + .expect("operator-signed checkpoint verifies under its pinned key"); + } + + #[test] + fn log_signature_rejects_a_different_pinned_operator() { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let signed = signed_by(&signing_key); + let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + let pinned = Ed25519PublicKey::from_bytes(other.verifying_key().to_bytes()); + assert!( + signed.verify_log_signature(&pinned).is_err(), + "a checkpoint from a different operator must fail the pinned-key check" + ); + } + + #[test] + fn log_signature_rejects_a_forged_signature() { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let mut signed = signed_by(&signing_key); + // Forge: same pinned key claimed, but the signature bytes are garbage. + signed.log_signature = Ed25519Signature::from_bytes([0x42; 64]); + let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()); + assert!( + signed.verify_log_signature(&pinned).is_err(), + "a forged checkpoint signature must fail closed" + ); + } + + #[test] + fn log_signature_rejects_a_tampered_checkpoint_body() { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let mut signed = signed_by(&signing_key); + // Backdate/rewrite: the root changes but the old signature is replayed. + signed.checkpoint.root = MerkleHash::from_bytes([0xcd; 32]); + let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()); + assert!( + signed.verify_log_signature(&pinned).is_err(), + "a rewritten checkpoint body must not verify under the old signature" + ); + } + #[test] fn checkpoint_json_roundtrip() { let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z") diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index 27a80cb7..e295ef84 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -559,12 +559,25 @@ pub fn wasm_verify_org_bundle( /// embedded org bundle, re-derives each row's authority-at-release from the /// embedded KEL (tamper check), and checks each row's transparency-log /// inclusion/consistency proof — so the dashboard computes the verdict live -/// instead of replaying a recorded native run. +/// instead of replaying a recorded native run. With a pinned log key, each +/// row's checkpoint signature is verified against that operator key too +/// (`checkpoint_attested` in the verdict); without one the verdict honestly +/// reports membership only. /// /// Args: /// * `pack_json`: The `EvidencePack` JSON (the `.evidence` file). /// * `pinned_roots_json`: JSON array of pinned `did:keri:` roots. +/// * `pinned_log_key_hex`: The pinned log operator key (64 hex chars, +/// Ed25519), or `undefined` for a membership-only verdict. #[wasm_bindgen(js_name = verifyEvidencePackOffline)] -pub fn wasm_verify_evidence_pack_offline(pack_json: &str, pinned_roots_json: &str) -> String { - crate::evidence_pack::verify_evidence_pack_offline_json(pack_json, pinned_roots_json) +pub fn wasm_verify_evidence_pack_offline( + pack_json: &str, + pinned_roots_json: &str, + pinned_log_key_hex: Option, +) -> String { + crate::evidence_pack::verify_evidence_pack_offline_json( + pack_json, + pinned_roots_json, + pinned_log_key_hex.as_deref(), + ) } From df69939e8dddd849bd361941fad0be364ef273aa Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 18:28:00 +0100 Subject: [PATCH 11/32] feat(LTL-5): mobile FFI read-only KEL verify surface over one shared attachment-pairing core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auths-keri: pair_kel_attachments — the single fail-closed definition pairing a KEL with its per-event CESR signature groups (count mismatch = the KEL is unauthenticated = refused; never a structural-only fallback), plus the AttachmentError::CountMismatch variant. - auths-verifier (wasm): validateKelJson now hex-decodes its wire and routes through pair_kel_attachments; the inline mismatch/zip copy is deleted. - auths-mobile-ffi: new kel_verification module — validate_kel_json authenticates a registry KEL (validate_signed_kel; dip/drt fail closed) and returns a typed VerifiedKeyState record; new KelVerificationFailed error; six unit tests (P-256 happy paths + unauthenticated/forged/tampered/ malformed rejections); one-line fix of the deprecated GenericArray::as_slice so the crate is clippy-clean under -D warnings. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-keri/src/events.rs | 51 ++++ crates/auths-keri/src/lib.rs | 6 +- .../auths-mobile-ffi/src/kel_verification.rs | 258 ++++++++++++++++++ crates/auths-mobile-ffi/src/lib.rs | 16 +- .../auths-mobile-ffi/src/pairing_context.rs | 2 +- crates/auths-verifier/src/wasm.rs | 33 +-- 6 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 crates/auths-mobile-ffi/src/kel_verification.rs diff --git a/crates/auths-keri/src/events.rs b/crates/auths-keri/src/events.rs index ff636261..48b2c0fa 100644 --- a/crates/auths-keri/src/events.rs +++ b/crates/auths-keri/src/events.rs @@ -1570,6 +1570,45 @@ fn parse_sig_group(s: &str) -> Result<(Vec, &str), AttachmentE Ok((out, cursor)) } +/// Pair an ordered KEL with its per-event CESR signature attachments, +/// producing the [`SignedEvent`]s an authenticated replay +/// ([`validate_signed_kel`](crate::validate_signed_kel)) consumes. +/// +/// FAIL-CLOSED on count mismatch: a KEL whose attachment list is absent or +/// short is an *unauthenticated* KEL — it is refused here rather than letting +/// any caller fall back to a structural-only replay (the RT-002 forge). Every +/// stateless verify entrypoint (WASM, mobile FFI) routes through this one +/// definition so the refusal rule cannot drift between transports. +/// +/// Args: +/// * `events`: The ordered KEL events, e.g. from [`parse_kel_json`](crate::parse_kel_json). +/// * `attachments`: One CESR `-A##` indexed-signature group per event, as raw +/// bytes (the text-domain `.cesr` form; transport encodings like hex are the +/// caller's adapter concern). +/// +/// Usage: +/// ```ignore +/// let events = auths_keri::parse_kel_json(kel_json)?; +/// let signed = auths_keri::pair_kel_attachments(events, &attachment_bytes)?; +/// let state = auths_keri::validate_signed_kel(&signed, None)?; +/// ``` +pub fn pair_kel_attachments( + events: Vec, + attachments: &[impl AsRef<[u8]>], +) -> Result, AttachmentError> { + if attachments.len() != events.len() { + return Err(AttachmentError::CountMismatch { + events: events.len(), + attachments: attachments.len(), + }); + } + events + .into_iter() + .zip(attachments) + .map(|(event, att)| Ok(SignedEvent::new(event, parse_attachment(att.as_ref())?))) + .collect() +} + /// Error shape for attachment encode/decode. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -1580,6 +1619,18 @@ pub enum AttachmentError { /// Decoding a CESR attachment stream failed (bad counter, malformed Siger, etc.). #[error("attachment decode: {0}")] Decode(String), + /// The KEL and its attachment list disagree in length: at least one event + /// has no signature group, so the KEL is unauthenticated. Refused outright + /// instead of degrading to a structural-only replay. + #[error( + "KEL/attachment count mismatch ({events} events vs {attachments} attachments): the KEL is unauthenticated, refusing" + )] + CountMismatch { + /// Number of events in the KEL. + events: usize, + /// Number of signature attachments supplied. + attachments: usize, + }, } /// CESR base64url alphabet, ordered so `B64_ALPHA[n]` is the char for n. diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 41ad39a6..52c8d69a 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -85,9 +85,9 @@ pub use events::{ AgentScope, DipEvent, DipEventInit, DrtEvent, DrtEventInit, Event, IcpEvent, IcpEventInit, IndexedSignature, IxnEvent, KERI_VERSION_PREFIX, KeriSequence, RotEvent, RotEventInit, Seal, SignedEvent, SourceSeal, WireSignedDip, WireSignedRot, decode_agent_scope, decode_signed_dip, - decode_signed_rot, encode_agent_scope, encode_signed_dip, encode_signed_rot, parse_attachment, - parse_delegated_attachment, parse_source_seal_couples, serialize_attachment, - serialize_source_seal_couples, + decode_signed_rot, encode_agent_scope, encode_signed_dip, encode_signed_rot, + pair_kel_attachments, parse_attachment, parse_delegated_attachment, parse_source_seal_couples, + serialize_attachment, serialize_source_seal_couples, }; pub use keys::{KeriDecodeError, KeriPublicKey}; pub use ksn::{KSN_TYPE, KSN_VERSION, KeyStateNotice, KsnError, SignedKsn}; diff --git a/crates/auths-mobile-ffi/src/kel_verification.rs b/crates/auths-mobile-ffi/src/kel_verification.rs new file mode 100644 index 00000000..bef0ca33 --- /dev/null +++ b/crates/auths-mobile-ffi/src/kel_verification.rs @@ -0,0 +1,258 @@ +//! Read-only KEL verification for registry state the app displays. +//! +//! Every other surface in this crate is write-side: it BUILDS payloads +//! the Secure Enclave signs. This module is the read side. A registry +//! sync hands the app an identity's KEL (the ordered event JSONs plus +//! one CESR indexed-signature group per event) and the app authenticates +//! it on-device before rendering it — the same authenticated replay the +//! WASM verifier exposes as `validateKelJson`, over the same auths-keri +//! core (`pair_kel_attachments` + `validate_signed_kel`). No private key +//! material is involved; the inputs are public registry bytes. +//! +//! Fail-closed properties, inherited from the core: +//! - An absent or short attachment list is an UNAUTHENTICATED KEL and is +//! refused outright — there is no structural-only fallback. +//! - A delegated (`dip`/`drt`) KEL is refused here: a single-KEL +//! entrypoint cannot supply the delegator's anchoring seals, so it +//! must resolve through a path that carries the delegator KEL too. + +use crate::MobileError; + +/// Upper bound per JSON input. Registry KELs are small (a handful of +/// events); anything near this size is malformed or hostile input. +const MAX_KEL_INPUT_BYTES: usize = 1024 * 1024; + +/// The authenticated key-state a verified KEL resolves to. +/// +/// Returned only after every event's signature verified against the +/// in-force key-state, so callers can treat each field as proven — +/// e.g. compare `did` against the controller DID a registry claimed. +#[derive(Debug, Clone, uniffi::Record)] +pub struct VerifiedKeyState { + /// The KERI identifier prefix the KEL self-addresses. + pub prefix: String, + + /// The full DID: `did:keri:{prefix}`. + pub did: String, + + /// Sequence number of the latest verified event. + pub sequence: u64, + + /// Current signing key(s), CESR-encoded. + pub current_keys: Vec, + + /// SAID of the latest verified event. + pub last_event_said: String, +} + +/// Authenticate a KERI Key Event Log and return the resulting key state. +/// +/// Args: +/// * `kel_json`: JSON array of KEL events (inception, rotation, interaction). +/// * `attachments_json`: JSON array of CESR text-domain `-A##` indexed-signature +/// groups, one per event — the registry's `.attachments.cesr` content, +/// verbatim. +/// +/// Usage: +/// ```ignore +/// // iOS Swift +/// let state = try validateKelJson(kelJson: kelJson, attachmentsJson: attachmentsJson) +/// guard state.did == claimedControllerDid else { /* registry lied */ } +/// ``` +#[uniffi::export] +pub fn validate_kel_json( + kel_json: String, + attachments_json: String, +) -> Result { + if kel_json.len() > MAX_KEL_INPUT_BYTES || attachments_json.len() > MAX_KEL_INPUT_BYTES { + return Err(MobileError::Serialization(format!( + "KEL input too large: max {MAX_KEL_INPUT_BYTES} bytes per field" + ))); + } + + let events = auths_keri::parse_kel_json(&kel_json) + .map_err(|e| MobileError::Serialization(format!("Invalid KEL JSON: {e}")))?; + let attachments: Vec = serde_json::from_str(&attachments_json) + .map_err(|e| MobileError::Serialization(format!("Invalid attachments JSON: {e}")))?; + + // Pairing fails closed on a count mismatch (an unauthenticated KEL never + // degrades to a structural-only replay); the rule lives in auths-keri. + let signed = auths_keri::pair_kel_attachments(events, &attachments) + .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + + let state = auths_keri::validate_signed_kel(&signed, None) + .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + + let prefix = state.prefix.as_str().to_string(); + Ok(VerifiedKeyState { + did: format!("did:keri:{prefix}"), + prefix, + sequence: u64::try_from(state.sequence) + .map_err(|_| MobileError::KelVerificationFailed("sequence exceeds u64".to_string()))?, + current_keys: state + .current_keys + .iter() + .map(|k| k.as_str().to_string()) + .collect(), + last_event_said: state.last_event_said.as_str().to_string(), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use auths_keri::{ + CesrKey, Event, IcpEvent, IndexedSignature, IxnEvent, KeriSequence, Prefix, Said, Seal, + Threshold, VersionString, finalize_icp_event, finalize_ixn_event, serialize_attachment, + serialize_for_signing, + }; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use p256::ecdsa::{SigningKey, signature::Signer}; + use p256::elliptic_curve::rand_core::OsRng; + + /// Build a finalized single-key P-256 inception event plus its signer. + fn signed_icp() -> (IcpEvent, SigningKey) { + let sk = SigningKey::random(&mut OsRng); + let compressed = sk.verifying_key().to_encoded_point(true); + let key_encoded = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(compressed.as_bytes())); + + let icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key_encoded)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("ENextCommitment".to_string())], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + }; + (finalize_icp_event(icp).unwrap(), sk) + } + + /// Sign `event` with `sk` and return the CESR attachment group as text. + fn cesr_attachment(event: &Event, sk: &SigningKey) -> String { + let payload = serialize_for_signing(event).unwrap(); + let sig: p256::ecdsa::Signature = sk.sign(&payload); + let att = serialize_attachment(&[IndexedSignature { + index: 0, + prior_index: None, + sig: sig.to_bytes().to_vec(), + }]) + .unwrap(); + String::from_utf8(att).unwrap() + } + + fn to_json(events: &[Event], attachments: &[String]) -> (String, String) { + ( + serde_json::to_string(events).unwrap(), + serde_json::to_string(attachments).unwrap(), + ) + } + + #[test] + fn verifies_signed_icp_and_returns_key_state() { + let (icp, sk) = signed_icp(); + let event = Event::Icp(icp.clone()); + let att = cesr_attachment(&event, &sk); + let (kel_json, attachments_json) = to_json(&[event], &[att]); + + let state = validate_kel_json(kel_json, attachments_json) + .expect("a correctly signed KEL must verify"); + assert_eq!(state.prefix, icp.i.as_str()); + assert_eq!(state.did, format!("did:keri:{}", icp.i.as_str())); + assert_eq!(state.sequence, 0); + assert_eq!(state.current_keys, vec![icp.k[0].as_str().to_string()]); + assert_eq!(state.last_event_said, icp.d.as_str()); + } + + #[test] + fn verifies_icp_then_ixn() { + let (icp, sk) = signed_icp(); + let ixn = finalize_ixn_event(IxnEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: icp.i.clone(), + s: KeriSequence::new(1), + p: icp.d.clone(), + a: vec![Seal::digest("EAttest")], + }) + .unwrap(); + + let events = [Event::Icp(icp), Event::Ixn(ixn)]; + let atts = [ + cesr_attachment(&events[0], &sk), + cesr_attachment(&events[1], &sk), + ]; + let (kel_json, attachments_json) = to_json(&events, &atts); + + let state = validate_kel_json(kel_json, attachments_json).unwrap(); + assert_eq!(state.sequence, 1); + } + + #[test] + fn rejects_missing_attachments_as_unauthenticated() { + // RT-002 shape: a structurally valid KEL with NO attachments must be + // refused, never replayed structurally. + let (icp, _sk) = signed_icp(); + let (kel_json, attachments_json) = to_json(&[Event::Icp(icp)], &[]); + + let err = validate_kel_json(kel_json, attachments_json).unwrap_err(); + assert!( + matches!(err, MobileError::KelVerificationFailed(ref msg) if msg.contains("unauthenticated")), + "got {err:?}" + ); + } + + #[test] + fn rejects_wrong_signer() { + let (icp, _sk) = signed_icp(); + let attacker = SigningKey::random(&mut OsRng); + let event = Event::Icp(icp); + let att = cesr_attachment(&event, &attacker); + let (kel_json, attachments_json) = to_json(&[event], &[att]); + + let err = validate_kel_json(kel_json, attachments_json).unwrap_err(); + assert!( + matches!(err, MobileError::KelVerificationFailed(_)), + "got {err:?}" + ); + } + + #[test] + fn rejects_tampered_event() { + let (icp, sk) = signed_icp(); + let event = Event::Icp(icp); + let att = cesr_attachment(&event, &sk); + let kel_json = serde_json::to_string(&[event]) + .unwrap() + .replace("\"s\":\"0\"", "\"s\":\"1\""); + let attachments_json = serde_json::to_string(&[att]).unwrap(); + + assert!(validate_kel_json(kel_json, attachments_json).is_err()); + } + + #[test] + fn rejects_malformed_inputs() { + assert!(matches!( + validate_kel_json("not json".into(), "[]".into()), + Err(MobileError::Serialization(_)) + )); + assert!(matches!( + validate_kel_json("[]".into(), "not json".into()), + Err(MobileError::Serialization(_)) + )); + let oversized = " ".repeat(MAX_KEL_INPUT_BYTES + 1); + assert!(matches!( + validate_kel_json(oversized, "[]".into()), + Err(MobileError::Serialization(_)) + )); + } +} diff --git a/crates/auths-mobile-ffi/src/lib.rs b/crates/auths-mobile-ffi/src/lib.rs index c70be993..4b5108a9 100644 --- a/crates/auths-mobile-ffi/src/lib.rs +++ b/crates/auths-mobile-ffi/src/lib.rs @@ -23,6 +23,7 @@ pub mod auth_challenge_context; pub mod delegated_inception_context; pub mod device_kel_rotation; pub mod identity_context; +pub mod kel_verification; pub mod pairing_context; pub mod shared_kel_context; pub(crate) mod signature; @@ -40,12 +41,13 @@ pub use auth_challenge_context::{ AuthChallengeContext, assemble_auth_challenge_response, build_auth_challenge_signing_payload, }; pub use delegated_inception_context::{ - P256DelegatedInceptionContext, SignedDelegatedInception, - assemble_p256_delegated_inception, build_p256_delegated_inception_payload, + P256DelegatedInceptionContext, SignedDelegatedInception, assemble_p256_delegated_inception, + build_p256_delegated_inception_payload, }; pub use identity_context::{ P256IdentityInceptionContext, assemble_p256_identity, build_p256_identity_inception_payload, }; +pub use kel_verification::{VerifiedKeyState, validate_kel_json}; pub use pairing_context::{ PairingBindingContext, assemble_pairing_response_body, build_pairing_binding_message, }; @@ -85,6 +87,9 @@ pub enum MobileError { #[error("Pre-committed next key does not match the prior KEL commitment: {0}")] CommitmentMismatch(String), + + #[error("KEL verification failed: {0}")] + KelVerificationFailed(String), } // ============================================================================ @@ -231,7 +236,8 @@ pub(crate) fn compute_next_commitment(public_key: &[u8]) -> String { /// Finalize an ICP event by computing and setting the SAID. pub(crate) fn finalize_icp_event(mut icp: IcpEvent) -> Result { - let value = serde_json::to_value(&icp).map_err(|e| MobileError::Serialization(e.to_string()))?; + let value = + serde_json::to_value(&icp).map_err(|e| MobileError::Serialization(e.to_string()))?; let said = compute_said(&value) .ok_or_else(|| MobileError::Serialization("SAID computation failed".to_string()))?; icp.d = said.clone(); @@ -317,8 +323,8 @@ pub fn parse_auth_challenge_uri(uri: String) -> Result = serde_json::from_str(attachments_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse attachments JSON: {}", e)))?; - // An absent/short attachment list is an UNAUTHENTICATED KEL — refuse it rather - // than fall back to a structural-only replay (that fallback is exactly RT-002). - if attachments.len() != events.len() { - return Err(JsValue::from_str(&format!( - "KEL/attachment length mismatch ({} events vs {} attachments): the KEL is \ - unauthenticated, refusing (RT-002)", - events.len(), - attachments.len() - ))); - } - - let signed: Vec = events - .into_iter() - .zip(attachments.iter()) - .map(|(event, att_hex)| { - let bytes = hex::decode(att_hex) - .map_err(|e| JsValue::from_str(&format!("Invalid attachment hex: {}", e)))?; - let sigs = auths_keri::parse_attachment(&bytes) - .map_err(|e| JsValue::from_str(&format!("Invalid CESR attachment: {}", e)))?; - Ok(auths_keri::SignedEvent::new(event, sigs)) + // This entrypoint's wire carries hex; decode to the raw CESR attachment + // bytes the shared pairing step consumes. + let attachment_bytes: Vec> = attachments + .iter() + .map(|att_hex| { + hex::decode(att_hex) + .map_err(|e| JsValue::from_str(&format!("Invalid attachment hex: {}", e))) }) .collect::>()?; + // Pairing fails closed on an absent/short attachment list (an + // unauthenticated KEL must never degrade to a structural-only replay — + // that fallback is exactly RT-002). The rule lives once, in auths-keri. + let signed = auths_keri::pair_kel_attachments(events, &attachment_bytes) + .map_err(|e| JsValue::from_str(&format!("Invalid CESR attachment: {}", e)))?; + let key_state = auths_keri::validate_signed_kel(&signed, None) .map_err(|e| JsValue::from_str(&format!("KEL authentication failed: {}", e)))?; From 8b68749bd5326493c1f16c0e8b006c5097d39894 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 19:18:57 +0100 Subject: [PATCH 12/32] =?UTF-8?q?feat(LTL-6):=20offline=20auth-challenge?= =?UTF-8?q?=20verifier=20=E2=80=94=20auth=20verify=20against=20the=20regis?= =?UTF-8?q?try's=20in-force=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/auth.rs | 84 ++++++- crates/auths-sdk/src/workflows/auth.rs | 227 ++++++++++++++++++ .../src/check_verify_path_completeness.rs | 1 + 3 files changed, 309 insertions(+), 3 deletions(-) diff --git a/crates/auths-cli/src/commands/auth.rs b/crates/auths-cli/src/commands/auth.rs index b79fea81..671e7c02 100644 --- a/crates/auths-cli/src/commands/auth.rs +++ b/crates/auths-cli/src/commands/auth.rs @@ -18,11 +18,17 @@ use crate::ux::format::{JsonResponse, is_json_mode}; # Sign an authentication challenge auths auth challenge --nonce abc123def456 # Sign challenge for default domain (auths.dev) + auths auth verify --nonce abc123def456 --did did:keri:E... --signature + # Verify a challenge response offline, against the + # registry's current key for that DID Flow: - 1. Service sends you a nonce - 2. Run: auths auth challenge --nonce --domain - 3. Service verifies your signature against your DID + 1. Verifier sends a nonce + 2. Responder runs: auths auth challenge --nonce --domain + 3. Verifier runs: auths auth verify --nonce --domain \\ + --did --signature + (offline — the signature is checked against the registry's in-force + key, never a key the responder supplied) Related: auths id — Manage your identity @@ -47,6 +53,27 @@ pub enum AuthSubcommand { #[arg(long, default_value = "auths.dev")] domain: String, }, + + /// Verify a challenge response offline against the registry's current key + Verify { + /// The challenge nonce this verifier issued + #[arg(long)] + nonce: String, + + /// The domain the challenge was bound to + #[arg(long, default_value = "auths.dev")] + domain: String, + + /// The responder's did:keri: controller DID (delegated device DIDs + /// fail closed — their liveness needs the delegator's revocation + /// verdict) + #[arg(long)] + did: String, + + /// Hex-encoded signature from the challenge response + #[arg(long)] + signature: String, + }, } fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<()> { @@ -106,12 +133,63 @@ fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<( } } +/// Verify a challenge response offline against the registry's in-force key. +/// +/// The verifier-side counterpart of `auth challenge`: no auth server, no +/// network. The DID's KEL is replayed from the local registry and the +/// signature is checked against its *current* key — a response signed by a +/// stale pre-rotation key or a stolen device key fails, a response signed by +/// the identity's in-force key proves it alive. +fn handle_auth_verify(nonce: &str, domain: &str, did: &str, signature_hex: &str) -> Result<()> { + let signature = hex::decode(signature_hex) + .context("--signature must be the hex string printed by `auths auth challenge`")?; + + let auths_home = auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))?; + let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked( + auths_sdk::storage::RegistryConfig::single_tenant(&auths_home), + ); + + let verified = auths_sdk::workflows::auth::verify_auth_challenge( + ®istry, did, nonce, domain, &signature, + ) + .context("Auth challenge verification failed")?; + + if is_json_mode() { + JsonResponse::success( + "auth verify", + &serde_json::json!({ + "verified": true, + "did": verified.did, + "public_key": verified.public_key_hex, + "curve": verified.curve.to_string(), + "nonce": nonce, + "domain": domain, + }), + ) + .print() + .map_err(anyhow::Error::from) + } else { + println!("✓ Verified — the signature checks out under the registry's current key"); + println!("DID: {}", verified.did); + println!("Public Key: {}", verified.public_key_hex); + println!("Curve: {}", verified.curve); + println!("Domain: {domain}"); + Ok(()) + } +} + impl ExecutableCommand for AuthCommand { fn execute(&self, ctx: &CliConfig) -> Result<()> { match &self.subcommand { AuthSubcommand::Challenge { nonce, domain } => { handle_auth_challenge(nonce, domain, ctx) } + AuthSubcommand::Verify { + nonce, + domain, + did, + signature, + } => handle_auth_verify(nonce, domain, did, signature), } } } diff --git a/crates/auths-sdk/src/workflows/auth.rs b/crates/auths-sdk/src/workflows/auth.rs index 586d4ab9..f672217e 100644 --- a/crates/auths-sdk/src/workflows/auth.rs +++ b/crates/auths-sdk/src/workflows/auth.rs @@ -1,6 +1,15 @@ use auths_core::error::AuthsErrorInfo; use thiserror::Error; +// The offline verifier resolves keys through the git-backed registry, so it is +// gated like the rest of the registry-reading workflows (`commit_trust`) — +// `auths-sdk` still builds without `backend-git` (the challenge builder does not +// need it). +#[cfg(feature = "backend-git")] +use crate::keri::{CurrentKeyError, resolve_current_public_key}; +#[cfg(feature = "backend-git")] +use crate::ports::RegistryBackend; + /// Result of signing an authentication challenge. /// /// Args: @@ -97,6 +106,102 @@ pub fn build_auth_challenge_message( json_canon::to_string(&payload).map_err(|e| AuthChallengeError::Canonicalization(e.to_string())) } +/// A challenge response proven against the registry's in-force key. +/// +/// Returned only when the signature verifies under the **registry's** current +/// signing key for the DID — never under a key the responder supplied. This is +/// what makes the liveness claim third-party-checkable instead of self-vouched. +#[cfg(feature = "backend-git")] +#[derive(Debug, Clone)] +pub struct VerifiedAuthChallenge { + /// The DID whose registry key state verified the signature. + pub did: String, + /// Hex-encoded public key that verified — the registry's current key. + pub public_key_hex: String, + /// The verified key's curve. + pub curve: auths_crypto::CurveType, +} + +/// Errors from the offline auth-challenge verification workflow. +#[cfg(feature = "backend-git")] +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AuthChallengeVerifyError { + /// The challenge inputs were invalid (empty nonce/domain, or the canonical + /// payload could not be built). + #[error(transparent)] + Challenge(#[from] AuthChallengeError), + + /// The DID's current key could not be resolved from the local registry. + /// Delegated identifiers fail here by design: their in-force status needs + /// the delegator's revocation verdict, which a single-KEL replay cannot + /// supply — verify against the identity's controller DID instead. + #[error(transparent)] + CurrentKey(#[from] CurrentKeyError), + + /// The signature does not verify under the registry's current key. Either + /// the response was tampered with, signed by a different key (e.g. a stale + /// pre-rotation key or a stolen device key), or bound to other inputs. + #[error("signature does not verify under the registry's current key for {did}: {reason}")] + SignatureInvalid { + /// The DID whose registry key rejected the signature. + did: String, + /// The verification failure, rendered for display. + reason: String, + }, +} + +/// Verify an auth-challenge response **offline** against the registry's +/// in-force key for `did`. +/// +/// The counterpart of [`build_auth_challenge_message`]: rebuilds the exact +/// canonical payload the signer signed, resolves the DID's *current* signing +/// key by replaying its KEL from the local registry (post-rotation key, never +/// a stale inception key), and checks the signature in-process. No network, +/// no auth server — the registry is the only authority consulted. +/// +/// Args: +/// * `registry`: The backend holding the DID's KEL (the trusted floor). +/// * `did`: The signer's `did:keri:` controller DID. +/// * `nonce`: The challenge nonce the verifier issued. +/// * `domain`: The domain the challenge was bound to. +/// * `signature`: The raw signature bytes from the challenge response. +/// +/// Usage: +/// ```ignore +/// let verified = verify_auth_challenge(®istry, &did, &nonce, "auths.dev", &sig)?; +/// println!("alive: {} under {}", verified.did, verified.public_key_hex); +/// ``` +#[cfg(feature = "backend-git")] +pub fn verify_auth_challenge( + registry: &dyn RegistryBackend, + did: &str, + nonce: &str, + domain: &str, + signature: &[u8], +) -> Result { + let message = build_auth_challenge_message(nonce, domain)?; + let (pk_bytes, curve) = resolve_current_public_key(registry, did)?; + let key = auths_keri::KeriPublicKey::from_verkey_bytes(&pk_bytes, curve).map_err(|e| { + // The resolver already parsed this key; a length mismatch here is a + // registry inconsistency, not a caller error. + CurrentKeyError::UnsupportedKey { + did: did.to_string(), + reason: e.to_string(), + } + })?; + key.verify_signature(message.as_bytes(), signature) + .map_err(|reason| AuthChallengeVerifyError::SignatureInvalid { + did: did.to_string(), + reason, + })?; + Ok(VerifiedAuthChallenge { + did: did.to_string(), + public_key_hex: hex::encode(&pk_bytes), + curve, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +233,126 @@ mod tests { let err = build_auth_challenge_message("abc", "").unwrap_err(); assert!(matches!(err, AuthChallengeError::EmptyDomain)); } + + #[cfg(feature = "backend-git")] + mod verify { + use super::*; + use auths_id::testing::fakes::FakeRegistryBackend; + use auths_keri::{ + CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, + VersionString, compute_next_commitment, finalize_icp_event, + }; + use ring::signature::{Ed25519KeyPair, KeyPair}; + + /// A registry holding one identity whose signing key the test controls. + fn registry_with_identity(seed: [u8; 32]) -> (FakeRegistryBackend, String, Ed25519KeyPair) { + let keypair = Ed25519KeyPair::from_seed_unchecked(&seed).unwrap(); + let key = KeriPublicKey::ed25519(keypair.public_key().as_ref()).unwrap(); + let next = KeriPublicKey::ed25519(&[9u8; 32]).unwrap(); + let icp = IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())], + nt: Threshold::Simple(1), + n: vec![compute_next_commitment(&next)], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + }; + let finalized = finalize_icp_event(icp).unwrap(); + let prefix = finalized.i.clone(); + let did = format!("did:keri:{prefix}"); + let registry = FakeRegistryBackend::new(); + registry + .append_event(&prefix, &Event::Icp(finalized)) + .unwrap(); + (registry, did, keypair) + } + + #[test] + fn challenge_signed_by_registry_key_verifies() { + let (registry, did, keypair) = registry_with_identity([7u8; 32]); + let message = build_auth_challenge_message("abc123", "auths.dev").unwrap(); + let signature = keypair.sign(message.as_bytes()); + + let verified = + verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref()) + .unwrap(); + assert_eq!(verified.did, did); + assert_eq!( + verified.public_key_hex, + hex::encode(keypair.public_key().as_ref()) + ); + assert_eq!(verified.curve, auths_crypto::CurveType::Ed25519); + } + + #[test] + fn foreign_key_signature_is_rejected() { + // A different keypair than the one the registry holds — the + // stolen-device / stale-key case. + let (registry, did, _keypair) = registry_with_identity([7u8; 32]); + let thief = Ed25519KeyPair::from_seed_unchecked(&[8u8; 32]).unwrap(); + let message = build_auth_challenge_message("abc123", "auths.dev").unwrap(); + let signature = thief.sign(message.as_bytes()); + + let err = + verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref()) + .unwrap_err(); + assert!(matches!( + err, + AuthChallengeVerifyError::SignatureInvalid { .. } + )); + } + + #[test] + fn nonce_is_bound_into_the_verdict() { + // A valid signature over a DIFFERENT nonce must not verify — the + // replayed-response case. + let (registry, did, keypair) = registry_with_identity([7u8; 32]); + let message = build_auth_challenge_message("old-nonce", "auths.dev").unwrap(); + let signature = keypair.sign(message.as_bytes()); + + let err = verify_auth_challenge( + ®istry, + &did, + "fresh-nonce", + "auths.dev", + signature.as_ref(), + ) + .unwrap_err(); + assert!(matches!( + err, + AuthChallengeVerifyError::SignatureInvalid { .. } + )); + } + + #[test] + fn unknown_did_fails_resolution() { + let registry = FakeRegistryBackend::new(); + let err = verify_auth_challenge( + ®istry, + "did:keri:ENotHere0000000000000000000000000000000000", + "abc123", + "auths.dev", + &[0u8; 64], + ) + .unwrap_err(); + assert!(matches!(err, AuthChallengeVerifyError::CurrentKey(_))); + } + + #[test] + fn empty_nonce_is_refused_before_any_resolution() { + let registry = FakeRegistryBackend::new(); + let err = verify_auth_challenge(®istry, "did:keri:E", "", "auths.dev", &[0u8; 64]) + .unwrap_err(); + assert!(matches!( + err, + AuthChallengeVerifyError::Challenge(AuthChallengeError::EmptyNonce) + )); + } + } } diff --git a/crates/xtask/src/check_verify_path_completeness.rs b/crates/xtask/src/check_verify_path_completeness.rs index b856e2be..fb0aacef 100644 --- a/crates/xtask/src/check_verify_path_completeness.rs +++ b/crates/xtask/src/check_verify_path_completeness.rs @@ -40,6 +40,7 @@ const BANNED_METHODS: &[&str] = &[ const BANNED_PATHS: &[&str] = &[ "crates/auths-verifier/src/", "crates/auths-cli/src/commands/verify_commit.rs", + "crates/auths-cli/src/commands/auth.rs", ]; const EXEMPT_PATHS: &[&str] = &["/tests/", "/testing/", "/fakes/"]; From ef5fb5696d517187064e6b6fc341eeba0d52fabe Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 20:30:48 +0100 Subject: [PATCH 13/32] feat(LTL-R2): host-side apply of device-authored shared-KEL rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auths-sdk identity::shared_rot::apply_shared_kel_rot: decode the held envelope, replay the registry KEL to prior key state, validate SAID/ sequence/chain + signing threshold + prior next-commitment reveals, then append — the host contributes only its registry, never authorship - auths-sdk pairing::lan::wait_for_shared_kel_rot: race-free await/take of the daemon-held rotation (notify interest registered before take) - auths id rotate --from-device: ephemeral LAN session (QR/short code/ --print-uri), receives the co-signed rotation, applies it against prior state, refreshes commit trailers; conflicts with all local- authoring flags — no local key material is read or written - cli lan-pairing feature now enables auths-sdk/lan-pairing (one facade, no daemon re-wrap); sdk lan-pairing pulls tokio for the waiter Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/Cargo.toml | 2 +- .../auths-cli/src/commands/device/pair/lan.rs | 140 ++++++ .../src/commands/device/pair/lan_server.rs | 15 + .../auths-cli/src/commands/device/pair/mod.rs | 3 + crates/auths-cli/src/commands/id/identity.rs | 157 +++++++ crates/auths-sdk/Cargo.toml | 3 +- crates/auths-sdk/src/domains/identity/mod.rs | 2 + .../src/domains/identity/shared_rot.rs | 411 ++++++++++++++++++ crates/auths-sdk/src/pairing/lan.rs | 46 +- crates/auths-sdk/src/pairing/mod.rs | 1 + 10 files changed, 777 insertions(+), 3 deletions(-) create mode 100644 crates/auths-sdk/src/domains/identity/shared_rot.rs diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index fb9e12a5..77f7c4e3 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -84,7 +84,7 @@ tokio-util = { version = "0.7", optional = true } [features] default = ["lan-pairing"] -lan-pairing = ["dep:auths-pairing-daemon", "dep:axum", "dep:tokio-util"] +lan-pairing = ["dep:auths-pairing-daemon", "dep:axum", "dep:tokio-util", "auths-sdk/lan-pairing"] [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal", "process"] } diff --git a/crates/auths-cli/src/commands/device/pair/lan.rs b/crates/auths-cli/src/commands/device/pair/lan.rs index 20f20898..da31794c 100644 --- a/crates/auths-cli/src/commands/device/pair/lan.rs +++ b/crates/auths-cli/src/commands/device/pair/lan.rs @@ -263,6 +263,146 @@ pub async fn handle_initiate_lan( Ok(()) } +/// Receive a phone-authored shared-KEL rotation over an ephemeral LAN session. +/// +/// The host is transport only here — it authors nothing and signs nothing: +/// 1. Start a one-shot LAN session and display the QR / short code. +/// 2. The device scans, binds its signing key (`POST /response`), and +/// submits the co-authored rotation envelope (`POST /shared-kel-rot`), +/// which the daemon signature-gates before holding. +/// 3. Return the held envelope; the caller validates it against the +/// registry's prior key state and appends it +/// (`auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot`). +/// +/// Args: +/// * `now`: Injected clock for session expiry. +/// * `repo_path`: The identity registry whose controller this session serves. +/// * `no_qr` / `print_uri` / `no_mdns`: Display + discovery toggles (same as `pair`). +/// * `expiry_secs`: Session lifetime. +pub async fn handle_receive_shared_rot( + now: chrono::DateTime, + repo_path: &std::path::Path, + no_qr: bool, + print_uri: bool, + no_mdns: bool, + expiry_secs: u64, +) -> Result { + let identity_storage = + auths_sdk::storage::RegistryIdentityStorage::new(repo_path.to_path_buf()); + let controller_did = + auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?; + + let lan_ip = + detect_lan_ip().context("Failed to detect LAN IP. Are you connected to a network?")?; + let expiry = chrono::Duration::seconds(expiry_secs as i64); + + // A rotation-receive session grants no capabilities — the envelope's own + // indexed signatures (and the registry's prior state) are the authority. + let mut session = PairingToken::generate_with_expiry( + now, + controller_did.clone(), + "http://placeholder".to_string(), // replaced below + vec![], + expiry, + ) + .context("Failed to generate session token")?; + + let request = CreateSessionRequest { + session_id: session.token.session_id.clone(), + controller_did: session.token.controller_did.clone(), + ephemeral_pubkey: auths_sdk::pairing::Base64UrlEncoded::from_raw( + session.token.ephemeral_pubkey.clone(), + ), + short_code: session.token.short_code.clone(), + capabilities: session.token.capabilities.clone(), + expires_at: session.token.expires_at.timestamp(), + recovery_target: None, + }; + + let mut server = LanPairingServer::start(request, lan_ip).await?; + let port = server.addr().port(); + let endpoint = format!("http://{}:{}", lan_ip, port); + session.token.endpoint = format!("{}?token={}", endpoint, server.pairing_token()); + + println!(); + println!( + "{}", + style(format!("━━━ {PHONE}Receive Identity Rotation (LAN) ━━━")).bold() + ); + println!(); + println!( + " {} {}", + style("Identity:").dim(), + style(&controller_did).cyan() + ); + println!(); + + let _advertiser = if !no_mdns { + match server.advertise(port, &session.token.short_code, &controller_did) { + Some(Ok(adv)) => Some(adv), + Some(Err(e)) => { + println!(" {} {} {}", WARN, style("mDNS unavailable:").yellow(), e); + None + } + None => None, + } + } else { + None + }; + + if !no_qr { + let options = QrOptions::default(); + let qr = render_qr(&session.token, &options).context("Failed to render QR code")?; + println!("{}", qr); + } + + let sc = &session.token.short_code; + println!(); + println!(" Scan the QR code above, or enter this code on the device:"); + println!(); + println!( + " {}", + style(format!("{}-{}", &sc[..3], &sc[3..])).bold().cyan() + ); + println!(); + if print_uri { + print_token_uri(&session.token); + println!(); + } + + let wait_spinner = create_wait_spinner(&format!("{PHONE}Waiting for the device's rotation...")); + let expiry_duration = Duration::from_secs(expiry_secs); + + let result: Result = async { + server + .wait_for_response(expiry_duration) + .await + .map_err(|e| anyhow::anyhow!("device never connected: {e}"))?; + // The device is bound; the rotation envelope follows on the same + // session. Reuse the full window — the daemon enforces session TTL. + let held = server + .wait_for_shared_kel_rot(expiry_duration) + .await + .ok_or_else(|| { + anyhow::anyhow!( + "the device connected but submitted no rotation before the session expired" + ) + })?; + Ok(held.rot_envelope) + } + .await; + + if let Some(adv) = _advertiser { + adv.shutdown(); + } + match &result { + Ok(_) => wait_spinner.finish_with_message(format!("{CHECK}Rotation received!")), + Err(_) => wait_spinner.finish_and_clear(), + } + server.shutdown(); + result +} + /// Join a LAN pairing session by discovering it via mDNS. pub async fn handle_join_lan( now: chrono::DateTime, diff --git a/crates/auths-cli/src/commands/device/pair/lan_server.rs b/crates/auths-cli/src/commands/device/pair/lan_server.rs index 47381a6c..39509a16 100644 --- a/crates/auths-cli/src/commands/device/pair/lan_server.rs +++ b/crates/auths-cli/src/commands/device/pair/lan_server.rs @@ -128,6 +128,21 @@ impl LanPairingServer { self.handle.wait_for_confirmation(timeout).await } + /// Wait for the device to submit a co-authored shared-KEL rotation + /// (`POST /shared-kel-rot`), then take it for replay. + /// + /// Should be called after `wait_for_response` has bound the device's + /// signing key. The daemon has already verified the envelope's indexed + /// signatures; the caller still validates it against the registry's + /// prior key state before appending + /// (`auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot`). + pub async fn wait_for_shared_kel_rot( + &self, + timeout: Duration, + ) -> Option { + auths_sdk::pairing::lan::wait_for_shared_kel_rot(&self.handle, timeout).await + } + /// Shut the listener down. Consumes `self`. pub fn shutdown(self) { self.cancel.cancel(); diff --git a/crates/auths-cli/src/commands/device/pair/mod.rs b/crates/auths-cli/src/commands/device/pair/mod.rs index a877db32..121ff0b8 100644 --- a/crates/auths-cli/src/commands/device/pair/mod.rs +++ b/crates/auths-cli/src/commands/device/pair/mod.rs @@ -12,6 +12,9 @@ mod lan_server; mod offline; mod online; +#[cfg(feature = "lan-pairing")] +pub use lan::handle_receive_shared_rot; + use std::sync::Arc; use anyhow::Result; diff --git a/crates/auths-cli/src/commands/id/identity.rs b/crates/auths-cli/src/commands/id/identity.rs index 970fbac8..77c78687 100644 --- a/crates/auths-cli/src/commands/id/identity.rs +++ b/crates/auths-cli/src/commands/id/identity.rs @@ -180,6 +180,41 @@ pub enum IdSubcommand { /// `--signing-threshold`. Omit to keep the prior `nt`. #[arg(long)] rotation_threshold: Option, + + /// Receive the rotation from a paired device over LAN instead of + /// authoring it locally. The device builds and co-signs the + /// shared-KEL rotation; this host only validates it against the + /// registry's prior key state and appends it. No local key + /// material is touched. + #[cfg(feature = "lan-pairing")] + #[arg(long, conflicts_with_all = ["alias", "current_key_alias", "next_key_alias", "add_witness", "remove_witness", "witness_threshold", "dry_run"])] + from_device: bool, + + /// Don't display the QR code for `--from-device` (only the short code). + #[cfg(feature = "lan-pairing")] + #[arg(long = "no-qr", requires = "from_device", hide_short_help = true)] + no_qr: bool, + + /// Print the session token URI for `--from-device` so scripts can + /// deliver it to the device out of band. + #[cfg(feature = "lan-pairing")] + #[arg(long = "print-uri", requires = "from_device", hide_short_help = true)] + print_uri: bool, + + /// Disable mDNS advertisement for `--from-device`. + #[cfg(feature = "lan-pairing")] + #[arg(long = "no-mdns", requires = "from_device", hide_short_help = true)] + no_mdns: bool, + + /// Session lifetime in seconds for `--from-device`. + #[cfg(feature = "lan-pairing")] + #[arg( + long = "timeout", + requires = "from_device", + default_value = "300", + hide_short_help = true + )] + timeout: u64, }, /// Expand a single-device identity into multi-device via one rotation. @@ -262,6 +297,71 @@ pub enum IdSubcommand { Agent(super::agent::AgentCommand), } +/// `auths id rotate --from-device`: apply a rotation a paired device +/// authored, instead of authoring one locally. +/// +/// The device builds and co-signs the shared-KEL rotation (proving +/// controllership by revealing a pre-committed key); this host receives the +/// envelope over an ephemeral LAN session, validates it against the +/// registry's prior key state, and appends it. No local key material is +/// read or written — the host contributes only its registry. +#[cfg(feature = "lan-pairing")] +fn handle_rotate_from_device( + repo_path: &std::path::Path, + no_qr: bool, + print_uri: bool, + no_mdns: bool, + timeout: u64, + now: chrono::DateTime, + env_config: &EnvironmentConfig, +) -> Result<()> { + use auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot; + + let rt = tokio::runtime::Runtime::new()?; + let envelope = rt.block_on(crate::commands::device::pair::handle_receive_shared_rot( + now, repo_path, no_qr, print_uri, no_mdns, timeout, + ))?; + + let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf()); + let controller_did = + auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?; + let prefix_str = controller_did + .strip_prefix("did:keri:") + .ok_or_else(|| anyhow!("invalid DID format, expected 'did:keri:': {controller_did}"))?; + let prefix = auths_keri::Prefix::new_unchecked(prefix_str.to_string()); + + let ctx = crate::factories::storage::build_auths_context(repo_path, env_config, None)?; + let applied = apply_shared_kel_rot(&envelope, &prefix, ctx.registry.as_ref()) + .with_context(|| "Failed to apply the device-authored rotation")?; + + if is_json_mode() { + JsonResponse::success( + "id rotate", + &serde_json::json!({ + "from_device": true, + "controller_did": controller_did, + "sequence": applied.sequence.to_string(), + "event_said": applied.said.as_str(), + "controller_count": applied.controller_count, + }), + ) + .print() + .map_err(anyhow::Error::from)?; + } else { + println!("\n✅ Device-authored rotation applied."); + println!(" Identity: {}", controller_did); + println!(" KEL advanced to seq {}", applied.sequence); + println!(" Controllers in force: {}", applied.controller_count); + } + + // The rotation advanced the KEL — restamp the commit-trailers file so + // hook-stamped commits carry the new anchor position. + if let Err(e) = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&ctx, repo_path) { + println!("⚠️ Could not refresh commit trailers ({e}); run `auths doctor`."); + } + Ok(()) +} + fn display_dry_run_rotate( repo_path: &std::path::Path, current_alias: Option<&str>, @@ -524,7 +624,23 @@ pub fn handle_id( remove_device, signing_threshold, rotation_threshold, + #[cfg(feature = "lan-pairing")] + from_device, + #[cfg(feature = "lan-pairing")] + no_qr, + #[cfg(feature = "lan-pairing")] + print_uri, + #[cfg(feature = "lan-pairing")] + no_mdns, + #[cfg(feature = "lan-pairing")] + timeout, } => { + #[cfg(feature = "lan-pairing")] + if from_device { + return handle_rotate_from_device( + &repo_path, no_qr, print_uri, no_mdns, timeout, now, env_config, + ); + } if !add_device.is_empty() || !remove_device.is_empty() || signing_threshold.is_some() @@ -1017,3 +1133,44 @@ pub fn handle_id( } } } + +#[cfg(all(test, feature = "lan-pairing"))] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct Harness { + #[command(subcommand)] + sub: IdSubcommand, + } + + #[test] + fn rotate_from_device_parses() { + let h = Harness::parse_from(["id", "rotate", "--from-device"]); + match h.sub { + IdSubcommand::Rotate { from_device, .. } => assert!(from_device), + other => panic!("expected Rotate, got {other:?}"), + } + } + + #[test] + fn rotate_from_device_conflicts_with_local_authoring_flags() { + // The device authors the rotation — naming a local key is a + // contradiction and must be refused at parse time. + assert!( + Harness::try_parse_from(["id", "rotate", "--from-device", "--alias", "main"]).is_err() + ); + assert!(Harness::try_parse_from(["id", "rotate", "--from-device", "--dry-run"]).is_err()); + } + + #[test] + fn rotate_session_toggles_require_from_device() { + assert!(Harness::try_parse_from(["id", "rotate", "--no-qr"]).is_err()); + assert!(Harness::try_parse_from(["id", "rotate", "--print-uri"]).is_err()); + assert!( + Harness::try_parse_from(["id", "rotate", "--from-device", "--no-qr", "--print-uri"]) + .is_ok() + ); + } +} diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index b7423fc7..d557ba34 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -49,11 +49,12 @@ uuid = { workspace = true, features = ["serde", "v4"] } dashmap = "6" reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json"], optional = true } auths-pairing-daemon = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } [features] test-utils = ["auths-id/test-utils"] mcp = ["dep:reqwest"] -lan-pairing = ["dep:auths-pairing-daemon"] +lan-pairing = ["dep:auths-pairing-daemon", "dep:tokio"] backend-git = ["dep:auths-storage", "auths-storage/backend-git"] witness-server = ["auths-core/witness-server"] witness-client = ["auths-id/witness-client"] diff --git a/crates/auths-sdk/src/domains/identity/mod.rs b/crates/auths-sdk/src/domains/identity/mod.rs index 42fc4cd4..c6655ef6 100644 --- a/crates/auths-sdk/src/domains/identity/mod.rs +++ b/crates/auths-sdk/src/domains/identity/mod.rs @@ -14,6 +14,8 @@ pub mod registration; pub mod rotation; /// Identity services pub mod service; +/// Apply a co-authored shared-KEL rotation received from a paired device +pub mod shared_rot; /// Identity types and configuration pub mod types; diff --git a/crates/auths-sdk/src/domains/identity/shared_rot.rs b/crates/auths-sdk/src/domains/identity/shared_rot.rs new file mode 100644 index 00000000..5cafcdaf --- /dev/null +++ b/crates/auths-sdk/src/domains/identity/shared_rot.rs @@ -0,0 +1,411 @@ +//! Apply a co-authored shared-KEL rotation received from a paired device. +//! +//! The pairing daemon verifies a rotation envelope's indexed signatures +//! against the rotation's own key list at its HTTP boundary, then holds it +//! for the embedding host. This module is the host's side of that handoff: +//! replay the registry's KEL to the identity's prior key state, validate +//! the rotation against that state (SAID, sequence, chain linkage, signing +//! threshold AND the prior next-commitment reveals), and append it to the +//! KEL. The device that authored the rotation never touches the registry; +//! the registry never accepts an event it has not replayed against its own +//! prior state. + +use std::ops::ControlFlow; + +use auths_id::keri::validate_kel; +use auths_id::ports::registry::RegistryBackend; +use auths_id::storage::registry::backend::RegistryError; +use auths_keri::{ + Event, Prefix, Said, SignedEvent, decode_signed_rot, parse_attachment, validate_for_append, + validate_signed_event, +}; + +/// Errors from applying a received shared-KEL rotation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SharedRotError { + /// The wire envelope could not be decoded into a rotation + attachment. + #[error("rotation envelope invalid: {0}")] + InvalidEnvelope(String), + + /// The rotation names a different identity than this host serves. + #[error("rotation targets '{got}' but this host serves '{expected}'")] + PrefixMismatch { + /// The prefix the host expected (its controller identity). + expected: String, + /// The prefix the rotation actually names. + got: String, + }, + + /// The registry holds no KEL for the rotation's identity. + #[error("identity not found in the registry: {prefix}")] + UnknownIdentity { + /// The prefix that has no KEL. + prefix: String, + }, + + /// Replaying the registry's existing KEL failed (corrupt local state). + #[error("registry KEL replay failed: {0}")] + KelReplayFailed(String), + + /// The rotation does not validate against the registry's prior key + /// state (bad chain linkage, stale sequence, unsatisfied signing + /// threshold, or unrevealed prior commitment). + #[error("rotation rejected against prior key state: {0}")] + RejectedByPriorState(String), + + /// The validated rotation could not be written to the KEL. + #[error("KEL append failed: {0}")] + AppendFailed(String), +} + +/// A rotation that validated against the registry's prior key state and +/// is now appended to the KEL. Constructed only by +/// [`apply_shared_kel_rot`] — holding one means the registry's KEL has +/// already advanced. +#[derive(Debug, Clone)] +pub struct AppliedSharedKelRot { + /// The identity whose KEL advanced. + pub prefix: Prefix, + /// The new tip sequence number. + pub sequence: u128, + /// SAID of the appended rotation event. + pub said: Said, + /// Number of controller keys in force after the rotation. + pub controller_count: usize, +} + +/// Validate a received shared-KEL rotation against the registry's prior +/// key state and append it. +/// +/// The envelope is the single-string wire form a paired device emits +/// (`auths_keri::encode_signed_rot`) and the pairing daemon holds for the +/// host. The daemon already verified the indexed signatures against the +/// rotation's own key list; this function supplies what only the host can: +/// the registry's prior state. It replays the identity's KEL, checks the +/// rotation's SAID, sequence, and chain linkage against the replayed tip, +/// re-verifies the indexed signatures against BOTH the new signing +/// threshold and the prior next-commitment reveals, and only then appends. +/// +/// Args: +/// * `envelope`: base64url wire envelope of the signed rotation. +/// * `expected_prefix`: the identity this host serves — a rotation naming +/// any other identity is refused before the registry is read. +/// * `registry`: the registry backend holding the identity's KEL. +/// +/// Usage: +/// ```ignore +/// let applied = apply_shared_kel_rot(&held.rot_envelope, &prefix, registry.as_ref())?; +/// println!("KEL advanced to seq {}", applied.sequence); +/// ``` +pub fn apply_shared_kel_rot( + envelope: &str, + expected_prefix: &Prefix, + registry: &(dyn RegistryBackend + Send + Sync), +) -> Result { + let (rot, attachment) = + decode_signed_rot(envelope).map_err(|e| SharedRotError::InvalidEnvelope(e.to_string()))?; + let signatures = parse_attachment(&attachment) + .map_err(|e| SharedRotError::InvalidEnvelope(e.to_string()))?; + + if rot.i != *expected_prefix { + return Err(SharedRotError::PrefixMismatch { + expected: expected_prefix.as_str().to_string(), + got: rot.i.as_str().to_string(), + }); + } + + // Replay the registry's own KEL — prior state is derived from the + // stored events, never trusted from the wire. + let mut events: Vec = Vec::new(); + registry + .visit_events(expected_prefix, 0, &mut |e| { + events.push(e.clone()); + ControlFlow::Continue(()) + }) + .map_err(|e| match e { + RegistryError::NotFound { .. } => SharedRotError::UnknownIdentity { + prefix: expected_prefix.as_str().to_string(), + }, + other => SharedRotError::KelReplayFailed(other.to_string()), + })?; + if events.is_empty() { + return Err(SharedRotError::UnknownIdentity { + prefix: expected_prefix.as_str().to_string(), + }); + } + let state = + validate_kel(&events).map_err(|e| SharedRotError::KelReplayFailed(e.to_string()))?; + + // Structural gate: SAID, sequence, chain linkage against the replayed tip. + let event = Event::Rot(rot); + validate_for_append(&event, &state) + .map_err(|e| SharedRotError::RejectedByPriorState(e.to_string()))?; + + // Cryptographic gate: indexed signatures must satisfy the rotation's own + // signing threshold AND the prior establishment event's next-commitment + // threshold (each verifying key must reveal a pre-committed `n[]` slot). + let signed = SignedEvent::new(event, signatures); + validate_signed_event(&signed, Some(&state)) + .map_err(|e| SharedRotError::RejectedByPriorState(e.to_string()))?; + + registry + .append_signed_event(expected_prefix, &signed.event, &attachment) + .map_err(|e| SharedRotError::AppendFailed(e.to_string()))?; + + let Event::Rot(rot) = signed.event else { + // The event was constructed as a Rot above and never reassigned. + return Err(SharedRotError::AppendFailed( + "event variant changed during validation".to_string(), + )); + }; + Ok(AppliedSharedKelRot { + sequence: rot.s.value(), + controller_count: rot.k.len(), + said: rot.d, + prefix: rot.i, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::ops::ControlFlow; + + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + + use auths_id::testing::fakes::FakeRegistryBackend; + use auths_keri::{ + CesrKey, IcpEvent, IcpEventInit, IndexedSignature, KeriPublicKey, KeriSequence, RotEvent, + RotEventInit, Threshold, VersionString, compute_next_commitment, encode_signed_rot, + finalize_icp_event, finalize_rot_event, serialize_attachment, serialize_for_signing, + }; + + /// Deterministic P-256 key: any small nonzero scalar is valid. + fn p256_key(seed: u8) -> (SigningKey, Vec) { + let mut bytes = [0u8; 32]; + bytes[31] = seed; + let sk = SigningKey::from_slice(&bytes).unwrap(); + let compressed = sk + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec(); + (sk, compressed) + } + + fn verkey(compressed: &[u8]) -> KeriPublicKey { + KeriPublicKey::P256 { + key: compressed.try_into().unwrap(), + transferable: true, + } + } + + fn qb64(compressed: &[u8]) -> CesrKey { + CesrKey::new_unchecked(verkey(compressed).to_qb64().unwrap()) + } + + fn commitment(compressed: &[u8]) -> Said { + compute_next_commitment(&verkey(compressed)) + } + + /// A registry holding a real 2-controller inception: + /// slot 0 = mac, slot 1 = phone; `n` pre-commits both next keys. + /// Returns (registry, icp, phone_next_sk, phone_next_pub). + fn registry_with_shared_kel() -> (FakeRegistryBackend, IcpEvent, SigningKey, Vec) { + let (_mac_sk, mac_pub) = p256_key(1); + let (_phone_sk, phone_pub) = p256_key(2); + let (_mac_next_sk, mac_next_pub) = p256_key(3); + let (phone_next_sk, phone_next_pub) = p256_key(4); + + let icp = finalize_icp_event(IcpEvent::new(IcpEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::new_unchecked(String::new()), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![qb64(&mac_pub), qb64(&phone_pub)], + nt: Threshold::Simple(1), + n: vec![commitment(&mac_next_pub), commitment(&phone_next_pub)], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + })) + .unwrap(); + + let registry = FakeRegistryBackend::new(); + registry + .append_signed_event(&icp.i.clone(), &Event::Icp(icp.clone()), &[]) + .unwrap(); + (registry, icp, phone_next_sk, phone_next_pub) + } + + /// The phone's swap rotation: replaces the mac slot, rotates the phone + /// slot to its revealed pre-committed key, signed only by that key. + fn phone_swap_envelope( + icp: &IcpEvent, + phone_next_sk: &SigningKey, + phone_next_pub: &[u8], + ) -> String { + let (_new_mac_sk, new_mac_pub) = p256_key(5); + let (_new_mac_next_sk, new_mac_next_pub) = p256_key(6); + let (_phone_new_next_sk, phone_new_next_pub) = p256_key(7); + + let rot = finalize_rot_event(RotEvent::new(RotEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: icp.i.clone(), + s: KeriSequence::new(1), + p: icp.d.clone(), + kt: Threshold::Simple(1), + k: vec![qb64(&new_mac_pub), qb64(phone_next_pub)], + nt: Threshold::Simple(1), + n: vec![ + commitment(&new_mac_next_pub), + commitment(&phone_new_next_pub), + ], + bt: Threshold::Simple(0), + br: vec![], + ba: vec![], + c: vec![], + a: vec![], + })) + .unwrap(); + + let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); + let sig: Signature = phone_next_sk.sign(&canonical); + let raw: [u8; 64] = sig.to_bytes().into(); + let attachment = serialize_attachment(&[IndexedSignature { + index: 1, + prior_index: None, + sig: raw.to_vec(), + }]) + .unwrap(); + encode_signed_rot(&rot, &attachment).unwrap() + } + + fn kel_len(registry: &FakeRegistryBackend, prefix: &Prefix) -> usize { + let mut n = 0usize; + registry + .visit_events(prefix, 0, &mut |_| { + n += 1; + ControlFlow::Continue(()) + }) + .unwrap(); + n + } + + #[test] + fn phone_authored_swap_is_validated_and_appended() { + let (registry, icp, phone_next_sk, phone_next_pub) = registry_with_shared_kel(); + let envelope = phone_swap_envelope(&icp, &phone_next_sk, &phone_next_pub); + + let applied = apply_shared_kel_rot(&envelope, &icp.i, ®istry).unwrap(); + assert_eq!(applied.sequence, 1); + assert_eq!( + applied.controller_count, 2, + "swap preserves controller count" + ); + assert_eq!(applied.prefix, icp.i); + + assert_eq!( + kel_len(®istry, &icp.i), + 2, + "KEL advanced by exactly one event" + ); + let stored = registry.get_attachment(&icp.i, 1).unwrap(); + assert!( + stored.is_some_and(|a| !a.is_empty()), + "the rot's signature attachment must be stored for later authentication" + ); + } + + #[test] + fn rotation_for_another_identity_is_refused_before_any_registry_read() { + let (registry, icp, phone_next_sk, phone_next_pub) = registry_with_shared_kel(); + let envelope = phone_swap_envelope(&icp, &phone_next_sk, &phone_next_pub); + + let other = Prefix::new_unchecked("EOTHERIDENTITYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".into()); + let err = apply_shared_kel_rot(&envelope, &other, ®istry).unwrap_err(); + assert!( + matches!(err, SharedRotError::PrefixMismatch { .. }), + "got: {err}" + ); + assert_eq!(kel_len(®istry, &icp.i), 1, "registry untouched"); + } + + #[test] + fn forged_signature_is_rejected_and_nothing_is_appended() { + let (registry, icp, phone_next_sk, phone_next_pub) = registry_with_shared_kel(); + let envelope = phone_swap_envelope(&icp, &phone_next_sk, &phone_next_pub); + + // Re-sign the same rot with a key that reveals NO prior commitment. + let (rot, _attachment) = decode_signed_rot(&envelope).unwrap(); + let (attacker_sk, _attacker_pub) = p256_key(9); + let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); + let sig: Signature = attacker_sk.sign(&canonical); + let raw: [u8; 64] = sig.to_bytes().into(); + let forged_attachment = serialize_attachment(&[IndexedSignature { + index: 1, + prior_index: None, + sig: raw.to_vec(), + }]) + .unwrap(); + let forged = encode_signed_rot(&rot, &forged_attachment).unwrap(); + + let err = apply_shared_kel_rot(&forged, &icp.i, ®istry).unwrap_err(); + assert!( + matches!(err, SharedRotError::RejectedByPriorState(_)), + "got: {err}" + ); + assert_eq!( + kel_len(®istry, &icp.i), + 1, + "a rejected rotation must never land" + ); + } + + #[test] + fn replayed_rotation_is_stale_against_the_advanced_kel() { + let (registry, icp, phone_next_sk, phone_next_pub) = registry_with_shared_kel(); + let envelope = phone_swap_envelope(&icp, &phone_next_sk, &phone_next_pub); + + apply_shared_kel_rot(&envelope, &icp.i, ®istry).unwrap(); + let err = apply_shared_kel_rot(&envelope, &icp.i, ®istry).unwrap_err(); + assert!( + matches!(err, SharedRotError::RejectedByPriorState(_)), + "got: {err}" + ); + assert_eq!( + kel_len(®istry, &icp.i), + 2, + "replay must not double-append" + ); + } + + #[test] + fn unknown_identity_is_a_typed_error() { + let (_registry, icp, phone_next_sk, phone_next_pub) = registry_with_shared_kel(); + let envelope = phone_swap_envelope(&icp, &phone_next_sk, &phone_next_pub); + + let empty = FakeRegistryBackend::new(); + let err = apply_shared_kel_rot(&envelope, &icp.i, &empty).unwrap_err(); + assert!( + matches!(err, SharedRotError::UnknownIdentity { .. }), + "got: {err}" + ); + } + + #[test] + fn garbage_envelope_is_invalid_not_a_panic() { + let registry = FakeRegistryBackend::new(); + let prefix = Prefix::new_unchecked("EXXXX".into()); + let err = apply_shared_kel_rot("not-an-envelope", &prefix, ®istry).unwrap_err(); + assert!( + matches!(err, SharedRotError::InvalidEnvelope(_)), + "got: {err}" + ); + } +} diff --git a/crates/auths-sdk/src/pairing/lan.rs b/crates/auths-sdk/src/pairing/lan.rs index fbfd86eb..8d9c96b8 100644 --- a/crates/auths-sdk/src/pairing/lan.rs +++ b/crates/auths-sdk/src/pairing/lan.rs @@ -5,7 +5,9 @@ pub use auths_pairing_daemon::{PairingDaemon, PairingDaemonBuilder, PairingDaemonHandle}; -use auths_core::pairing::types::CreateSessionRequest; +use std::time::Duration; + +use auths_core::pairing::types::{CreateSessionRequest, SubmitSharedKelRotRequest}; use super::PairingError; @@ -44,3 +46,45 @@ pub fn create_lan_pairing_daemon( .build(session) .map_err(|e| PairingError::DaemonError(e.to_string())) } + +/// Wait for (and take) a co-authored shared-KEL rotation received by the +/// daemon during this session. +/// +/// The daemon verifies the envelope's indexed signatures at its HTTP +/// boundary and holds at most one rotation per session; this awaits +/// `shared_kel_rot_notify` and takes it for the host to replay against the +/// registry's prior key state +/// (`crate::domains::identity::shared_rot::apply_shared_kel_rot`). +/// +/// Interest in the notifier is registered BEFORE the fast-path take, so a +/// rotation landing between the two cannot be missed. Returns `None` on +/// timeout. +/// +/// Args: +/// * `handle`: The daemon handle for the live session. +/// * `timeout`: Maximum time to wait for the device to submit a rotation. +/// +/// Usage: +/// ```ignore +/// if let Some(held) = wait_for_shared_kel_rot(&handle, Duration::from_secs(120)).await { +/// let applied = apply_shared_kel_rot(&held.rot_envelope, &prefix, registry.as_ref())?; +/// } +/// ``` +pub async fn wait_for_shared_kel_rot( + handle: &PairingDaemonHandle, + timeout: Duration, +) -> Option { + let state = handle.state(); + let notify = state.shared_kel_rot_notify(); + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + if let Some(rot) = state.take_shared_kel_rot().await { + return Some(rot); + } + match tokio::time::timeout(timeout, notified).await { + Ok(()) => state.take_shared_kel_rot().await, + Err(_) => None, + } +} diff --git a/crates/auths-sdk/src/pairing/mod.rs b/crates/auths-sdk/src/pairing/mod.rs index 4847a376..11a55824 100644 --- a/crates/auths-sdk/src/pairing/mod.rs +++ b/crates/auths-sdk/src/pairing/mod.rs @@ -17,6 +17,7 @@ pub use delegation::{ // Re-exports of pairing types from auths-core for CLI consumption pub use auths_core::pairing::types::{ Base64UrlEncoded, CreateSessionRequest, SubmitConfirmationRequest, SubmitResponseRequest, + SubmitSharedKelRotRequest, }; pub use auths_core::pairing::{ PairingResponse, PairingSession, PairingToken, QrOptions, normalize_short_code, render_qr, From 9c7108af6bfad8238383e56e63687f3c5b364887 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 21:09:51 +0100 Subject: [PATCH 14/32] =?UTF-8?q?feat(PWNTS-1b):=20KEL-anchored=20OIDC-sub?= =?UTF-8?q?ject=20policy=20=E2=80=94=20org=20anchor-oidc-policy=20writer?= =?UTF-8?q?=20+=20verify=20--oidc-policy-did=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC-subject policy's source of truth moves from a verifier-pinned file to the org's witnessed log: `auths org anchor-oidc-policy` parses the policy (never anchors an invalid one), stores the source as a content-addressed blob, and seals its SHA-256 digest on the org KEL (an oidcpolicy: digest seal, distinct from every existing marker). `auths artifact verify --oidc-policy-did ` resolves the latest anchored policy from the local registry, refuses digest mismatches (tampered blob = verdict, exit 1), and JOINs it against the attestation's signed OIDC binding. Rotation is an auditable KEL event — the latest anchored policy wins. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/artifact/mod.rs | 45 ++++ .../auths-cli/src/commands/artifact/verify.rs | 71 ++++++- crates/auths-cli/src/commands/org.rs | 50 ++++- .../auths-cli/src/commands/unified_verify.rs | 1 + crates/auths-id/src/keri/delegation.rs | 71 ++++++- crates/auths-sdk/src/domains/org/error.rs | 13 ++ crates/auths-sdk/src/domains/org/mod.rs | 5 + .../auths-sdk/src/domains/org/oidc_policy.rs | 195 ++++++++++++++++++ crates/auths-sdk/src/workflows/org.rs | 3 + crates/auths-sdk/tests/cases/mod.rs | 1 + .../auths-sdk/tests/cases/org_oidc_policy.rs | 186 +++++++++++++++++ 11 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 crates/auths-sdk/src/domains/org/oidc_policy.rs create mode 100644 crates/auths-sdk/tests/cases/org_oidc_policy.rs diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index a14c088b..b271732b 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -232,6 +232,13 @@ pub enum ArtifactSubcommand { /// Fail-closed: a missing binding or any mismatch fails verification. #[arg(long, value_name = "POLICY-FILE")] oidc_policy: Option, + + /// Resolve the OIDC-subject policy from the org's KEL instead of a + /// pinned file: reads the latest policy digest the org anchored + /// (`auths org anchor-oidc-policy`) from the local registry and refuses + /// a digest mismatch — the witnessed log is the source of truth. + #[arg(long, value_name = "ORG-DID", conflicts_with = "oidc_policy")] + oidc_policy_did: Option, }, } @@ -621,6 +628,7 @@ pub fn handle_artifact( signed_at, json, oidc_policy, + oidc_policy_did, } => { if offline { return verify::handle_offline_verify( @@ -642,6 +650,7 @@ pub fn handle_artifact( witness_threshold, verify_commit, oidc_policy, + oidc_policy_did, }, )) } @@ -756,6 +765,42 @@ mod tests { } } + #[test] + fn verify_oidc_policy_did_conflicts_with_policy_file() { + // A pinned file and a KEL-resolved policy are two trust postures — + // exactly one may be chosen. + let err = Cli::try_parse_from([ + "test", + "verify", + "a.tar.gz", + "--oidc-policy", + "p.json", + "--oidc-policy-did", + "did:keri:EOrg", + ]); + assert!(err.is_err(), "the two policy sources must be exclusive"); + + let ok = Cli::try_parse_from([ + "test", + "verify", + "a.tar.gz", + "--oidc-policy-did", + "did:keri:EOrg", + ]) + .unwrap(); + match ok.command { + ArtifactSubcommand::Verify { + oidc_policy, + oidc_policy_did, + .. + } => { + assert!(oidc_policy.is_none()); + assert_eq!(oidc_policy_did.as_deref(), Some("did:keri:EOrg")); + } + _ => panic!("expected Verify"), + } + } + #[test] fn publish_forwards_signing_flags() { let cli = Cli::try_parse_from([ diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index cb34c3f0..34d3bf26 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -59,6 +59,9 @@ pub struct VerifyArtifactArgs { pub verify_commit: bool, /// OIDC-subject policy to JOIN against the signed OIDC binding. pub oidc_policy: Option, + /// Org `did:keri:` whose KEL-anchored OIDC-subject policy to resolve and + /// JOIN — the witnessed log as the policy's source of truth. + pub oidc_policy_did: Option, } /// Execute the `artifact verify` command. @@ -73,15 +76,17 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> witness_threshold, verify_commit, oidc_policy, + oidc_policy_did, } = args; let witness_keys = &witness_keys; let file_str = file.to_string_lossy().to_string(); - // 0. Parse the OIDC-subject policy up front: an unreadable or malformed - // policy is a "could not attempt" (exit 2), not a verdict. - let oidc_policy = match &oidc_policy { - None => None, - Some(path) => { + // 0. Resolve the OIDC-subject policy up front: an unreadable or malformed + // policy is a "could not attempt" (exit 2), not a verdict — except a + // KEL-anchored blob that fails its digest check, which IS a verdict (1). + let oidc_policy = match (&oidc_policy, &oidc_policy_did) { + (None, None) => None, + (Some(path), _) => { let raw = match fs::read_to_string(path) { Ok(r) => r, Err(e) => { @@ -99,6 +104,12 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> } } } + (None, Some(org_did)) => match resolve_anchored_oidc_policy(org_did) { + Ok(p) => Some(p), + Err((exit_code, message)) => { + return output_error(&file_str, exit_code, &message); + } + }, }; // 1. Locate and load signature file @@ -418,6 +429,56 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> ) } +/// Resolve the org's KEL-anchored OIDC-subject policy from the local registry. +/// +/// The org seals the policy digest on its KEL (`auths org anchor-oidc-policy`); +/// this reads the latest seal, loads the content-addressed blob, and refuses a +/// digest mismatch — the verifier trusts the org's witnessed log, not a file +/// someone handed them. Errors carry the verify exit-code contract: a tampered +/// blob is a verdict (1); everything else is could-not-attempt (2). +fn resolve_anchored_oidc_policy(org_did: &str) -> Result { + use auths_sdk::workflows::org::load_org_oidc_policy; + + let Some(prefix) = org_did.strip_prefix("did:keri:") else { + return Err(( + 2, + format!("--oidc-policy-did requires a did:keri: identifier, got '{org_did}'"), + )); + }; + let auths_home = auths_sdk::paths::auths_home() + .map_err(|e| (2, format!("Could not locate ~/.auths: {e}")))?; + let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked( + auths_sdk::storage::RegistryConfig::single_tenant(&auths_home), + ); + let org_prefix = auths_verifier::Prefix::new_unchecked(prefix.to_string()); + + match load_org_oidc_policy(®istry, &org_prefix) { + Ok(Some(loaded)) => { + if !is_json_mode() { + eprintln!( + " OIDC policy resolved from the org KEL (digest {})", + loaded.policy_digest + ); + } + Ok(loaded.policy) + } + Ok(None) => Err(( + 2, + format!( + "organization {org_did} has no OIDC-subject policy anchored on its KEL \ + — anchor one with `auths org anchor-oidc-policy`" + ), + )), + Err(e @ auths_sdk::domains::org::error::OrgError::PolicyIntegrity { .. }) => { + Err((1, format!("OIDC policy resolution failed: {e}"))) + } + Err(e) => Err(( + 2, + format!("Failed to resolve the anchored OIDC policy: {e}"), + )), + } +} + /// Resolve identity public key from bundle or from the attestation's issuer DID. fn resolve_identity_key( identity_bundle: &Option, diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index adf53b10..df986594 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -23,7 +23,8 @@ use auths_sdk::workflows::commit_trust::commit_signer_trailers; use auths_sdk::workflows::org::{ AuthorityAtSigning, Role, add_member, build_org_bundle, classify_authority_at_signing, create_org, fleet_metrics, list_members, list_offboarding_records, load_offboarding_record, - load_org_policy, member_role_order, revoke_member, set_org_policy, walk_delegation_chain, + load_org_policy, member_role_order, revoke_member, set_org_oidc_policy, set_org_policy, + walk_delegation_chain, }; use crate::factories::storage::build_auths_context; @@ -266,6 +267,23 @@ pub enum OrgSubcommand { action: PolicyAction, }, + /// Anchor the org's OIDC-subject policy on its KEL (who may sign keylessly). + /// Verifiers resolve it with `auths artifact verify --oidc-policy-did ` + /// — the witnessed log is the policy's source of truth, not a pinned file. + AnchorOidcPolicy { + /// Organization identity ID + #[arg(long)] + org: String, + + /// Path to the OIDC-subject policy JSON (issuer + repository, optional workflow_ref) + #[arg(long)] + file: PathBuf, + + /// Org signing key alias (defaults to the org slug alias) + #[arg(long)] + key: Option, + }, + /// Show fleet governance metrics for an organization Metrics { /// Organization identity ID @@ -1120,6 +1138,36 @@ pub fn handle_org( } }, + OrgSubcommand::AnchorOidcPolicy { org, file, key } => { + let org_prefix = + Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); + let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org))); + let policy_json = fs::read(&file) + .with_context(|| format!("Failed to read OIDC policy file {file:?}"))?; + + let sdk_ctx = build_auths_context( + &repo_path, + &ctx.env_config, + Some(passphrase_provider.clone()), + )?; + let result = set_org_oidc_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json) + .context("Failed to anchor OIDC-subject policy")?; + + println!("✅ OIDC-subject policy anchored on the org KEL:"); + println!(" Org: {}", result.org_did); + println!(" Policy digest: {}", result.policy_digest); + println!( + " Trusts: {} via issuer {}", + result.policy.repository(), + result.policy.issuer() + ); + println!( + "\nVerifiers resolve it from the witnessed log:\n auths artifact verify --oidc-policy-did {}", + result.org_did + ); + Ok(()) + } + OrgSubcommand::Metrics { org, json } => { let org_prefix = Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index 6bcf5f47..e432d891 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -199,6 +199,7 @@ pub async fn handle_verify_unified( witness_threshold: cmd.witness_threshold, verify_commit: false, oidc_policy: None, + oidc_policy_did: None, }, ) .await diff --git a/crates/auths-id/src/keri/delegation.rs b/crates/auths-id/src/keri/delegation.rs index 38cd8a9b..d4d8dcfa 100644 --- a/crates/auths-id/src/keri/delegation.rs +++ b/crates/auths-id/src/keri/delegation.rs @@ -576,11 +576,80 @@ pub fn mark_org_policy( /// Args: /// * `events`: The org's KEL events (oldest first; the latest anchored policy wins). pub fn read_org_policy_hash(events: &[Event]) -> Option { + read_latest_marked_digest(events, ORG_POLICY_MARKER) +} + +/// Marker prefix for an org OIDC-subject-policy `Seal::Digest` value +/// (`oidcpolicy:{source_digest_hex}`). +/// +/// A different document from the `policy:` authorization policy: this one states +/// WHICH OIDC workload identity may sign keylessly. The marker is distinct from +/// `policy:`, the `agent:` role marker, the `agentscope:` scope seal, and the +/// bare-prefix revocation digest, so no scanner misreads it. +const ORG_OIDC_POLICY_MARKER: &str = "oidcpolicy:"; + +/// Anchor an OIDC-subject-policy seal on the org KEL: a +/// `Seal::Digest{d: "oidcpolicy:{digest}"}` binding the KEL to the digest of the +/// org's OIDC-subject policy source. +/// +/// Only the digest rides the KEL — the policy bytes live in a content-addressed +/// blob (the registry credential store) — so the append-only log stays lean and +/// the binding is tamper-evident (loaders recompute the blob's digest and +/// compare). Single-author: the org's current key signs the `ixn`. +/// Latest-anchored wins. +/// +/// Args: +/// * `backend`: Registry backend holding the org KEL. +/// * `org_prefix` / `org_alias` / `org_curve`: the org authoring the seal. +/// * `source_digest_hex`: Lowercase-hex digest of the policy's source bytes. +/// * `passphrase_provider` / `keychain`: org key custody. +/// +/// Usage: +/// ```ignore +/// mark_org_oidc_policy(&*backend, &org_prefix, &org_alias, curve, &digest_hex, &provider, &keychain)?; +/// ``` +#[allow(clippy::too_many_arguments)] +pub fn mark_org_oidc_policy( + backend: &(dyn RegistryBackend + Send + Sync), + org_prefix: &Prefix, + org_alias: &KeyAlias, + org_curve: CurveType, + source_digest_hex: &str, + passphrase_provider: &dyn PassphraseProvider, + keychain: &(dyn KeyStorage + Send + Sync), +) -> Result<(), InitError> { + author_root_anchor_ixn( + backend, + org_prefix, + org_alias, + org_curve, + vec![Seal::Digest { + d: Said::new_unchecked(format!("{ORG_OIDC_POLICY_MARKER}{source_digest_hex}")), + }], + passphrase_provider, + keychain, + ) + .map(|_| ()) +} + +/// Read the latest OIDC-subject-policy digest anchored on a KEL slice, or `None` +/// if the org never anchored one. Pure — the inverse of +/// [`mark_org_oidc_policy`]'s marker. +/// +/// Args: +/// * `events`: The org's KEL events (oldest first; the latest anchored policy wins). +pub fn read_org_oidc_policy_digest(events: &[Event]) -> Option { + read_latest_marked_digest(events, ORG_OIDC_POLICY_MARKER) +} + +/// The latest `Seal::Digest` value carrying `marker` on a KEL slice — the one +/// scan both policy markers share (latest anchored wins). +fn read_latest_marked_digest(events: &[Event], marker: &str) -> Option { let mut found: Option = None; for event in events { for seal in event.anchors() { if let Seal::Digest { d } = seal - && let Some(hash) = d.as_str().strip_prefix(ORG_POLICY_MARKER) + && let Some(hash) = d.as_str().strip_prefix(marker) { found = Some(hash.to_string()); } diff --git a/crates/auths-sdk/src/domains/org/error.rs b/crates/auths-sdk/src/domains/org/error.rs index 7d247c9e..0bbd0ac4 100644 --- a/crates/auths-sdk/src/domains/org/error.rs +++ b/crates/auths-sdk/src/domains/org/error.rs @@ -144,6 +144,15 @@ pub enum OrgError { reason: String, }, + /// The supplied OIDC-subject policy did not parse (invalid JSON or empty + /// required fields). A policy that does not parse is never anchored — fail + /// closed. + #[error("invalid OIDC-subject policy: {reason}")] + OidcPolicyInvalid { + /// The parse failure. + reason: String, + }, + /// The org KEL anchors a policy hash but its content-addressed blob is missing — /// the policy cannot be loaded, so authority cannot be evaluated. Fail closed. #[error("org policy blob for hash '{hash}' is missing from storage")] @@ -212,6 +221,7 @@ impl AuthsErrorInfo for OrgError { Self::Attestation(_) => "AUTHS-E5617", Self::Bundle(e) => e.error_code(), Self::PolicyCompile { .. } => "AUTHS-E5622", + Self::OidcPolicyInvalid { .. } => "AUTHS-E5628", Self::PolicyBlobMissing { .. } => "AUTHS-E5623", Self::PolicyIntegrity { .. } => "AUTHS-E5624", Self::ChainCycle { .. } => "AUTHS-E5625", @@ -275,6 +285,9 @@ impl AuthsErrorInfo for OrgError { Self::PolicyCompile { .. } => Some( "Fix the policy JSON (a serialized `Expr`); see `auths org policy show` for the current policy", ), + Self::OidcPolicyInvalid { .. } => Some( + "Fix the OIDC-subject policy JSON (issuer + repository, optional workflow_ref); nothing was anchored", + ), Self::PolicyBlobMissing { .. } => { Some("The policy blob is missing; re-anchor it with `auths org policy set`") } diff --git a/crates/auths-sdk/src/domains/org/mod.rs b/crates/auths-sdk/src/domains/org/mod.rs index 8ccaadee..344de50f 100644 --- a/crates/auths-sdk/src/domains/org/mod.rs +++ b/crates/auths-sdk/src/domains/org/mod.rs @@ -14,6 +14,8 @@ pub mod metrics; pub mod offboarding; /// Offline verification of an air-gapped org bundle (zero-network, fail-closed). pub mod offline_verify; +/// KEL-anchored OIDC-subject policy: anchor/resolve the keyless-CI trust statement. +pub mod oidc_policy; /// Org-wide authorization policy: author/store/load + the fail-closed gate. pub mod policy; /// Org services @@ -35,6 +37,9 @@ pub use offboarding::{ OffboardingRecord, SignedOffboardingRecord, load_offboarding_record, verify_offboarding_record, }; pub use offline_verify::{OfflineVerifyReport, authenticate_bundled_kel, verify_org_bundle}; +pub use oidc_policy::{ + LoadedOrgOidcPolicy, OrgOidcPolicySet, load_org_oidc_policy, set_org_oidc_policy, +}; pub use policy::{ Expr, LoadedOrgPolicy, OrgPolicySet, evaluate_with_org_policy, load_org_policy, set_org_policy, }; diff --git a/crates/auths-sdk/src/domains/org/oidc_policy.rs b/crates/auths-sdk/src/domains/org/oidc_policy.rs new file mode 100644 index 00000000..1cf9022d --- /dev/null +++ b/crates/auths-sdk/src/domains/org/oidc_policy.rs @@ -0,0 +1,195 @@ +//! KEL-anchored OIDC-subject policy — the org's witnessed log as the policy's +//! source of truth. +//! +//! An [`OidcSubjectPolicy`] states WHICH workload identity may sign keylessly +//! for the org. Handing it to verifiers as a pinned file gives policy +//! distribution and rotation no audit trail; anchoring it on the org KEL does: +//! the policy JSON is stored as a **content-addressed blob** in the org's +//! credential namespace, and only its SHA-256 digest rides the KEL (an +//! `oidcpolicy:{digest}` seal). Every policy change is a witnessed KEL event, +//! and the binding is **tamper-evident**: [`load_org_oidc_policy`] recomputes +//! the loaded blob's digest and refuses a mismatch. +//! +//! Parse, don't validate: a policy that does not parse as an +//! [`OidcSubjectPolicy`] is never anchored, and a load returns the parsed type +//! — callers never re-check raw JSON. +//! +//! Anchoring requires a single-signature (`kt=1`) org — the anchoring `ixn` is +//! single-author, matching [`super::policy`] and [`super::delegation`]. + +use std::ops::ControlFlow; + +use auths_core::storage::keychain::{KeyAlias, extract_public_key_bytes}; +use auths_id::keri::delegation::{mark_org_oidc_policy, read_org_oidc_policy_digest}; +use auths_id::keri::types::Prefix; +use auths_id::keri::{Event, Said}; +use auths_id::ports::registry::RegistryBackend; +use auths_verifier::oidc_policy::OidcSubjectPolicy; +use sha2::{Digest, Sha256}; + +use crate::context::AuthsContext; +use crate::domains::org::delegation::ensure_single_sig_org; +use crate::domains::org::error::OrgError; + +/// The result of anchoring an org's OIDC-subject policy. +#[derive(Debug, Clone)] +pub struct OrgOidcPolicySet { + /// The org's `did:keri:`. + pub org_did: String, + /// Lowercase-hex SHA-256 digest of the policy source. Anchored on the KEL. + pub policy_digest: String, + /// The parsed policy that was anchored. + pub policy: OidcSubjectPolicy, +} + +/// An OIDC-subject policy resolved from the org KEL, digest-checked and parsed. +#[derive(Debug, Clone)] +pub struct LoadedOrgOidcPolicy { + /// The parsed policy, ready to JOIN against a signed OIDC binding. + pub policy: OidcSubjectPolicy, + /// Lowercase-hex SHA-256 digest (matches the on-KEL `oidcpolicy:` seal). + pub policy_digest: String, + /// The raw policy source JSON (for display). + pub source_json: String, +} + +/// Lowercase-hex SHA-256 of a policy's exact source bytes — the ONE digest both +/// the anchor writer and the loader compute. +fn oidc_policy_digest(source: &[u8]) -> String { + hex::encode(Sha256::digest(source)) +} + +/// Content-addressed blob key for an OIDC-subject policy of the given digest. +/// Namespaced (`oidcpolicy-{digest}`) so it never collides with the `policy-` +/// blobs or the member-prefix-keyed off-boarding records in the same store. +fn oidc_policy_blob_key(digest_hex: &str) -> Said { + Said::new_unchecked(format!("oidcpolicy-{digest_hex}")) +} + +/// Anchor an org's OIDC-subject policy: parse it, store the source JSON as a +/// content-addressed blob, and seal its digest on the org KEL. +/// +/// Fail-closed: a policy that does not parse is **never** anchored +/// ([`OrgError::OidcPolicyInvalid`]). Re-anchoring seals a new digest; the +/// latest wins on load — rotation is an auditable KEL event. Requires a +/// single-signature org. +/// +/// Args: +/// * `ctx`: Auths context (registry, key storage, passphrase). +/// * `org_prefix`: The org's KEL prefix (the policy authority). +/// * `org_alias`: Keychain alias of the org's signing key. +/// * `policy_json`: The policy JSON (issuer + repository, optional workflow_ref). +/// +/// Usage: +/// ```ignore +/// let set = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, policy_json)?; +/// println!("anchored OIDC policy {}", set.policy_digest); +/// ``` +pub fn set_org_oidc_policy( + ctx: &AuthsContext, + org_prefix: &Prefix, + org_alias: &KeyAlias, + policy_json: &[u8], +) -> Result { + ensure_single_sig_org(ctx, org_prefix)?; + + // Parse first — an invalid policy is never anchored. + let source = std::str::from_utf8(policy_json).map_err(|e| OrgError::OidcPolicyInvalid { + reason: e.to_string(), + })?; + let policy = OidcSubjectPolicy::parse(source).map_err(|e| OrgError::OidcPolicyInvalid { + reason: e.to_string(), + })?; + let digest_hex = oidc_policy_digest(policy_json); + + // The source JSON lives off-KEL in a content-addressed blob; only the digest + // is sealed, so the append-only KEL stays lean. + ctx.registry + .store_credential(org_prefix, &oidc_policy_blob_key(&digest_hex), policy_json) + .map_err(OrgError::Storage)?; + + let (_pk, org_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + org_alias, + ctx.passphrase_provider.as_ref(), + ) + .map_err(OrgError::CryptoError)?; + + mark_org_oidc_policy( + ctx.registry.as_ref(), + org_prefix, + org_alias, + org_curve, + &digest_hex, + ctx.passphrase_provider.as_ref(), + ctx.key_storage.as_ref(), + ) + .map_err(OrgError::Delegation)?; + + Ok(OrgOidcPolicySet { + org_did: format!("did:keri:{}", org_prefix.as_str()), + policy_digest: digest_hex, + policy, + }) +} + +/// Load the org's current (latest-anchored) OIDC-subject policy from the KEL + +/// blob, fail-closed. +/// +/// Takes the registry **port** directly — resolution is read-only, so verifiers +/// need no key custody to call it. Returns `Ok(None)` if the org never anchored +/// an OIDC-subject policy. A KEL that references a missing blob is +/// [`OrgError::PolicyBlobMissing`]; a blob that does not hash to the sealed +/// digest is [`OrgError::PolicyIntegrity`] (tampered); an unparseable blob is +/// [`OrgError::OidcPolicyInvalid`]. +/// +/// Args: +/// * `registry`: Registry backend holding the org KEL + credential blobs. +/// * `org_prefix`: The org's KEL prefix. +/// +/// Usage: +/// ```ignore +/// if let Some(loaded) = load_org_oidc_policy(®istry, &org_prefix)? { +/// let join = loaded.policy.join(&binding)?; +/// } +/// ``` +pub fn load_org_oidc_policy( + registry: &(dyn RegistryBackend + Send + Sync), + org_prefix: &Prefix, +) -> Result, OrgError> { + let mut events: Vec = Vec::new(); + let _ = registry.visit_events(org_prefix, 0, &mut |e| { + events.push(e.clone()); + ControlFlow::Continue(()) + }); + let Some(digest_hex) = read_org_oidc_policy_digest(&events) else { + return Ok(None); + }; + + let bytes = registry + .load_credential(org_prefix, &oidc_policy_blob_key(&digest_hex)) + .map_err(OrgError::Storage)? + .ok_or_else(|| OrgError::PolicyBlobMissing { + hash: digest_hex.clone(), + })?; + + // Tamper check: the loaded blob must hash to the value the KEL sealed. + let actual = oidc_policy_digest(&bytes); + if actual != digest_hex { + return Err(OrgError::PolicyIntegrity { + expected: digest_hex, + actual, + }); + } + + let source = String::from_utf8_lossy(&bytes).into_owned(); + let policy = OidcSubjectPolicy::parse(&source).map_err(|e| OrgError::OidcPolicyInvalid { + reason: e.to_string(), + })?; + + Ok(Some(LoadedOrgOidcPolicy { + policy, + policy_digest: digest_hex, + source_json: source, + })) +} diff --git a/crates/auths-sdk/src/workflows/org.rs b/crates/auths-sdk/src/workflows/org.rs index 2d451b6c..3b8a6c8c 100644 --- a/crates/auths-sdk/src/workflows/org.rs +++ b/crates/auths-sdk/src/workflows/org.rs @@ -20,6 +20,9 @@ pub use crate::domains::org::offboarding::{ OffboardingRecord, SignedOffboardingRecord, load_offboarding_record, verify_offboarding_record, }; pub use crate::domains::org::offline_verify::{OfflineVerifyReport, verify_org_bundle}; +pub use crate::domains::org::oidc_policy::{ + LoadedOrgOidcPolicy, OrgOidcPolicySet, load_org_oidc_policy, set_org_oidc_policy, +}; pub use crate::domains::org::policy::{ Expr, LoadedOrgPolicy, OrgPolicySet, evaluate_with_org_policy, load_org_policy, set_org_policy, }; diff --git a/crates/auths-sdk/tests/cases/mod.rs b/crates/auths-sdk/tests/cases/mod.rs index 62358a77..5b05cc7b 100644 --- a/crates/auths-sdk/tests/cases/mod.rs +++ b/crates/auths-sdk/tests/cases/mod.rs @@ -21,6 +21,7 @@ mod kill_switch; mod local_signer; mod org; mod org_delegation; +mod org_oidc_policy; mod org_policy; mod org_trace; mod pairing; diff --git a/crates/auths-sdk/tests/cases/org_oidc_policy.rs b/crates/auths-sdk/tests/cases/org_oidc_policy.rs new file mode 100644 index 00000000..df808a19 --- /dev/null +++ b/crates/auths-sdk/tests/cases/org_oidc_policy.rs @@ -0,0 +1,186 @@ +//! KEL-anchored OIDC-subject policy: anchor + resolve, with the org's witnessed +//! log as the policy's source of truth. Policy source lives in a +//! content-addressed blob; only its SHA-256 digest rides the org KEL (an +//! `oidcpolicy:` seal), tamper-evident on load. + +use std::sync::Arc; + +use auths_core::PrefilledPassphraseProvider; +use auths_core::signing::{PassphraseProvider, StorageSigner}; +use auths_core::storage::keychain::KeyAlias; +use auths_core::testing::IsolatedKeychainHandle; +use auths_id::keri::Said; +use auths_sdk::context::AuthsContext; +use auths_sdk::domains::identity::service::initialize; +use auths_sdk::domains::identity::types::{ + CreateDeveloperIdentityConfig, IdentityConfig, InitializeResult, +}; +use auths_sdk::domains::org::error::OrgError; +use auths_sdk::domains::org::oidc_policy::{load_org_oidc_policy, set_org_oidc_policy}; +use auths_sdk::domains::org::policy::{Expr, load_org_policy, set_org_policy}; +use auths_sdk::domains::signing::types::GitSigningScope; +use auths_verifier::Prefix; + +use crate::cases::helpers::{build_test_context, build_test_context_with_provider}; + +const PASS: &str = "Test-passphrase1!"; + +const GOOD_POLICY: &str = r#"{ + "issuer": "https://token.actions.githubusercontent.com", + "repository": "acme/widget" +}"#; + +const ROTATED_POLICY: &str = r#"{ + "issuer": "https://token.actions.githubusercontent.com", + "repository": "acme/widget", + "workflow_ref": "acme/widget/.github/workflows/release.yml" +}"#; + +/// `(ctx, org signing alias, org prefix, tmp)` for a fresh org AID. +fn setup() -> (AuthsContext, KeyAlias, Prefix, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let keychain = IsolatedKeychainHandle::new(); + let signer = StorageSigner::new(keychain.clone()); + let provider = PrefilledPassphraseProvider::new(PASS); + let config = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked("org-key")) + .with_git_signing_scope(GitSigningScope::Skip) + .build(); + let boot_ctx = build_test_context(tmp.path(), Arc::new(keychain.clone())); + let result = match initialize( + IdentityConfig::Developer(config), + &boot_ctx, + Arc::new(keychain.clone()), + &signer, + &provider, + None, + ) + .unwrap() + { + InitializeResult::Developer(r) => r, + _ => unreachable!(), + }; + + let arc_provider: Arc = + Arc::new(PrefilledPassphraseProvider::new(PASS)); + let ctx = build_test_context_with_provider( + tmp.path(), + Arc::new(keychain.clone()), + Some(arc_provider), + ); + let managed = ctx.identity_storage.load_identity().expect("org identity"); + let org_prefix = Prefix::new_unchecked( + managed + .controller_did + .as_str() + .strip_prefix("did:keri:") + .unwrap() + .to_string(), + ); + (ctx, result.key_alias, org_prefix, tmp) +} + +#[test] +fn anchor_then_resolve_round_trips_and_latest_wins() { + let (ctx, org_alias, org_prefix, _tmp) = setup(); + + let set1 = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, GOOD_POLICY.as_bytes()) + .expect("anchor policy 1"); + assert_eq!(set1.policy.repository(), "acme/widget"); + + let loaded = load_org_oidc_policy(ctx.registry.as_ref(), &org_prefix) + .expect("load") + .expect("a policy is anchored"); + assert_eq!(loaded.policy_digest, set1.policy_digest); + assert_eq!( + loaded.policy, set1.policy, + "the resolved policy is the anchored one, parsed" + ); + + // Rotation is a KEL event: re-anchor — the latest seal wins on load. + let set2 = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, ROTATED_POLICY.as_bytes()) + .expect("anchor policy 2"); + assert_ne!(set1.policy_digest, set2.policy_digest); + + let loaded2 = load_org_oidc_policy(ctx.registry.as_ref(), &org_prefix) + .expect("load") + .expect("a policy is anchored"); + assert_eq!( + loaded2.policy_digest, set2.policy_digest, + "latest anchored policy wins" + ); +} + +#[test] +fn unparseable_policy_is_rejected_at_anchor() { + let (ctx, org_alias, org_prefix, _tmp) = setup(); + let err = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, b"{ not valid json") + .expect_err("garbage must not anchor"); + assert!( + matches!(err, OrgError::OidcPolicyInvalid { .. }), + "expected OidcPolicyInvalid, got {err:?}" + ); + // An empty required field is also invalid — parse, don't validate. + let err = set_org_oidc_policy( + &ctx, + &org_prefix, + &org_alias, + br#"{"issuer":" ","repository":"acme/widget"}"#, + ) + .expect_err("empty issuer must not anchor"); + assert!(matches!(err, OrgError::OidcPolicyInvalid { .. })); + // Nothing anchored — load returns None. + assert!( + load_org_oidc_policy(ctx.registry.as_ref(), &org_prefix) + .expect("load") + .is_none() + ); +} + +#[test] +fn tampered_blob_fails_integrity_on_load() { + let (ctx, org_alias, org_prefix, _tmp) = setup(); + let set = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, GOOD_POLICY.as_bytes()) + .expect("anchor policy"); + + // Overwrite the content-addressed blob with a different (still-valid) policy + // whose digest no longer matches the sealed value. + let key = Said::new_unchecked(format!("oidcpolicy-{}", set.policy_digest)); + ctx.registry + .store_credential(&org_prefix, &key, ROTATED_POLICY.as_bytes()) + .expect("overwrite blob"); + + let err = load_org_oidc_policy(ctx.registry.as_ref(), &org_prefix) + .expect_err("tampered blob must fail closed"); + assert!( + matches!(err, OrgError::PolicyIntegrity { .. }), + "expected PolicyIntegrity, got {err:?}" + ); +} + +#[test] +fn oidc_policy_seal_does_not_shadow_the_authorization_policy_seal() { + let (ctx, org_alias, org_prefix, _tmp) = setup(); + + // Both policy kinds anchored on the same KEL — each marker resolves its own. + set_org_policy( + &ctx, + &org_prefix, + &org_alias, + &serde_json::to_vec(&Expr::NotRevoked).unwrap(), + ) + .expect("set authorization policy"); + let set = set_org_oidc_policy(&ctx, &org_prefix, &org_alias, GOOD_POLICY.as_bytes()) + .expect("anchor OIDC policy"); + + let authz = load_org_policy(&ctx, &org_prefix) + .expect("load authz") + .expect("authz policy anchored"); + let oidc = load_org_oidc_policy(ctx.registry.as_ref(), &org_prefix) + .expect("load oidc") + .expect("oidc policy anchored"); + assert_ne!( + authz.policy_hash, oidc.policy_digest, + "the two seals are distinct documents" + ); + assert_eq!(oidc.policy_digest, set.policy_digest); +} From 96de9d52b1b67d1a3dd88df0de9082deac77bcce Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 22:33:34 +0100 Subject: [PATCH 15/32] =?UTF-8?q?feat(PWNTS-4):=20stateless=20commit-bundl?= =?UTF-8?q?e=20verification=20=E2=80=94=20verifyCommitJson=20WASM=20export?= =?UTF-8?q?=20over=20a=20parsed=20BundleTrust=20anchor;=20P-256=20SSH=20ve?= =?UTF-8?q?rify=20through=20the=20provider=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .../auths-cli/src/commands/verify_commit.rs | 58 +- .../auths-sdk/src/workflows/commit_trust.rs | 178 +----- crates/auths-verifier/src/commit.rs | 16 +- crates/auths-verifier/src/commit_bundle.rs | 586 ++++++++++++++++++ crates/auths-verifier/src/commit_kel.rs | 40 ++ crates/auths-verifier/src/lib.rs | 8 +- crates/auths-verifier/src/wasm.rs | 37 ++ 7 files changed, 690 insertions(+), 233 deletions(-) create mode 100644 crates/auths-verifier/src/commit_bundle.rs diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index f8bb6518..21c985ce 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -8,8 +8,9 @@ use auths_sdk::ports::RegistryBackend; use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig}; use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ - Attestation, CommitVerdict, IdentityBundle, VerificationReport, VerifierWitnessPolicy, - WitnessGateStatus, verify_chain_with_witnesses, verify_commit_against_kel_witnessed, + Attestation, BundleTrust, CommitVerdict, IdentityBundle, VerificationReport, + VerifierWitnessPolicy, WitnessGateStatus, verify_chain_with_witnesses, + verify_commit_against_kel_witnessed, }; use clap::Parser; use serde::Serialize; @@ -244,9 +245,11 @@ struct BundleKel { } /// Load an identity bundle from `path` and return the trusted root `did:keri:` it pins -/// (freshness-checked via the SDK trust resolver) plus the KEL events it carries for -/// stateless resolution. Fails closed: any read, parse, or staleness error is returned -/// so the caller can abort rather than verify unconstrained. +/// plus the KEL events it carries for stateless resolution. The trust checks — +/// freshness, RT-005 self-certification, RT-002 KEL signature authentication — +/// live once, in [`BundleTrust::parse`]; this only adds the file I/O and path +/// context. Fails closed: any read, parse, or trust error is returned so the +/// caller can abort rather than verify unconstrained. fn load_bundle_trust( path: &std::path::Path, now: chrono::DateTime, @@ -255,48 +258,9 @@ fn load_bundle_trust( .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?; let bundle: IdentityBundle = serde_json::from_str(&content) .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?; - let root = auths_sdk::workflows::commit_trust::trusted_root_from_bundle(&bundle, now) - .map_err(|e| e.to_string())?; - // `bundle.kel` is already `Vec` — parsed at the deserialize boundary, - // so a structurally-broken event fails the bundle parse above rather than - // slipping through as loose JSON. - let kel = bundle.kel; - - // Authenticate the bundle's KEL (RT-002): a bundle is attacker-controlled - // input, so we do NOT merely replay it structurally — we verify every event - // is signed by the controlling key-state via `validate_signed_kel`. The - // producer ships each event's CESR signature attachment (hex); a bundle - // missing them (length mismatch), or whose signatures don't verify, fails - // closed HERE, before its KEL is ever used to resolve a signer. - if !kel.is_empty() { - if bundle.kel_attachments.len() != kel.len() { - return Err(format!( - "identity bundle {path:?} carries {} KEL events but {} signature \ - attachments — cannot authenticate it; re-export with a current \ - `auths id export-bundle`", - kel.len(), - bundle.kel_attachments.len() - )); - } - let signed: Vec = kel - .iter() - .zip(bundle.kel_attachments.iter()) - .map(|(event, att_hex)| { - let att = hex::decode(att_hex).map_err(|e| { - format!("identity bundle {path:?} has a non-hex KEL signature: {e}") - })?; - let sigs = auths_keri::parse_attachment(&att).map_err(|e| { - format!("identity bundle {path:?} has an unparseable KEL signature: {e}") - })?; - Ok(auths_keri::SignedEvent::new(event.clone(), sigs)) - }) - .collect::>()?; - auths_keri::validate_signed_kel(&signed, None).map_err(|e| { - format!("identity bundle {path:?} KEL failed signature authentication (RT-002): {e}") - })?; - } - - Ok((root, kel)) + let trust = BundleTrust::parse(&bundle, now) + .map_err(|e| format!("identity bundle {path:?} is not a usable trust anchor: {e}"))?; + Ok(trust.into_parts()) } /// Resolve the commit spec to a list of commit SHAs. diff --git a/crates/auths-sdk/src/workflows/commit_trust.rs b/crates/auths-sdk/src/workflows/commit_trust.rs index 4d2046b9..46efc58d 100644 --- a/crates/auths-sdk/src/workflows/commit_trust.rs +++ b/crates/auths-sdk/src/workflows/commit_trust.rs @@ -8,11 +8,12 @@ //! itself lives in `auths_verifier::verify_commit_against_kel`; this workflow owns the //! orchestration (trailer parse → KEL resolution → verdict). -use auths_keri::compute_event_said; -use auths_verifier::IdentityBundle; use chrono::{DateTime, Utc}; -use crate::keri::parse_trailers; +/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device` +/// trailers. One definition, in the verifier — the same parse the WASM/CLI +/// stateless paths use, re-exported here for workflow callers. +pub use auths_verifier::commit_signer_trailers; // `verify_commit_local` resolves KELs through `KelResolverChain`, which is only // compiled with the git registry backend; gate it and its imports accordingly so @@ -36,11 +37,6 @@ pub enum CommitTrustError { )] MissingTrailers, - /// A supplied identity bundle was unreadable, malformed, or stale. Fails - /// closed — an unusable trust anchor must never silently downgrade trust. - #[error("identity bundle is not a usable trust anchor: {0}")] - BundleInvalid(String), - /// A signer's KEL could not be resolved from the registry. #[error("{role} KEL for {did} could not be resolved: {reason}")] KelUnresolved { @@ -57,97 +53,6 @@ pub enum CommitTrustError { Policy(#[from] crate::domains::org::error::OrgError), } -/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device` -/// trailers. Returns `None` when either trailer is absent (a commit not signed by -/// `auths`). -/// -/// Args: -/// * `raw_commit`: The raw git commit object (headers + message). -/// -/// Usage: -/// ```ignore -/// if let Some((root, device)) = commit_signer_trailers(commit) { /* trusted? */ } -/// ``` -pub fn commit_signer_trailers(raw_commit: &str) -> Option<(String, String)> { - let message = raw_commit - .split_once("\n\n") - .map(|(_, m)| m) - .unwrap_or(raw_commit); - let trailers = parse_trailers(message); - let find = |key: &str| { - trailers - .iter() - .rev() - .find(|(k, _)| k.eq_ignore_ascii_case(key)) - .map(|(_, v)| v.trim().to_string()) - }; - Some((find("Auths-Id")?, find("Auths-Device")?)) -} - -/// The trusted root `did:keri:` a CI/stateless identity bundle pins. -/// -/// The bundle's `identity_did` is a self-certifying KERI prefix (the prefix *is* the -/// digest of its inception event), so trusting it as a pinned root is sound: any KEL -/// resolved for that DID must self-certify to the same prefix or fail prefix-binding. -/// The bundle is freshness-checked against `now` and fails **closed** — a stale or -/// malformed anchor is rejected, never silently treated as "no constraint". -/// -/// Args: -/// * `bundle`: The parsed identity bundle supplied via `--identity-bundle`. -/// * `now`: Current time, injected at the presentation boundary. -/// -/// Usage: -/// ```ignore -/// let root = trusted_root_from_bundle(&bundle, clock.now())?; -/// pinned_roots.push(root); -/// ``` -pub fn trusted_root_from_bundle( - bundle: &IdentityBundle, - now: DateTime, -) -> Result { - bundle - .check_freshness(now) - .map_err(|e| CommitTrustError::BundleInvalid(e.to_string()))?; - - // Self-certification of the bundle root (RT-005): the DID the caller is about - // to treat as a root MUST actually name the inception the bundle carries, so - // a bundle cannot pair a victim's DID with an attacker-authored KEL. For a - // self-addressing (`E`) root the DID MUST equal the inception SAID; for a - // basic-derivation root it MUST equal the inception's controller field (and - // replay independently enforces `i == k[0]`). The bundle is *evidence for* - // the pinned root, never a self-asserted anchor — the CLI additionally - // requires the returned root to already be pinned independently. - if let Some(inception) = bundle.kel.first() { - let claimed = bundle.identity_did.to_string(); - let prefix = claimed - .strip_prefix("did:keri:") - .unwrap_or(claimed.as_str()); - if prefix.starts_with('E') { - let said = compute_event_said(inception).map_err(|e| { - CommitTrustError::BundleInvalid(format!( - "bundle KEL inception has no computable SAID: {e}" - )) - })?; - if prefix != said.as_str() { - return Err(CommitTrustError::BundleInvalid(format!( - "bundle identity_did {claimed} does not self-certify to its \ - inception SAID did:keri:{said}" - ))); - } - } else { - let inception_i = inception.prefix().as_str(); - if prefix != inception_i { - return Err(CommitTrustError::BundleInvalid(format!( - "bundle identity_did {claimed} does not match its inception \ - controller {inception_i}" - ))); - } - } - } - - Ok(bundle.identity_did.to_string()) -} - /// The verifier's own root identity DID, implicitly trusted for its own /// verifications. /// @@ -456,79 +361,4 @@ mod tests { Some((ROOT.to_string(), DEVICE.to_string())) ); } - - #[allow(clippy::disallowed_methods)] // INVARIANT: fixed test strings, never external input - fn test_bundle(did: &str, ts: DateTime, ttl: u64) -> IdentityBundle { - IdentityBundle { - identity_did: auths_verifier::IdentityDID::new_unchecked(did.to_string()), - public_key_hex: auths_verifier::PublicKeyHex::new_unchecked("00".to_string()), - curve: auths_crypto::CurveType::P256, - attestation_chain: Vec::new(), - kel: Vec::new(), - kel_attachments: Vec::new(), - bundle_timestamp: ts, - max_valid_for_secs: ttl, - } - } - - fn fixed_time() -> DateTime { - DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp") - } - - #[test] - fn fresh_bundle_yields_its_root_did() { - let t = fixed_time(); - let bundle = test_bundle(ROOT, t, 3600); - let now = t + chrono::Duration::seconds(100); - assert_eq!(trusted_root_from_bundle(&bundle, now).expect("fresh"), ROOT); - } - - #[test] - fn stale_bundle_fails_closed() { - let t = fixed_time(); - let bundle = test_bundle(ROOT, t, 3600); - let now = t + chrono::Duration::seconds(7200); - assert!(matches!( - trusted_root_from_bundle(&bundle, now), - Err(CommitTrustError::BundleInvalid(_)) - )); - } - - #[test] - fn bundle_rejects_did_not_matching_its_kel_inception() { - // RT-005 self-certification: a bundle that pairs a DID with a KEL whose - // inception names a DIFFERENT controller must fail closed, so a bundle - // can never become the trust anchor for an attacker-authored KEL. - use auths_keri::{ - CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, - VersionString, compute_next_commitment, finalize_icp_event, - }; - // A real, self-certifying inception — its prefix is its own `E…` SAID. - let key = KeriPublicKey::ed25519(&[7u8; 32]).unwrap(); - let next = KeriPublicKey::ed25519(&[8u8; 32]).unwrap(); - let inception = finalize_icp_event(IcpEvent { - v: VersionString::placeholder(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: Threshold::Simple(1), - k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())], - nt: Threshold::Simple(1), - n: vec![compute_next_commitment(&next)], - bt: Threshold::Simple(0), - b: vec![], - c: vec![], - a: vec![], - }) - .unwrap(); - let t = fixed_time(); - // Pair that inception with an unrelated `D…` DID it does not certify. - let mut bundle = test_bundle("did:keri:DAttackerKey", t, 3600); - bundle.kel = vec![Event::Icp(inception)]; - let now = t + chrono::Duration::seconds(100); - assert!(matches!( - trusted_root_from_bundle(&bundle, now), - Err(CommitTrustError::BundleInvalid(_)) - )); - } } diff --git a/crates/auths-verifier/src/commit.rs b/crates/auths-verifier/src/commit.rs index a7c108c6..92f85b13 100644 --- a/crates/auths-verifier/src/commit.rs +++ b/crates/auths-verifier/src/commit.rs @@ -84,21 +84,17 @@ pub async fn verify_commit_signature( .map_err(|_| CommitVerificationError::SignatureInvalid)?; } auths_crypto::CurveType::P256 => { - #[cfg(feature = "native")] - { - auths_crypto::RingCryptoProvider::p256_verify( + // Through the provider port like Ed25519 above — never a direct + // backend call, so the same verdict computes on native (ring) and + // WASM (WebCrypto/pure-Rust p256) alike. + provider + .verify_p256( envelope.public_key.as_bytes(), &signed_data, &envelope.signature, ) + .await .map_err(|_| CommitVerificationError::SignatureInvalid)?; - } - #[cfg(not(feature = "native"))] - { - return Err(CommitVerificationError::SshSigParseFailed( - "P-256 verification not available on this platform".into(), - )); - } } } diff --git a/crates/auths-verifier/src/commit_bundle.rs b/crates/auths-verifier/src/commit_bundle.rs new file mode 100644 index 00000000..1483b78f --- /dev/null +++ b/crates/auths-verifier/src/commit_bundle.rs @@ -0,0 +1,586 @@ +//! Stateless commit verification against an identity bundle — no git, no +//! identity store, no network. +//! +//! An exported identity bundle carries everything a verifier with nothing +//! installed needs to decide "commit ← maintainer": the identity's KEL, one +//! CESR signature attachment per event, and a freshness window. This module +//! turns that bundle into a [`BundleTrust`] — a parsed trust anchor whose +//! existence proves the bundle was fresh, self-certifying (RT-005), and +//! signature-authenticated (RT-002) — and then verifies a raw commit object +//! against it with [`verify_commit_against_kel`]. The same path serves the CLI +//! (`--identity-bundle` on a bare CI runner) and the browser (the +//! `verifyCommitJson` WASM export), so the verdict cannot drift between +//! transports. + +use auths_keri::{Event, compute_event_said, pair_kel_attachments, validate_signed_kel}; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use crate::commit_kel::{CommitVerdict, commit_signer_trailers, verify_commit_against_kel}; +use crate::core::{IdentityBundle, MAX_JSON_BATCH_SIZE}; +use auths_crypto::CryptoProvider; + +/// Why an identity bundle could not become a trust anchor. Fails closed: an +/// unusable bundle is rejected, never silently treated as "no constraint". +#[derive(Debug, thiserror::Error)] +pub enum BundleTrustError { + /// The bundle is past its own TTL. + #[error("bundle is stale: {0}")] + Stale(String), + + /// RT-005: the bundle's `identity_did` does not name the inception its KEL + /// carries, so it could pair a victim's DID with an attacker-authored KEL. + #[error("bundle does not self-certify: {0}")] + NotSelfCertifying(String), + + /// RT-002: the bundle's KEL events could not be authenticated against the + /// controlling key-state (missing, malformed, or forged signature + /// attachments). Re-export with a current `auths id export-bundle`. + #[error("bundle KEL failed signature authentication (RT-002): {0}")] + KelUnauthenticated(String), +} + +/// A parsed, authenticated trust anchor extracted from an identity bundle. +/// +/// Constructing one ([`BundleTrust::parse`]) proves three things, so callers +/// never re-check them: +/// +/// 1. **Freshness** — the bundle is within its own TTL. +/// 2. **Self-certification (RT-005)** — the bundle's `identity_did` names the +/// inception event its KEL carries (SAID for `E…` prefixes, controller +/// field otherwise), so a bundle cannot pair a victim's DID with an +/// attacker-authored KEL. +/// 3. **KEL authentication (RT-002)** — every KEL event is signed by its +/// controlling key-state, verified via [`validate_signed_kel`]; a stripped +/// or forged attachment fails closed here. +/// +/// The bundle is *evidence for* a pinned root, never the source of the pin: +/// callers must still require [`BundleTrust::root_did`] to be in their +/// independently pinned root set. +pub struct BundleTrust { + root_did: String, + kel: Vec, +} + +impl BundleTrust { + /// Parse a bundle into a trust anchor: freshness + RT-005 + RT-002. + /// + /// Args: + /// * `bundle`: The deserialized identity bundle (attacker-controlled input). + /// * `now`: Current time, injected at the boundary. + /// + /// Usage: + /// ```ignore + /// let trust = BundleTrust::parse(&bundle, Utc::now())?; + /// assert!(pinned_roots.contains(&trust.root_did().to_string())); + /// ``` + pub fn parse(bundle: &IdentityBundle, now: DateTime) -> Result { + bundle + .check_freshness(now) + .map_err(|e| BundleTrustError::Stale(e.to_string()))?; + + // RT-005 — self-certification: the DID the caller is about to treat as + // a root MUST actually name the inception the bundle carries. For a + // self-addressing (`E`) root the DID MUST equal the inception SAID; for + // a basic-derivation root it MUST equal the inception's controller + // field (and replay independently enforces `i == k[0]`). + if let Some(inception) = bundle.kel.first() { + let claimed = bundle.identity_did.to_string(); + let prefix = claimed + .strip_prefix("did:keri:") + .unwrap_or(claimed.as_str()); + if prefix.starts_with('E') { + let said = compute_event_said(inception).map_err(|e| { + BundleTrustError::NotSelfCertifying(format!( + "bundle KEL inception has no computable SAID: {e}" + )) + })?; + if prefix != said.as_str() { + return Err(BundleTrustError::NotSelfCertifying(format!( + "bundle identity_did {claimed} does not self-certify to its \ + inception SAID did:keri:{said}" + ))); + } + } else { + let inception_i = inception.prefix().as_str(); + if prefix != inception_i { + return Err(BundleTrustError::NotSelfCertifying(format!( + "bundle identity_did {claimed} does not match its inception \ + controller {inception_i}" + ))); + } + } + } + + // RT-002 — authenticate the KEL: every event must carry a valid CESR + // signature from its controlling key-state. The count-mismatch refusal + // (absent/short attachment list ⇒ unauthenticated KEL) lives once, in + // `pair_kel_attachments` — never re-implemented here. + if !bundle.kel.is_empty() { + let attachment_bytes: Vec> = bundle + .kel_attachments + .iter() + .map(|att_hex| { + hex::decode(att_hex).map_err(|e| { + BundleTrustError::KelUnauthenticated(format!( + "non-hex KEL signature attachment: {e}" + )) + }) + }) + .collect::>()?; + let signed = pair_kel_attachments(bundle.kel.clone(), &attachment_bytes) + .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?; + validate_signed_kel(&signed, None) + .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?; + } + + Ok(Self { + root_did: bundle.identity_did.to_string(), + kel: bundle.kel.clone(), + }) + } + + /// The root `did:keri:` this bundle self-certifies to. Evidence for a pin, + /// never the pin itself. + pub fn root_did(&self) -> &str { + &self.root_did + } + + /// The authenticated KEL events, oldest first. + pub fn kel(&self) -> &[Event] { + &self.kel + } + + /// Consume the anchor into `(root_did, kel)` for callers that thread the + /// parts separately (the CLI's stateless resolver). + pub fn into_parts(self) -> (String, Vec) { + (self.root_did, self.kel) + } +} + +/// The tagged JSON envelope returned by [`verify_commit_with_bundle_json`]: +/// `kind: "verdict"` carries the commit verdict; `kind: "error"` means the +/// inputs never reached a verdict (unusable bundle, unpinned root, bad JSON). +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CommitBundleEnvelope { + Verdict { + valid: bool, + verdict: &'static str, + detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + signer_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + root_did: Option, + }, + Error { + error: String, + }, +} + +fn envelope_to_string(envelope: &CommitBundleEnvelope) -> String { + serde_json::to_string(envelope) + .unwrap_or_else(|_| r#"{"kind":"error","error":"serialization failed"}"#.to_string()) +} + +/// A stable machine code for each [`CommitVerdict`] variant. +fn verdict_code(verdict: &CommitVerdict) -> &'static str { + match verdict { + CommitVerdict::Valid { .. } => "valid", + CommitVerdict::Unsigned => "unsigned", + CommitVerdict::SshSignatureInvalid => "ssh-signature-invalid", + CommitVerdict::GpgUnsupported => "gpg-unsupported", + CommitVerdict::DeviceKelInvalid(_) => "device-kel-invalid", + CommitVerdict::RootKelInvalid(_) => "root-kel-invalid", + CommitVerdict::RootNotPinned(_) => "root-not-pinned", + CommitVerdict::RootAbandoned => "root-abandoned", + CommitVerdict::NotDelegatedByClaimedRoot { .. } => "not-delegated-by-claimed-root", + CommitVerdict::DelegationSealNotFound => "delegation-seal-not-found", + CommitVerdict::DeviceRevoked => "device-revoked", + CommitVerdict::SignedAfterRevocation { .. } => "signed-after-revocation", + CommitVerdict::OutsideAgentScope { .. } => "outside-agent-scope", + CommitVerdict::AgentExpired { .. } => "agent-expired", + CommitVerdict::SignerKeyMismatch => "signer-key-mismatch", + CommitVerdict::SignedBySupersededKey => "signed-by-superseded-key", + CommitVerdict::WitnessQuorumNotMet { .. } => "witness-quorum-not-met", + } +} + +/// A human-readable one-liner for each [`CommitVerdict`] variant. +fn verdict_detail(verdict: &CommitVerdict) -> String { + match verdict { + CommitVerdict::Valid { + signer_did, + root_did, + duplicitous_root, + } => { + let fork = if *duplicitous_root { + " (warning: root KEL shows a fork)" + } else { + "" + }; + format!("commit signed by {signer_did}, chained to pinned root {root_did}{fork}") + } + CommitVerdict::Unsigned => "commit carries no SSH signature".to_string(), + CommitVerdict::SshSignatureInvalid => "SSH signature did not validate".to_string(), + CommitVerdict::GpgUnsupported => "PGP-signed commit (unsupported)".to_string(), + CommitVerdict::DeviceKelInvalid(e) => format!("device KEL invalid: {e}"), + CommitVerdict::RootKelInvalid(e) => format!("root KEL invalid: {e}"), + CommitVerdict::RootNotPinned(did) => format!("root {did} is not pinned"), + CommitVerdict::RootAbandoned => "root identity is abandoned".to_string(), + CommitVerdict::NotDelegatedByClaimedRoot { + device_did, + root_did, + } => format!("{device_did} is not delegated by {root_did}"), + CommitVerdict::DelegationSealNotFound => { + "root never anchored the device's delegation".to_string() + } + CommitVerdict::DeviceRevoked => "the signer's delegation is revoked".to_string(), + CommitVerdict::SignedAfterRevocation { + signed_at, + revoked_at, + .. + } => format!("signed at KEL position {signed_at}, revoked at {revoked_at}"), + CommitVerdict::OutsideAgentScope { capability, .. } => { + format!("capability {capability} is outside the agent's anchored scope") + } + CommitVerdict::AgentExpired { expired_at, .. } => { + format!("agent delegation expired at {expired_at}") + } + CommitVerdict::SignerKeyMismatch => { + "SSH signer key is not the device's current key".to_string() + } + CommitVerdict::SignedBySupersededKey => { + "SSH signer key was superseded by a device rotation".to_string() + } + CommitVerdict::WitnessQuorumNotMet { + collected, + required, + .. + } => format!("witness quorum not met: {collected} of {required}"), + } +} + +fn verdict_envelope(verdict: CommitVerdict) -> CommitBundleEnvelope { + let (signer_did, root_did) = match &verdict { + CommitVerdict::Valid { + signer_did, + root_did, + .. + } => (Some(signer_did.clone()), Some(root_did.clone())), + _ => (None, None), + }; + CommitBundleEnvelope::Verdict { + valid: verdict.is_valid(), + verdict: verdict_code(&verdict), + detail: verdict_detail(&verdict), + signer_did, + root_did, + } +} + +/// Verify a raw git commit object against an identity bundle, fully stateless, +/// returning the tagged JSON envelope (`kind`: `"verdict"` | `"error"`). +/// +/// The bundle is attacker-controlled input: it is parsed into a +/// [`BundleTrust`] (freshness + RT-005 self-certification + RT-002 KEL +/// authentication) and is **evidence only** — its root must already be in +/// `pinned_roots_json` or verification refuses before any signature check. +/// The commit's `Auths-Id`/`Auths-Device` trailers may only *select* the +/// bundle identity: a trailer naming any other DID cannot be resolved without +/// an identity store and fails closed. +/// +/// Args: +/// * `commit_text`: The raw git commit object (headers + message + `gpgsig`), +/// exactly as produced by `git cat-file commit `. +/// * `bundle_json`: The identity bundle JSON (from `auths id export-bundle`). +/// * `pinned_roots_json`: JSON array of independently pinned `did:keri:` roots. +/// * `now`: Current time, injected at the boundary. +/// * `provider`: Crypto provider for in-process SSH-signature verification. +pub async fn verify_commit_with_bundle_json( + commit_text: &str, + bundle_json: &str, + pinned_roots_json: &str, + now: DateTime, + provider: &dyn CryptoProvider, +) -> String { + match verify_commit_with_bundle_inner( + commit_text, + bundle_json, + pinned_roots_json, + now, + provider, + ) + .await + { + Ok(verdict) => envelope_to_string(&verdict_envelope(verdict)), + Err(error) => envelope_to_string(&CommitBundleEnvelope::Error { error }), + } +} + +async fn verify_commit_with_bundle_inner( + commit_text: &str, + bundle_json: &str, + pinned_roots_json: &str, + now: DateTime, + provider: &dyn CryptoProvider, +) -> Result { + for (name, input) in [ + ("commit", commit_text), + ("bundle", bundle_json), + ("pinned roots", pinned_roots_json), + ] { + if input.len() > MAX_JSON_BATCH_SIZE { + return Err(format!( + "{name} input too large: {} bytes, max {MAX_JSON_BATCH_SIZE}", + input.len() + )); + } + } + + let bundle: IdentityBundle = serde_json::from_str(bundle_json) + .map_err(|e| format!("identity bundle is not valid JSON: {e}"))?; + let pinned_roots: Vec = serde_json::from_str(pinned_roots_json) + .map_err(|e| format!("pinned roots is not a JSON array of DIDs: {e}"))?; + + let trust = BundleTrust::parse(&bundle, now).map_err(|e| e.to_string())?; + + // Evidence-only (RT-005): the bundle never becomes its own trust anchor — + // otherwise the anchor and the evidence both come from the same + // attacker-supplied input. + if !pinned_roots.iter().any(|r| r == trust.root_did()) { + return Err(format!( + "bundle root {} is not independently pinned: a bundle is evidence \ + for a pinned root, never the source of the pin", + trust.root_did() + )); + } + + let (root_did, device_did) = commit_signer_trailers(commit_text).ok_or_else(|| { + "commit carries no Auths-Id/Auths-Device trailer — it was not signed by auths".to_string() + })?; + + // Stateless resolution: the bundle carries exactly one identity's KEL, so a + // trailer may only select that identity. Anything else needs an identity + // store and fails closed here. + for (role, did) in [("root", &root_did), ("device", &device_did)] { + if did != trust.root_did() { + return Err(format!( + "{role} KEL for {did} is not carried by the bundle (bundle \ + identity is {}); stateless verification resolves only the \ + bundle identity", + trust.root_did() + )); + } + } + + Ok(verify_commit_against_kel( + commit_text.as_bytes(), + trust.kel(), + trust.kel(), + &pinned_roots, + provider, + ) + .await) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::core::PublicKeyHex; + use crate::types::IdentityDID; + + const ROOT: &str = "did:keri:Eroot00000000000000000000000000000000000000"; + + fn test_bundle(did: &str, ts: DateTime, ttl: u64) -> IdentityBundle { + IdentityBundle { + identity_did: IdentityDID::new_unchecked(did.to_string()), + public_key_hex: PublicKeyHex::new_unchecked("ab".repeat(32)), + curve: auths_crypto::CurveType::P256, + attestation_chain: Vec::new(), + kel: Vec::new(), + kel_attachments: Vec::new(), + bundle_timestamp: ts, + max_valid_for_secs: ttl, + } + } + + fn fixed_time() -> DateTime { + DateTime::::from_timestamp(1_700_000_000, 0).expect("valid timestamp") + } + + #[test] + fn fresh_bundle_parses_to_its_root_did() { + let t = fixed_time(); + let bundle = test_bundle(ROOT, t, 3600); + let now = t + chrono::Duration::seconds(100); + let trust = BundleTrust::parse(&bundle, now).expect("fresh"); + assert_eq!(trust.root_did(), ROOT); + assert!(trust.kel().is_empty()); + } + + #[test] + fn stale_bundle_fails_closed() { + let t = fixed_time(); + let bundle = test_bundle(ROOT, t, 3600); + let now = t + chrono::Duration::seconds(7200); + assert!(matches!( + BundleTrust::parse(&bundle, now), + Err(BundleTrustError::Stale(_)) + )); + } + + #[test] + fn bundle_rejects_did_not_matching_its_kel_inception() { + // RT-005 self-certification: a bundle that pairs a DID with a KEL whose + // inception names a DIFFERENT controller must fail closed, so a bundle + // can never become the trust anchor for an attacker-authored KEL. + use auths_keri::{ + CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, + VersionString, compute_next_commitment, finalize_icp_event, + }; + let key = KeriPublicKey::ed25519(&[7u8; 32]).unwrap(); + let next = KeriPublicKey::ed25519(&[8u8; 32]).unwrap(); + let inception = finalize_icp_event(IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())], + nt: Threshold::Simple(1), + n: vec![compute_next_commitment(&next)], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + }) + .unwrap(); + let t = fixed_time(); + // Pair that inception with an unrelated `D…` DID it does not certify. + let mut bundle = test_bundle("did:keri:DAttackerKey", t, 3600); + bundle.kel = vec![Event::Icp(inception)]; + let now = t + chrono::Duration::seconds(100); + assert!(matches!( + BundleTrust::parse(&bundle, now), + Err(BundleTrustError::NotSelfCertifying(_)) + )); + } + + #[test] + fn bundle_with_stripped_attachments_fails_rt002() { + // RT-002: a KEL without its signature attachments is unauthenticated — + // refused outright, never degraded to a structural-only replay. + use auths_keri::{ + CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, + VersionString, compute_next_commitment, finalize_icp_event, + }; + let key = KeriPublicKey::ed25519(&[7u8; 32]).unwrap(); + let next = KeriPublicKey::ed25519(&[8u8; 32]).unwrap(); + let inception = finalize_icp_event(IcpEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())], + nt: Threshold::Simple(1), + n: vec![compute_next_commitment(&next)], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + }) + .unwrap(); + let did = format!("did:keri:{}", inception.d.as_str()); + let t = fixed_time(); + let mut bundle = test_bundle(&did, t, 3600); + bundle.kel = vec![Event::Icp(inception)]; + // kel_attachments stays empty: self-certifies (RT-005 passes) but + // cannot be authenticated (RT-002 fails). + let now = t + chrono::Duration::seconds(100); + assert!(matches!( + BundleTrust::parse(&bundle, now), + Err(BundleTrustError::KelUnauthenticated(_)) + )); + } + + #[tokio::test] + async fn unpinned_bundle_root_is_an_error_envelope() { + // Evidence-only: a coherent bundle whose root is not pinned must refuse + // before any signature work. + let t = fixed_time(); + let bundle = test_bundle(ROOT, t, 3600); + let bundle_json = serde_json::to_string(&bundle).unwrap(); + let out = verify_commit_with_bundle_json( + "tree abc\n\nsubject\n", + &bundle_json, + "[]", + t + chrono::Duration::seconds(10), + &auths_crypto::RingCryptoProvider, + ) + .await; + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["kind"], "error"); + assert!( + v["error"] + .as_str() + .unwrap() + .contains("not independently pinned"), + "unexpected error: {out}" + ); + } + + #[tokio::test] + async fn trailerless_commit_is_an_error_envelope() { + let t = fixed_time(); + let bundle = test_bundle(ROOT, t, 3600); + let bundle_json = serde_json::to_string(&bundle).unwrap(); + let pinned = format!("[\"{ROOT}\"]"); + let out = verify_commit_with_bundle_json( + "tree abc\n\nsubject, no trailers\n", + &bundle_json, + &pinned, + t + chrono::Duration::seconds(10), + &auths_crypto::RingCryptoProvider, + ) + .await; + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["kind"], "error"); + assert!( + v["error"].as_str().unwrap().contains("Auths-Id"), + "unexpected error: {out}" + ); + } + + #[tokio::test] + async fn foreign_trailer_did_fails_closed() { + // A commit claiming a signer the bundle does not carry cannot be + // resolved statelessly — refused, never silently verified against the + // bundle's unrelated KEL. + let t = fixed_time(); + let bundle = test_bundle(ROOT, t, 3600); + let bundle_json = serde_json::to_string(&bundle).unwrap(); + let pinned = format!("[\"{ROOT}\"]"); + let commit = + "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eother\nAuths-Device: did:keri:Eother\n"; + let out = verify_commit_with_bundle_json( + commit, + &bundle_json, + &pinned, + t + chrono::Duration::seconds(10), + &auths_crypto::RingCryptoProvider, + ) + .await; + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["kind"], "error"); + assert!( + v["error"] + .as_str() + .unwrap() + .contains("not carried by the bundle"), + "unexpected error: {out}" + ); + } +} diff --git a/crates/auths-verifier/src/commit_kel.rs b/crates/auths-verifier/src/commit_kel.rs index f0b5274e..f7c058c2 100644 --- a/crates/auths-verifier/src/commit_kel.rs +++ b/crates/auths-verifier/src/commit_kel.rs @@ -209,6 +209,46 @@ fn parse_anchor_seq(commit_bytes: &[u8]) -> Option { }) } +/// The commit trailer key naming the root identity (`Auths-Id: did:keri:…`). +pub const ID_TRAILER: &str = "Auths-Id"; + +/// The commit trailer key naming the signing device/agent (`Auths-Device: …`). +pub const DEVICE_TRAILER: &str = "Auths-Device"; + +/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device` +/// trailers. Returns `None` when either trailer is absent (a commit not signed +/// by `auths`). Keys match case-insensitively; the last occurrence wins (git +/// trailer semantics). These are in-band *claims* that select which KELs to +/// replay — the proof is always the signature + the pinned-root check. +/// +/// Args: +/// * `raw_commit`: The raw git commit object (headers + message). +/// +/// Usage: +/// ``` +/// use auths_verifier::commit_signer_trailers; +/// let commit = "tree abc\n\nfix\n\nAuths-Id: did:keri:Er\nAuths-Device: did:keri:Ed\n"; +/// assert_eq!( +/// commit_signer_trailers(commit), +/// Some(("did:keri:Er".into(), "did:keri:Ed".into())) +/// ); +/// ``` +pub fn commit_signer_trailers(raw_commit: &str) -> Option<(String, String)> { + let message = raw_commit + .split_once("\n\n") + .map(|(_, m)| m) + .unwrap_or(raw_commit); + let find = |key: &str| { + message.lines().rev().find_map(|line| { + let (k, v) = line.split_once(':')?; + k.trim() + .eq_ignore_ascii_case(key) + .then(|| v.trim().to_string()) + }) + }; + Some((find(ID_TRAILER)?, find(DEVICE_TRAILER)?)) +} + /// The CESR commit trailer key carrying the capability the commit exercises, checked /// against the agent's delegator-anchored scope. pub const SCOPE_TRAILER: &str = "Auths-Scope"; diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 3983198c..c3949d77 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -50,6 +50,8 @@ pub mod action; pub mod clock; pub mod commit; +/// Stateless commit verification against an identity bundle (CLI + WASM). +pub mod commit_bundle; pub mod commit_error; pub mod commit_kel; /// The single cross-boundary verify contract (JSON request → tagged verdict). @@ -142,9 +144,11 @@ pub use auths_keri::{ // Re-export commit verification types pub use commit::VerifiedCommit; +pub use commit_bundle::{BundleTrust, BundleTrustError, verify_commit_with_bundle_json}; pub use commit_kel::{ - ANCHOR_SEQ_TRAILER, CommitVerdict, SCOPE_TRAILER, VerifierWitnessPolicy, WitnessGateStatus, - WitnessedVerdict, anchor_seq_trailer, scope_trailer, verify_commit_against_kel, + ANCHOR_SEQ_TRAILER, CommitVerdict, DEVICE_TRAILER, ID_TRAILER, SCOPE_TRAILER, + VerifierWitnessPolicy, WitnessGateStatus, WitnessedVerdict, anchor_seq_trailer, + commit_signer_trailers, scope_trailer, verify_commit_against_kel, verify_commit_against_kel_scoped, verify_commit_against_kel_witnessed, }; pub use ssh_sig::{SshKeyType, SshSigEnvelope}; diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index cac0d385..772b1c1a 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -543,6 +543,43 @@ pub fn wasm_verify_org_bundle( ) } +/// Verify a raw git commit object against an identity bundle, fully stateless, +/// returning the tagged JSON envelope (`kind`: `"verdict"` | `"error"`). +/// +/// This is the "commit ← maintainer" leg in the browser: the bundle's KEL is +/// freshness-checked, self-certification-checked (RT-005), and +/// signature-authenticated (RT-002), the bundle root must already be in +/// `pinned_roots_json` (evidence-only), and the commit's SSH signature is +/// verified in-process against the replayed KEL — the same +/// [`verify_commit_against_kel`](crate::verify_commit_against_kel) verdict the +/// native CLI computes, with no git and no identity store. +/// +/// Args: +/// * `commit_text`: The raw commit object (`git cat-file commit ` bytes). +/// * `bundle_json`: The identity bundle JSON (`auths id export-bundle`). +/// * `pinned_roots_json`: JSON array of independently pinned `did:keri:` roots. +/// +/// Usage (TypeScript): +/// ```ignore +/// const verdict = JSON.parse(await verifyCommitJson(commit, bundle, '["did:keri:E…"]')); +/// const commitLegHolds = verdict.kind === "verdict" && verdict.valid; +/// ``` +#[wasm_bindgen(js_name = verifyCommitJson)] +pub async fn wasm_verify_commit_json( + commit_text: &str, + bundle_json: &str, + pinned_roots_json: &str, +) -> String { + crate::commit_bundle::verify_commit_with_bundle_json( + commit_text, + bundle_json, + pinned_roots_json, + SystemClock.now(), + &provider(), + ) + .await +} + /// Verify an **offline compliance evidence pack** with zero network, returning /// the tagged verdict envelope (`kind`: `"verdicts"` | `"error"`) as a JSON /// string — one verdict per evidence row. From ee32fa28ccc0ccba751916e5a502e4085b7f4ac6 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 23:19:08 +0100 Subject: [PATCH 16/32] feat(agents): record a KEL-anchored delegation attestation on agent add agents::add_scoped now issues a signed attestation for every delegated agent (issuer = delegating root, subject = the agent, co-signed by the agent's key) and persists + anchors it through the same anchor_and_persist_via_backend path device links use. Programmatically minted identity bundles now carry a walkable attestation chain instead of only KEL events, so stateless chain verifiers (verifyChainJson) get a second, independent provenance leg. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .../src/domains/agents/delegation.rs | 93 ++++++++++++++++++- crates/auths-sdk/src/domains/agents/error.rs | 17 ++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/auths-sdk/src/domains/agents/delegation.rs b/crates/auths-sdk/src/domains/agents/delegation.rs index 1e821aac..9b554c3c 100644 --- a/crates/auths-sdk/src/domains/agents/delegation.rs +++ b/crates/auths-sdk/src/domains/agents/delegation.rs @@ -9,15 +9,19 @@ use std::ops::ControlFlow; use std::sync::Arc; +use auths_core::signing::StorageSigner; use auths_core::storage::keychain::{IdentityDID, KeyAlias, extract_public_key_bytes}; -use auths_id::keri::Event; +use auths_id::attestation::create::{AttestationInput, create_signed_attestation}; use auths_id::keri::delegation::{ DelegatedRole, incept_delegated_device, list_delegated_devices, mark_agent_scope, mark_delegated_agent, read_agent_scope, revoke_delegated_device, revoke_delegated_devices_batch, rotate_delegated_device, }; -use auths_id::keri::parse_did_keri; +use auths_id::keri::{Event, anchor_and_persist_via_backend, parse_did_keri}; +use auths_id::storage::git_refs::AttestationMetadata; use auths_keri::{AgentScope, Capability}; +use auths_verifier::core::SignerType; +use auths_verifier::types::CanonicalDid; use crate::context::AuthsContext; use crate::domains::agents::error::AgentError; @@ -165,12 +169,97 @@ pub fn add_scoped( .map_err(AgentError::DelegationError)?; } + // Record the delegation as a signed attestation (issuer = root, subject = + // agent), persisted and KEL-anchored through the same path device links + // use. Exported identity bundles then carry a walkable delegation chain — + // a provenance leg independent of the KEL events themselves. + record_delegation_attestation( + ctx, + &managed.controller_did, + &managed.storage_id, + root_alias, + &root_prefix, + agent_alias, + &agent.device_did, + expires_at, + )?; + Ok(AgentDelegationResult { agent_did: agent.device_did.as_str().to_string(), agent_prefix: agent.device_prefix.as_str().to_string(), }) } +/// Sign and anchor the attestation for a freshly delegated agent. +/// +/// The delegating root issues (and the agent's key co-signs) an attestation over +/// the agent's public key, then persists and KEL-anchors it through +/// [`anchor_and_persist_via_backend`] — the same single path device links use. +/// This is what puts a programmatically delegated agent into the identity's +/// attestation chain. +#[allow(clippy::too_many_arguments)] +fn record_delegation_attestation( + ctx: &AuthsContext, + identity_did: &IdentityDID, + rid: &str, + root_alias: &KeyAlias, + root_prefix: &auths_id::keri::types::Prefix, + agent_alias: &KeyAlias, + agent_did: &IdentityDID, + expires_at: Option, +) -> Result<(), AgentError> { + let (agent_pk, agent_pk_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + agent_alias, + ctx.passphrase_provider.as_ref(), + ) + .map_err(AgentError::CryptoError)?; + let now = ctx.clock.now(); + let meta = AttestationMetadata { + timestamp: Some(now), + expires_at: expires_at.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)), + note: None, + }; + let subject = CanonicalDid::from(agent_did.clone()); + let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + let attestation = create_signed_attestation( + now, + AttestationInput { + rid, + identity_did, + subject: &subject, + device_public_key: &agent_pk, + device_curve: agent_pk_curve, + payload: None, + meta: &meta, + identity_alias: Some(root_alias), + device_alias: Some(agent_alias), + delegated_by: None, + commit_sha: None, + signer_type: Some(SignerType::Agent), + oidc_binding: None, + }, + &signer, + ctx.passphrase_provider.as_ref(), + ) + .map_err(AgentError::AttestationError)?; + let mut batch = auths_id::storage::registry::backend::AtomicWriteBatch::new(); + batch.stage_attestation(attestation.clone()); + anchor_and_persist_via_backend( + ctx.registry.as_ref(), + &signer, + root_alias, + ctx.passphrase_provider.as_ref(), + root_prefix, + &attestation, + &mut batch, + &ctx.witness_params(), + now, + ) + .map_err(AgentError::AnchorError)?; + Ok(()) +} + /// Collect a KEL into a `Vec` (oldest first) via the registry. fn collect_kel(ctx: &AuthsContext, prefix: &auths_id::keri::types::Prefix) -> Vec { let mut events = Vec::new(); diff --git a/crates/auths-sdk/src/domains/agents/error.rs b/crates/auths-sdk/src/domains/agents/error.rs index befc2425..b50443a3 100644 --- a/crates/auths-sdk/src/domains/agents/error.rs +++ b/crates/auths-sdk/src/domains/agents/error.rs @@ -62,6 +62,15 @@ pub enum AgentError { /// Authoring or anchoring the delegated agent identifier failed. #[error("agent delegation failed: {0}")] DelegationError(#[source] auths_id::error::InitError), + + /// Creating the signed delegation attestation (the chain link issued by the + /// delegator over the agent's key) failed. + #[error("agent delegation attestation failed: {0}")] + AttestationError(#[source] auths_verifier::error::AttestationError), + + /// Persisting or KEL-anchoring the delegation attestation failed. + #[error("agent delegation attestation anchoring failed: {0}")] + AnchorError(#[source] auths_id::keri::AnchorError), } impl From for AgentError { @@ -80,6 +89,8 @@ impl AuthsErrorInfo for AgentError { Self::AgentNotFound { .. } => "AUTHS-E5314", Self::Revoked { .. } => "AUTHS-E5315", Self::OutsideDelegatorScope { .. } => "AUTHS-E5316", + Self::AttestationError(_) => "AUTHS-E5317", + Self::AnchorError(_) => "AUTHS-E5318", } } @@ -104,6 +115,12 @@ impl AuthsErrorInfo for AgentError { Self::OutsideDelegatorScope { .. } => { Some("Narrow the agent's --scope to a subset of the delegator's own capabilities") } + Self::AttestationError(_) => { + Some("The delegation attestation could not be signed; check the keychain aliases") + } + Self::AnchorError(_) => Some( + "The delegation attestation could not be anchored; check the root identity's KEL", + ), } } } From 4747df217295d68472f57a5fbd07747f37564d82 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 12 Jun 2026 23:57:52 +0100 Subject: [PATCH 17/32] =?UTF-8?q?feat(rp):=20registry-sync=20surface=20?= =?UTF-8?q?=E2=80=94=20RegistrySync=20port,=20RegistryWatcher=20poll=20loo?= =?UTF-8?q?p,=20git=20fetch=20adapter=20for=20refs/auths/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relying party verifies against its own registry replica; revocation freshness is the pull cadence. auths-rp now owns that cadence: the RegistrySync port (one pull, typed SyncOutcome), the RegistryWatcher background loop, and GitRegistrySync (feature git-sync) — a non-forced fetch of refs/auths/* that fails loud on a rewound remote instead of rolling the replica back below a revocation. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- Cargo.lock | 3 + crates/auths-rp/Cargo.toml | 12 + crates/auths-rp/src/lib.rs | 9 +- crates/auths-rp/src/registry_sync.rs | 554 +++++++++++++++++++++++++++ 4 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 crates/auths-rp/src/registry_sync.rs diff --git a/Cargo.lock b/Cargo.lock index 0370e5db..00c59edd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,13 +828,16 @@ dependencies = [ name = "auths-rp" version = "0.1.3" dependencies = [ + "auths-rp", "auths-verifier", "base64", "chrono", + "git2", "parking_lot", "ring", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", ] diff --git a/crates/auths-rp/Cargo.toml b/crates/auths-rp/Cargo.toml index afbdd530..41a3de28 100644 --- a/crates/auths-rp/Cargo.toml +++ b/crates/auths-rp/Cargo.toml @@ -11,11 +11,23 @@ homepage.workspace = true auths-verifier.workspace = true base64.workspace = true chrono = { version = "0.4", features = ["serde"] } +git2 = { workspace = true, optional = true } parking_lot.workspace = true ring.workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror.workspace = true +[features] +# The shipped git adapter for the registry-sync port: pulls `refs/auths/*` +# from a remote into the relying party's own repository. Off by default so +# wire-parsing consumers don't link libgit2. +git-sync = ["dep:git2"] + +[dev-dependencies] +# Self-dependency turns git-sync on for this crate's own test builds. +auths-rp = { path = ".", features = ["git-sync"] } +tempfile = "3" + [lints] workspace = true diff --git a/crates/auths-rp/src/lib.rs b/crates/auths-rp/src/lib.rs index 17ea8fc2..6fa045b5 100644 --- a/crates/auths-rp/src/lib.rs +++ b/crates/auths-rp/src/lib.rs @@ -9,7 +9,8 @@ //! [`WirePresentation::parse`]. //! //! The actual cryptographic check is the shipped, pure `auths_verifier::verify_presentation`; -//! this crate is the transport + (in later tasks) the Axum middleware and challenge store. +//! this crate is the transport + the challenge store + the registry-sync surface +//! ([`registry_sync`]) that keeps a relying party's registry replica current over a wire. use auths_verifier::{PresentationBinding, PresentationEnvelope}; use base64::Engine; @@ -17,12 +18,18 @@ use chrono::{DateTime, Utc}; pub mod challenge; pub mod principal; +pub mod registry_sync; pub use challenge::{ ChallengeError, ChallengeStore, DEFAULT_CHALLENGE_TTL_SECS, ExpectedNonce, InMemoryChallengeStore, IssuedChallenge, }; pub use principal::{Denied, Grant, VerifiedPrincipal}; +#[cfg(feature = "git-sync")] +pub use registry_sync::GitRegistrySync; +pub use registry_sync::{ + AUTHS_REFS_GLOB, RegistrySync, RegistryWatcher, RemoteUrl, SyncError, SyncOutcome, SyncResult, +}; /// The `Authorization` scheme name carrying a presentation (RFC 7235 `auth-scheme`). pub const AUTHS_PRESENTATION_SCHEME: &str = "Auths-Presentation"; diff --git a/crates/auths-rp/src/registry_sync.rs b/crates/auths-rp/src/registry_sync.rs new file mode 100644 index 00000000..0cabac6f --- /dev/null +++ b/crates/auths-rp/src/registry_sync.rs @@ -0,0 +1,554 @@ +//! Relying-party registry synchronization — revocation propagation over a wire. +//! +//! A relying party verifies presentations against its **own** registry replica; revocation +//! freshness is the pull cadence (see `auths_sdk::authenticate_presentation`). This module is +//! that cadence, shaped as a port + adapter + loop: +//! +//! - [`RegistrySync`] is the port: one pull attempt of the remote registry into the local +//! replica, reported as a typed [`SyncOutcome`]. +//! - [`RegistryWatcher`] is the loop: a background thread that calls the port on a fixed +//! interval until stopped, handing every result to an observer callback. +//! - `GitRegistrySync` (feature `git-sync`) is the shipped adapter: a git fetch of +//! [`AUTHS_REFS_GLOB`] (`refs/auths/*`) from the authoritative remote — the same packed +//! refs the operator's registry writes — into the relying party's repository. +//! +//! Trust stays in verification, never in transport: a fetched KEL/TEL is still replayed and +//! signature-checked by the verifier on every request, so a hostile remote cannot mint +//! validity. The transport itself is fail-closed against rollback: the fetch refspec is +//! **non-forced**, so a remote that rewinds its history (e.g. to resurrect a revoked +//! credential by serving an older tip) is rejected and the replica keeps the newest state it +//! has seen. + +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::{Condvar, Mutex}; + +/// The glob covering every ref the auths registry publishes (KELs, TELs, credentials, +/// identity metadata all live under the packed registry ref inside this namespace). +pub const AUTHS_REFS_GLOB: &str = "refs/auths/*"; + +/// A non-empty git remote URL the relying party pulls its registry from. +/// +/// Constructed only via [`RemoteUrl::parse`], so an empty remote is unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteUrl(String); + +impl RemoteUrl { + /// Parse a non-empty remote URL (e.g. `https://…`, `ssh://…`, `file:///srv/registry`). + /// + /// Args: + /// * `s`: The git remote URL of the authoritative registry. + /// + /// Usage: + /// ``` + /// # use auths_rp::RemoteUrl; + /// assert!(RemoteUrl::parse("file:///srv/registry").is_ok()); + /// assert!(RemoteUrl::parse("").is_err()); + /// ``` + pub fn parse(s: &str) -> Result { + if s.is_empty() { + return Err(SyncError::EmptyRemoteUrl); + } + Ok(Self(s.to_string())) + } + + /// The remote URL as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for RemoteUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// What one sync attempt did to the local replica. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncOutcome { + /// At least one `refs/auths/*` ref moved — new registry events arrived. + Updated { + /// How many refs changed under [`AUTHS_REFS_GLOB`]. + refs_changed: usize, + }, + /// The replica already matched the remote. + Unchanged, +} + +impl std::fmt::Display for SyncOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SyncOutcome::Updated { refs_changed } => { + write!(f, "updated ({refs_changed} ref(s) moved)") + } + SyncOutcome::Unchanged => f.write_str("unchanged"), + } + } +} + +/// Registry-sync errors (`thiserror`, closed at the port boundary). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SyncError { + /// The remote URL was empty. + #[error("empty registry remote URL")] + EmptyRemoteUrl, + /// The local replica repository could not be opened, initialized, or read. + #[error("registry replica repository error: {detail}")] + Repository { + /// The underlying error text. + detail: String, + }, + /// The fetch from the remote failed (unreachable, refused non-fast-forward, …). + #[error("registry fetch from '{url}' failed: {detail}")] + Transport { + /// The remote URL the fetch targeted. + url: String, + /// The underlying error text. + detail: String, + }, +} + +/// One sync attempt's result, as handed to a [`RegistryWatcher`] observer. +pub type SyncResult = Result; + +/// The registry-sync port: one pull of the authoritative registry into the local replica. +/// +/// `GitRegistrySync` (feature `git-sync`) is the shipped adapter; a deployment with a +/// different distribution channel (witness receipts, an object store, …) supplies its own +/// implementation and the [`RegistryWatcher`] loop works unchanged. +pub trait RegistrySync: Send + Sync { + /// Pull the remote registry once, reporting whether the replica changed. + fn sync(&self) -> SyncResult; + + /// The remote this sync pulls from (for logging/diagnostics). + fn remote_url(&self) -> &RemoteUrl; +} + +/// Coordinated stop flag for the watcher thread (interruptible interval sleep). +struct StopFlag { + stopped: Mutex, + bell: Condvar, +} + +impl StopFlag { + fn new() -> Self { + Self { + stopped: Mutex::new(false), + bell: Condvar::new(), + } + } + + /// Sleep up to `interval`, returning early — and `true` — once stop is requested. + fn wait(&self, interval: Duration) -> bool { + let mut stopped = self.stopped.lock(); + if *stopped { + return true; + } + self.bell.wait_for(&mut stopped, interval); + *stopped + } + + fn raise(&self) { + *self.stopped.lock() = true; + self.bell.notify_all(); + } +} + +/// The registry watch loop: polls a [`RegistrySync`] on a fixed interval, on its own thread. +/// +/// Every poll's [`SyncResult`] goes to the observer callback (the host decides how to log +/// an update or a transient fetch failure; the loop itself never prints). The first poll +/// happens one `interval` after spawn — do an explicit blocking [`RegistrySync::sync`] +/// first when the host must boot with a current replica. +/// +/// Dropping the watcher signals the thread to stop without blocking; [`RegistryWatcher::stop`] +/// signals and joins. +/// +/// Usage: +/// ```ignore +/// let sync: Arc = Arc::new(GitRegistrySync::open_or_init(replica, remote)?); +/// sync.sync()?; // prime the replica before serving +/// let watcher = RegistryWatcher::spawn(sync, Duration::from_millis(250), Box::new(|r| log(r)))?; +/// ``` +pub struct RegistryWatcher { + stop: Arc, + handle: Option>, +} + +impl RegistryWatcher { + /// Spawn the watch thread: poll `sync` every `interval`, reporting each result to + /// `on_sync`, until stopped. + /// + /// Args: + /// * `sync`: The registry-sync port to poll. + /// * `interval`: The pull cadence (also the worst-case revocation propagation lag). + /// * `on_sync`: Observer for every poll result (logging, metrics). + pub fn spawn( + sync: Arc, + interval: Duration, + on_sync: Box, + ) -> std::io::Result { + let stop = Arc::new(StopFlag::new()); + let thread_stop = Arc::clone(&stop); + let handle = std::thread::Builder::new() + .name("auths-rp-registry-watcher".to_string()) + .spawn(move || poll_registry(&*sync, interval, &*on_sync, &thread_stop))?; + Ok(Self { + stop, + handle: Some(handle), + }) + } + + /// Signal the watch thread to stop and join it. + pub fn stop(mut self) { + self.stop.raise(); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for RegistryWatcher { + fn drop(&mut self) { + // Signal without joining: drop must not block on an in-flight fetch. + self.stop.raise(); + } +} + +/// The watch-loop body: interval-sleep (interruptible), then one sync, until stopped. +fn poll_registry( + sync: &dyn RegistrySync, + interval: Duration, + on_sync: &(dyn Fn(&SyncResult) + Send), + stop: &StopFlag, +) { + loop { + if stop.wait(interval) { + return; + } + let result = sync.sync(); + on_sync(&result); + } +} + +#[cfg(feature = "git-sync")] +pub use git::GitRegistrySync; + +#[cfg(feature = "git-sync")] +mod git { + //! The shipped git adapter: fetch `refs/auths/*` from the remote into the replica. + + use std::collections::BTreeMap; + use std::path::PathBuf; + + use super::{AUTHS_REFS_GLOB, RegistrySync, RemoteUrl, SyncError, SyncOutcome, SyncResult}; + + /// A registry replica kept current by git fetch — the shipped [`RegistrySync`] adapter. + /// + /// Holds the replica path and remote URL only; the repository is opened per sync, so the + /// adapter is `Send + Sync` and the replica is always read fresh (matching the + /// verify-side per-request re-read discipline). The fetch refspec is non-forced: + /// a non-fast-forward remote (a rewound registry) is an error, never a rollback. + pub struct GitRegistrySync { + local_repo: PathBuf, + remote: RemoteUrl, + } + + impl GitRegistrySync { + /// Open the replica repository at `local_repo`, initializing an empty one if absent. + /// + /// Args: + /// * `local_repo`: The relying party's own registry repository path. + /// * `remote`: The authoritative registry remote to pull from. + /// + /// Usage: + /// ```ignore + /// let sync = GitRegistrySync::open_or_init("/var/lib/rp/registry", remote)?; + /// ``` + pub fn open_or_init( + local_repo: impl Into, + remote: RemoteUrl, + ) -> Result { + let local_repo = local_repo.into(); + if git2::Repository::open(&local_repo).is_err() { + git2::Repository::init(&local_repo).map_err(|e| SyncError::Repository { + detail: format!("init {} failed: {e}", local_repo.display()), + })?; + } + Ok(Self { local_repo, remote }) + } + + /// The replica repository path. + pub fn local_repo(&self) -> &std::path::Path { + &self.local_repo + } + } + + impl RegistrySync for GitRegistrySync { + fn sync(&self) -> SyncResult { + let repo = + git2::Repository::open(&self.local_repo).map_err(|e| SyncError::Repository { + detail: format!("open {} failed: {e}", self.local_repo.display()), + })?; + let before = auths_ref_tips(&repo)?; + + let transport = |e: git2::Error| SyncError::Transport { + url: self.remote.as_str().to_string(), + detail: e.to_string(), + }; + let mut remote = repo + .remote_anonymous(self.remote.as_str()) + .map_err(transport)?; + remote.connect(git2::Direction::Fetch).map_err(transport)?; + let advertised: BTreeMap = remote + .list() + .map_err(transport)? + .iter() + .filter(|head| is_auths_ref(head.name())) + .map(|head| (head.name().to_string(), head.oid())) + .collect(); + // Non-forced refspec (no leading '+'): libgit2 silently SKIPS any + // non-fast-forward update, so the replica can never be rolled back. + let refspec = format!("{AUTHS_REFS_GLOB}:{AUTHS_REFS_GLOB}"); + remote + .fetch(&[refspec.as_str()], None, None) + .map_err(transport)?; + + let after = auths_ref_tips(&repo)?; + // A skipped update means the remote diverged from (rewound below) the + // replica — e.g. serving a pre-revocation registry. Silence here would be + // silent staleness forever; fail loud instead. + for (name, oid) in &advertised { + if after.get(name) != Some(oid) { + return Err(SyncError::Transport { + url: self.remote.as_str().to_string(), + detail: format!( + "remote rewound {name} (non-fast-forward); refusing rollback" + ), + }); + } + } + let refs_changed = after + .iter() + .filter(|(name, oid)| before.get(*name) != Some(oid)) + .count() + + before.keys().filter(|k| !after.contains_key(*k)).count(); + if refs_changed == 0 { + Ok(SyncOutcome::Unchanged) + } else { + Ok(SyncOutcome::Updated { refs_changed }) + } + } + + fn remote_url(&self) -> &RemoteUrl { + &self.remote + } + } + + /// Whether a ref name falls under [`AUTHS_REFS_GLOB`] (prefix derived from the glob). + fn is_auths_ref(name: &str) -> bool { + name.starts_with(AUTHS_REFS_GLOB.trim_end_matches('*')) + } + + /// Snapshot the OIDs of every ref under [`AUTHS_REFS_GLOB`]. + fn auths_ref_tips(repo: &git2::Repository) -> Result, SyncError> { + let mut tips = BTreeMap::new(); + let refs = repo + .references_glob(AUTHS_REFS_GLOB) + .map_err(|e| SyncError::Repository { + detail: format!("listing {AUTHS_REFS_GLOB} failed: {e}"), + })?; + for r in refs { + let r = r.map_err(|e| SyncError::Repository { + detail: format!("reading ref failed: {e}"), + })?; + if let (Some(name), Some(oid)) = (r.name(), r.target()) { + tips.insert(name.to_string(), oid); + } + } + Ok(tips) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[test] + fn empty_remote_url_rejected() { + assert!(matches!( + RemoteUrl::parse(""), + Err(SyncError::EmptyRemoteUrl) + )); + assert_eq!( + RemoteUrl::parse("file:///srv/reg").unwrap().as_str(), + "file:///srv/reg" + ); + } + + struct CountingSync { + url: RemoteUrl, + calls: AtomicUsize, + } + + impl CountingSync { + fn new() -> Self { + Self { + url: RemoteUrl::parse("file:///dev/null").unwrap(), + calls: AtomicUsize::new(0), + } + } + } + + impl RegistrySync for CountingSync { + fn sync(&self) -> SyncResult { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(SyncOutcome::Unchanged) + } + + fn remote_url(&self) -> &RemoteUrl { + &self.url + } + } + + #[test] + fn watcher_polls_then_stops() { + let sync = Arc::new(CountingSync::new()); + let observed = Arc::new(AtomicUsize::new(0)); + let observed_in_cb = Arc::clone(&observed); + let watcher = RegistryWatcher::spawn( + Arc::clone(&sync) as Arc, + Duration::from_millis(5), + Box::new(move |result| { + assert!(result.is_ok()); + observed_in_cb.fetch_add(1, Ordering::SeqCst); + }), + ) + .unwrap(); + + // Wait (bounded) until at least two polls happened. + let mut spins = 0; + while sync.calls.load(Ordering::SeqCst) < 2 && spins < 400 { + std::thread::sleep(Duration::from_millis(5)); + spins += 1; + } + assert!( + sync.calls.load(Ordering::SeqCst) >= 2, + "watcher never polled" + ); + + watcher.stop(); + let after_stop = sync.calls.load(Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(40)); + assert_eq!( + sync.calls.load(Ordering::SeqCst), + after_stop, + "watcher kept polling after stop" + ); + assert_eq!(observed.load(Ordering::SeqCst), after_stop); + } + + #[cfg(feature = "git-sync")] + mod git_sync { + use super::super::{GitRegistrySync, RegistrySync, RemoteUrl, SyncError, SyncOutcome}; + + const REGISTRY_REF: &str = "refs/auths/registry"; + + /// Commit an empty tree onto `reference` (parented on its current tip, if any). + fn commit_on(repo: &git2::Repository, reference: &str, message: &str) -> git2::Oid { + let sig = git2::Signature::now("test", "test@example.com").unwrap(); + let tree_id = repo.treebuilder(None).unwrap().write().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let parent = repo + .find_reference(reference) + .ok() + .and_then(|r| r.peel_to_commit().ok()); + let parents: Vec<&git2::Commit> = parent.iter().collect(); + repo.commit(Some(reference), &sig, &sig, message, &tree, &parents) + .unwrap() + } + + fn origin_with_one_commit() -> (tempfile::TempDir, git2::Repository) { + let dir = tempfile::TempDir::new().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + commit_on(&repo, REGISTRY_REF, "registry event 1"); + (dir, repo) + } + + fn file_url(dir: &tempfile::TempDir) -> RemoteUrl { + RemoteUrl::parse(&format!("file://{}", dir.path().display())).unwrap() + } + + #[test] + fn pulls_updates_then_reports_unchanged() { + let (origin_dir, origin) = origin_with_one_commit(); + let replica_dir = tempfile::TempDir::new().unwrap(); + let replica = replica_dir.path().join("rp-registry"); + + let sync = GitRegistrySync::open_or_init(&replica, file_url(&origin_dir)).unwrap(); + assert!(matches!( + sync.sync().unwrap(), + SyncOutcome::Updated { refs_changed: 1 } + )); + assert!(matches!(sync.sync().unwrap(), SyncOutcome::Unchanged)); + + // A new registry event on the origin propagates on the next pull. + let new_tip = commit_on(&origin, REGISTRY_REF, "registry event 2 (revocation)"); + assert!(matches!( + sync.sync().unwrap(), + SyncOutcome::Updated { refs_changed: 1 } + )); + let replica_repo = git2::Repository::open(&replica).unwrap(); + let replica_tip = replica_repo + .find_reference(REGISTRY_REF) + .unwrap() + .target() + .unwrap(); + assert_eq!( + replica_tip, new_tip, + "replica tip must match the origin tip" + ); + } + + #[test] + fn rewound_remote_is_rejected_not_rolled_back() { + let (origin_dir, origin) = origin_with_one_commit(); + let replica_dir = tempfile::TempDir::new().unwrap(); + let replica = replica_dir.path().join("rp-registry"); + + let sync = GitRegistrySync::open_or_init(&replica, file_url(&origin_dir)).unwrap(); + commit_on(&origin, REGISTRY_REF, "registry event 2 (revocation)"); + sync.sync().unwrap(); + let replica_repo = git2::Repository::open(&replica).unwrap(); + let tip_before = replica_repo + .find_reference(REGISTRY_REF) + .unwrap() + .target() + .unwrap(); + + // The remote rewrites history to an unrelated root — e.g. trying to serve a + // pre-revocation registry. The non-forced fetch must refuse the rollback. + origin + .find_reference(REGISTRY_REF) + .unwrap() + .delete() + .unwrap(); + commit_on(&origin, REGISTRY_REF, "rewritten history"); + let err = sync.sync().unwrap_err(); + assert!(matches!(err, SyncError::Transport { .. }), "got: {err:?}"); + + let tip_after = git2::Repository::open(&replica) + .unwrap() + .find_reference(REGISTRY_REF) + .unwrap() + .target() + .unwrap(); + assert_eq!(tip_before, tip_after, "replica must keep the newest state"); + } + } +} From d6c7b3520a8b84dd0407fb220731b50c70c6386c Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 01:02:08 +0100 Subject: [PATCH 18/32] =?UTF-8?q?feat(LTL-8):=20registry=20propagation=20o?= =?UTF-8?q?ver=20git=20remotes=20=E2=80=94=20auths=20registry=20push/pull?= =?UTF-8?q?=20with=20validated=20KEL=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push publishes refs/auths/registry fast-forward-only (append-only registry; divergence refused, never forced). pull fetches the remote registry into a throwaway snapshot and merges every KEL into the local trusted floor under four guards: prefix binding, authenticated replay (validate_signed_kel with delegator seal lookup), rollback floor, and same-sequence fork refusal — nothing from a remote persists unvalidated, and signature attachments travel with the events. One capped untrusted-read primitive (collect_kel_capped) now serves both the remote KEL fetch and the merge. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/cli.rs | 3 + crates/auths-cli/src/commands/mod.rs | 1 + crates/auths-cli/src/commands/registry.rs | 141 ++++++++ crates/auths-cli/src/main.rs | 1 + crates/auths-id/src/keri/kel_resolver.rs | 73 +++++ crates/auths-id/src/keri/mod.rs | 7 +- crates/auths-id/src/keri/sync.rs | 309 ++++++++++++++++++ crates/auths-sdk/src/storage.rs | 7 +- crates/auths-storage/src/git/mod.rs | 6 +- crates/auths-storage/src/git/remote.rs | 103 +++--- crates/auths-storage/src/git/sync.rs | 214 ++++++++++++ .../tests/cases/mock_ed25519_keypairs.rs | 7 + crates/auths-storage/tests/cases/mod.rs | 1 + .../tests/cases/registry_sync.rs | 241 ++++++++++++++ 14 files changed, 1052 insertions(+), 62 deletions(-) create mode 100644 crates/auths-cli/src/commands/registry.rs create mode 100644 crates/auths-id/src/keri/sync.rs create mode 100644 crates/auths-storage/src/git/sync.rs create mode 100644 crates/auths-storage/tests/cases/registry_sync.rs diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index e001c6d9..0c5b484d 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -32,6 +32,7 @@ use crate::commands::namespace::NamespaceCommand; use crate::commands::org::OrgCommand; use crate::commands::policy::PolicyCommand; use crate::commands::publish::PublishCommand; +use crate::commands::registry::RegistryCommand; use crate::commands::reset::ResetCommand; use crate::commands::scim::ScimCommand; use crate::commands::sign::SignCommand; @@ -137,6 +138,8 @@ pub enum RootCommand { Audit(AuditCommand), #[command(hide = true)] Auth(AuthCommand), + #[command(hide = true)] + Registry(RegistryCommand), // ── Internal (visible via --help-all) ── #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index d4b6a6d5..75848594 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -34,6 +34,7 @@ pub mod org; pub mod policy; pub mod provision; pub mod publish; +pub mod registry; pub mod reset; pub mod scim; pub mod sign; diff --git a/crates/auths-cli/src/commands/registry.rs b/crates/auths-cli/src/commands/registry.rs new file mode 100644 index 00000000..16d7fc2f --- /dev/null +++ b/crates/auths-cli/src/commands/registry.rs @@ -0,0 +1,141 @@ +//! Registry propagation commands: push/pull `refs/auths/registry` over a git +//! remote, so a second machine sees this machine's identity events (rotations, +//! revocations, new devices) over a wire instead of a shared filesystem. + +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use clap::{Parser, Subcommand}; +use serde::Serialize; + +use auths_sdk::storage::{MergeOutcome, MergedKel, PushOutcome, pull_registry, push_registry}; + +use crate::commands::executable::ExecutableCommand; +use crate::config::CliConfig; +use crate::ux::format::{JsonResponse, Output, is_json_mode}; + +/// Sync the local identity registry with a git remote. +#[derive(Parser, Debug, Clone)] +#[command( + name = "registry", + about = "Push/pull the identity registry to/from a git remote", + after_help = "The registry travels as the packed git ref refs/auths/registry, so any +git remote (a bare repo over file://, git://, ssh://, https://) can carry it +between machines. Push is fast-forward-only (the registry is append-only). +Pull authenticates every fetched KEL (prefix binding, signature replay, +fork refusal) before merging it into the local registry. + +Examples: + auths registry push git://wire.local/registry.git # publish this machine's registry + auths registry pull git://wire.local/registry.git # merge another machine's events + +Related: + auths id show — The identity whose events the registry carries + auths device list — Devices learned from merged registries" +)] +pub struct RegistryCommand { + #[command(subcommand)] + pub subcommand: RegistrySubcommand, +} + +/// Registry sync subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum RegistrySubcommand { + /// Publish the local registry ref to a git remote (fast-forward only). + Push { + /// Git remote URL (file://, git://, ssh://, https://). + #[arg(value_name = "REMOTE")] + remote: String, + }, + /// Fetch a remote registry and merge its authenticated KELs locally. + Pull { + /// Git remote URL (file://, git://, ssh://, https://). + #[arg(value_name = "REMOTE")] + remote: String, + }, +} + +#[derive(Debug, Serialize)] +struct PushReport { + remote: String, + outcome: PushOutcome, +} + +#[derive(Debug, Serialize)] +struct PullReport { + remote: String, + merged: Vec, +} + +impl ExecutableCommand for RegistryCommand { + fn execute(&self, ctx: &CliConfig) -> Result<()> { + let registry_root = resolve_registry_root(ctx)?; + match &self.subcommand { + RegistrySubcommand::Push { remote } => { + let outcome = push_registry(®istry_root, remote)?; + if is_json_mode() { + JsonResponse::success( + "registry push", + PushReport { + remote: remote.clone(), + outcome, + }, + ) + .print()?; + } else { + let out = Output::new(); + match outcome { + PushOutcome::Updated => { + out.print_success(&format!("Registry pushed to {remote}")); + } + PushOutcome::AlreadyCurrent => { + out.print_info(&format!("Remote {remote} is already current")); + } + } + } + Ok(()) + } + RegistrySubcommand::Pull { remote } => { + let merged = pull_registry(®istry_root, remote)?; + if is_json_mode() { + JsonResponse::success( + "registry pull", + PullReport { + remote: remote.clone(), + merged, + }, + ) + .print()?; + } else { + let out = Output::new(); + out.print_success(&format!("Registry pulled from {remote}")); + for kel in &merged { + let line = match &kel.outcome { + MergeOutcome::Imported { events } => { + format!("{} imported ({events} events)", kel.prefix) + } + MergeOutcome::Advanced { events } => { + format!("{} advanced (+{events} events)", kel.prefix) + } + MergeOutcome::AlreadyCurrent => { + format!("{} already current", kel.prefix) + } + }; + out.print_info(&line); + } + } + Ok(()) + } + } + } +} + +/// The registry root the sync operates on: the global `--repo` override when +/// given, otherwise the configured Auths home. +fn resolve_registry_root(ctx: &CliConfig) -> Result { + if let Some(repo) = &ctx.repo_path { + return Ok(repo.clone()); + } + auths_sdk::paths::auths_home_with_config(&ctx.env_config) + .map_err(|e| anyhow!("Could not locate ~/.auths: {e}")) +} diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 4f8b1cfd..fb226900 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -114,6 +114,7 @@ fn run() -> Result<()> { RootCommand::Credential(cmd) => cmd.execute(&ctx), RootCommand::Audit(cmd) => cmd.execute(&ctx), RootCommand::Auth(cmd) => cmd.execute(&ctx), + RootCommand::Registry(cmd) => cmd.execute(&ctx), // Internal RootCommand::Emergency(cmd) => cmd.execute(&ctx), RootCommand::Agent(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-id/src/keri/kel_resolver.rs b/crates/auths-id/src/keri/kel_resolver.rs index d5aacb24..73ba00b3 100644 --- a/crates/auths-id/src/keri/kel_resolver.rs +++ b/crates/auths-id/src/keri/kel_resolver.rs @@ -145,6 +145,79 @@ pub fn collect_kel( Ok(events) } +/// Collect a prefix's full KEL from an **untrusted** [`RegistryBackend`] (a +/// fetched snapshot), enforcing DoS bounds and the truncation guard. +/// +/// This is the single capped read every consumer of a remote-sourced registry +/// goes through: an event-count cap and a total-serialized-bytes cap bound +/// memory against a hostile source, and a KEL whose first event is not the +/// inception (sequence 0) is rejected as [`KelResolveError::Truncated`] — +/// a withheld inception would otherwise make the prefix-binding guard +/// unrunnable. +/// +/// Args: +/// * `registry`: The (untrusted) backend to read from. +/// * `prefix`: The identifier prefix. +/// * `max_events`: Hard cap on event count. +/// * `max_bytes`: Hard cap on total serialized KEL size. +/// +/// Usage: +/// ```ignore +/// let events = collect_kel_capped(&snapshot, &prefix, 10_000, 4 << 20)?; +/// ``` +pub fn collect_kel_capped( + registry: &dyn RegistryBackend, + prefix: &Prefix, + max_events: usize, + max_bytes: usize, +) -> Result, KelResolveError> { + let mut events = Vec::new(); + let mut total_bytes = 0usize; + let mut cap_error: Option = None; + + let visit = registry.visit_events(prefix, 0, &mut |event| { + if events.len() >= max_events { + cap_error = Some(KelResolveError::Oversized(format!("> {max_events} events"))); + return ControlFlow::Break(()); + } + match serde_json::to_vec(event) { + Ok(bytes) => { + total_bytes += bytes.len(); + if total_bytes > max_bytes { + cap_error = Some(KelResolveError::Oversized(format!("> {max_bytes} bytes"))); + return ControlFlow::Break(()); + } + } + Err(e) => { + cap_error = Some(KelResolveError::Replay(format!( + "event serialization failed: {e}" + ))); + return ControlFlow::Break(()); + } + } + events.push(event.clone()); + ControlFlow::Continue(()) + }); + + match visit { + Ok(()) => {} + Err(RegistryError::NotFound { .. }) => { + return Err(KelResolveError::NotFound(format!("did:keri:{prefix}"))); + } + Err(e) => return Err(KelResolveError::Backend(e.to_string())), + } + if let Some(err) = cap_error { + return Err(err); + } + if events.is_empty() { + return Err(KelResolveError::NotFound(format!("did:keri:{prefix}"))); + } + if events[0].sequence().value() != 0 { + return Err(KelResolveError::Truncated); + } + Ok(events) +} + /// The prefix-binding guard: re-derive the inception event's self-addressing /// SAID and require it to equal `prefix`. /// diff --git a/crates/auths-id/src/keri/mod.rs b/crates/auths-id/src/keri/mod.rs index e6e75b23..1a579c81 100644 --- a/crates/auths-id/src/keri/mod.rs +++ b/crates/auths-id/src/keri/mod.rs @@ -120,6 +120,8 @@ pub mod rotation; pub mod seal; pub mod shared_kel; pub mod state; +#[cfg(feature = "git-storage")] +pub mod sync; pub mod types; pub mod validate; #[cfg(feature = "witness-client")] @@ -149,7 +151,8 @@ pub use inception::{ pub use kel::{GitKel, KelError}; #[cfg(feature = "git-storage")] pub use kel_resolver::{ - KelResolveError, KelResolver, LocalKelResolver, collect_kel, verify_prefix_binding, + KelResolveError, KelResolver, LocalKelResolver, collect_kel, collect_kel_capped, + verify_prefix_binding, }; #[cfg(feature = "git-storage")] pub use resolve::{ @@ -163,6 +166,8 @@ pub use rotation::{ }; pub use seal::Seal; pub use state::KeyState; +#[cfg(feature = "git-storage")] +pub use sync::{KelCaps, MergeOutcome, MergedKel, RegistryMergeError, merge_registries}; pub use types::{KeriTypeError, Prefix, Said, prefix_from_did}; pub use validate::{ ValidationError, compute_event_said, finalize_dip_event, finalize_drt_event, diff --git a/crates/auths-id/src/keri/sync.rs b/crates/auths-id/src/keri/sync.rs new file mode 100644 index 00000000..aed67bea --- /dev/null +++ b/crates/auths-id/src/keri/sync.rs @@ -0,0 +1,309 @@ +//! Validated KEL merge between registries — the trust core of registry +//! propagation (`registry push` / `registry pull`). +//! +//! Transport-free: both sides are [`RegistryBackend`] ports; the git fetch / +//! push that produces the source snapshot lives in the storage adapter. The +//! destination registry is the **trusted floor** (local-first): a source KEL +//! may only *advance* it, never rewrite it. Per identity in the source: +//! +//! - **prefix-binding guard** — the served inception must re-derive the +//! claimed prefix ([`verify_prefix_binding`]), so a source cannot +//! substitute a different identity's KEL; +//! - **authenticated replay** — every event must carry a valid signature from +//! the in-force key-state ([`validate_signed_kel`]); delegated (`dip`/`drt`) +//! events resolve their anchoring seal from the source, then the +//! destination. A KEL the source cannot prove it signed is refused whole; +//! - **rollback floor** — a source tip at-or-behind the destination's changes +//! nothing (prefer local; an older remote is never an instruction to +//! forget); +//! - **fork refusal** — a same-sequence SAID divergence between source and +//! destination fails the merge loudly ([`RegistryMergeError::Forked`]) +//! rather than silently picking a side. +//! +//! Only after all four hold is the strictly-newer suffix appended — +//! signature attachments included, so the destination's KELs remain +//! authenticatable (never merely structurally replayable). + +use std::ops::ControlFlow; + +use auths_keri::{ + DelegatorKelLookup, KelSealIndex, Prefix, Said, SignedEvent, SourceSeal, + parse_delegated_attachment, validate_signed_kel, +}; + +use super::kel_resolver::{KelResolveError, collect_kel_capped, verify_prefix_binding}; +use crate::ports::registry::{RegistryBackend, RegistryError}; + +/// DoS bounds applied when reading a KEL out of an untrusted source registry. +#[derive(Debug, Clone, Copy)] +pub struct KelCaps { + /// Hard cap on event count per KEL. + pub max_events: usize, + /// Hard cap on total serialized size per KEL, in bytes. + pub max_bytes: usize, +} + +/// What the merge did for one identity's KEL. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "outcome")] +pub enum MergeOutcome { + /// The destination had no KEL for this prefix — the full authenticated + /// KEL was imported. + Imported { + /// Number of events written. + events: usize, + }, + /// The destination's KEL was behind — the strictly-newer authenticated + /// suffix was appended. + Advanced { + /// Number of events appended. + events: usize, + }, + /// The destination already holds everything the source offered + /// (equal or newer). Nothing was written. + AlreadyCurrent, +} + +/// Per-identity merge report. +#[derive(Debug, Clone, serde::Serialize)] +pub struct MergedKel { + /// The identity prefix. + pub prefix: Prefix, + /// What happened to its KEL in the destination. + #[serde(flatten)] + pub outcome: MergeOutcome, +} + +/// Why a registry merge was refused. Any error aborts the whole merge — a +/// pull never partially trusts a source that failed one identity's checks. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RegistryMergeError { + /// The source listed an identity whose id is not a valid prefix. + #[error("source registry lists an invalid identity prefix '{id}': {reason}")] + InvalidPrefix { + /// The offending identity id. + id: String, + /// Why it failed to parse. + reason: String, + }, + + /// Reading the source KEL failed the untrusted-read guards + /// (not-found / oversized / truncated / prefix-binding). + #[error("source KEL for {prefix} was refused: {source}")] + SourceKel { + /// The identity prefix. + prefix: Prefix, + /// The guard that refused it. + #[source] + source: KelResolveError, + }, + + /// An event in the source KEL has no signature attachment — it cannot be + /// authenticated, so it is never persisted. + #[error("source KEL for {prefix} has no signature attachment at sequence {sequence}")] + MissingSignature { + /// The identity prefix. + prefix: Prefix, + /// The unsigned event's sequence. + sequence: u128, + }, + + /// The source KEL failed authenticated replay (bad signature, broken + /// chain, unauthorized delegation, …). + #[error("source KEL for {prefix} failed authentication: {reason}")] + Unauthenticated { + /// The identity prefix. + prefix: Prefix, + /// The validation failure. + reason: String, + }, + + /// Source and destination disagree at a shared sequence — a fork. + /// Refused outright; a merge never picks a side of a fork. + #[error( + "KEL fork for {prefix} at sequence {sequence}: \ + destination has {destination}, source has {incoming}" + )] + Forked { + /// The identity prefix. + prefix: Prefix, + /// The diverging sequence number. + sequence: u128, + /// The destination's event SAID at that sequence. + destination: Said, + /// The source's event SAID at that sequence. + incoming: Said, + }, + + /// A backend read/write failed. + #[error("registry backend error: {0}")] + Storage(#[from] RegistryError), +} + +/// Merge every identity's KEL from `source` into `dest` under the guards +/// documented at the module level. +/// +/// `source` is untrusted (a fetched snapshot); `dest` is the local trusted +/// floor. Returns one [`MergedKel`] per source identity, in prefix order. +/// Any guard failure aborts the whole merge with the first error. +/// +/// Args: +/// * `source`: The untrusted registry to read from. +/// * `dest`: The trusted local registry to advance. +/// * `caps`: DoS bounds for reading the source. +/// +/// Usage: +/// ```ignore +/// let report = merge_registries(snapshot.backend(), &local, &caps)?; +/// ``` +pub fn merge_registries( + source: &dyn RegistryBackend, + dest: &dyn RegistryBackend, + caps: &KelCaps, +) -> Result, RegistryMergeError> { + let mut ids: Vec = Vec::new(); + source.visit_identities(&mut |id| { + ids.push(id.to_string()); + ControlFlow::Continue(()) + })?; + ids.sort(); + ids.dedup(); + + let mut report = Vec::with_capacity(ids.len()); + for id in ids { + let prefix = Prefix::new(id.clone()).map_err(|e| RegistryMergeError::InvalidPrefix { + id, + reason: e.to_string(), + })?; + let outcome = merge_kel(source, dest, &prefix, caps)?; + report.push(MergedKel { prefix, outcome }); + } + Ok(report) +} + +/// Merge one identity's KEL from `source` into `dest`. +fn merge_kel( + source: &dyn RegistryBackend, + dest: &dyn RegistryBackend, + prefix: &Prefix, + caps: &KelCaps, +) -> Result { + let refused = |source: KelResolveError| RegistryMergeError::SourceKel { + prefix: prefix.clone(), + source, + }; + + let events = + collect_kel_capped(source, prefix, caps.max_events, caps.max_bytes).map_err(refused)?; + verify_prefix_binding(prefix, &events).map_err(refused)?; + + // Pair every event with its signature attachment; an unsigned event is + // unauthenticatable and refused before any validation work. + let mut attachments = Vec::with_capacity(events.len()); + let mut signed = Vec::with_capacity(events.len()); + for event in &events { + let sequence = event.sequence().value(); + let attachment = source.get_attachment(prefix, sequence)?.ok_or_else(|| { + RegistryMergeError::MissingSignature { + prefix: prefix.clone(), + sequence, + } + })?; + let (sigs, _seals) = parse_delegated_attachment(&attachment).map_err(|e| { + RegistryMergeError::Unauthenticated { + prefix: prefix.clone(), + reason: format!("unparseable attachment at sequence {sequence}: {e}"), + } + })?; + // Delegated events read from a registry backend already carry their + // rehydrated source seal; the attachment's `-G` group is redundant here. + signed.push(SignedEvent::new(event.clone(), sigs)); + attachments.push(attachment); + } + + let lookup = BackendSealLookup { + backends: [source, dest], + caps: *caps, + }; + validate_signed_kel(&signed, Some(&lookup)).map_err(|e| { + RegistryMergeError::Unauthenticated { + prefix: prefix.clone(), + reason: e.to_string(), + } + })?; + + let dest_tip = match dest.get_tip(prefix) { + Ok(tip) => Some(tip.sequence), + Err(RegistryError::NotFound { .. }) => None, + Err(e) => return Err(e.into()), + }; + + match dest_tip { + None => { + for (event, attachment) in events.iter().zip(&attachments) { + dest.append_signed_event(prefix, event, attachment)?; + } + Ok(MergeOutcome::Imported { + events: events.len(), + }) + } + Some(dest_tip) => { + // Fork refusal: every event in the shared range must agree by SAID. + for event in events.iter().filter(|e| e.sequence().value() <= dest_tip) { + let sequence = event.sequence().value(); + let local = dest.get_event(prefix, sequence)?; + if local.said() != event.said() { + return Err(RegistryMergeError::Forked { + prefix: prefix.clone(), + sequence, + destination: local.said().clone(), + incoming: event.said().clone(), + }); + } + } + let newer: Vec<_> = events + .iter() + .zip(&attachments) + .filter(|(event, _)| event.sequence().value() > dest_tip) + .collect(); + if newer.is_empty() { + return Ok(MergeOutcome::AlreadyCurrent); + } + let appended = newer.len(); + for (event, attachment) in newer { + dest.append_signed_event(prefix, event, attachment)?; + } + Ok(MergeOutcome::Advanced { events: appended }) + } + } +} + +/// Resolves a delegated event's anchoring seal from the merge's registries — +/// source first (a pushed registry carries the delegator's KEL alongside the +/// delegate's), then the destination (the delegator may already be local). +struct BackendSealLookup<'a> { + backends: [&'a dyn RegistryBackend; 2], + caps: KelCaps, +} + +impl DelegatorKelLookup for BackendSealLookup<'_> { + fn find_seal(&self, delegator_aid: &Prefix, seal_said: &Said) -> Option { + for backend in self.backends { + let Ok(events) = collect_kel_capped( + backend, + delegator_aid, + self.caps.max_events, + self.caps.max_bytes, + ) else { + continue; + }; + if let Some(seal) = + KelSealIndex::from_events(&events).find_seal(delegator_aid, seal_said) + { + return Some(seal); + } + } + None + } +} diff --git a/crates/auths-sdk/src/storage.rs b/crates/auths-sdk/src/storage.rs index d4af5b3c..043afb5c 100644 --- a/crates/auths-sdk/src/storage.rs +++ b/crates/auths-sdk/src/storage.rs @@ -2,10 +2,13 @@ //! //! Gated behind the `backend-git` feature. +#[cfg(feature = "backend-git")] +pub use auths_id::keri::sync::{MergeOutcome, MergedKel}; #[cfg(feature = "backend-git")] pub use auths_id::storage::GitWitnessReceiptLookup; #[cfg(feature = "backend-git")] pub use auths_storage::git::{ - GitAttestationStorage, GitIdentityStorage, GitRegistryBackend, RegistryAttestationStorage, - RegistryConfig, RegistryIdentityStorage, + GitAttestationStorage, GitIdentityStorage, GitRegistryBackend, PushOutcome, + RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, RegistrySyncError, + pull_registry, push_registry, }; diff --git a/crates/auths-storage/src/git/mod.rs b/crates/auths-storage/src/git/mod.rs index ae58600d..978ae79a 100644 --- a/crates/auths-storage/src/git/mod.rs +++ b/crates/auths-storage/src/git/mod.rs @@ -9,6 +9,7 @@ pub mod remote; pub mod standalone_attestation; pub mod standalone_export; pub mod standalone_identity; +pub mod sync; mod tree_ops; pub mod vfs; @@ -21,8 +22,11 @@ pub use oobi::{ OOBI_KEL_FILE, OOBI_RECEIPTS_FILE, OobiExportError, export_identity_oobi, export_receipts_oobi, oobi_receipts_relative_path, oobi_relative_path, parse_oobi_kel, parse_oobi_receipts, }; -pub use remote::{MAX_KEL_BYTES, MAX_KEL_EVENTS, RemoteKelError, RemoteKelSource}; +pub use remote::{ + MAX_KEL_BYTES, MAX_KEL_EVENTS, RegistrySnapshot, RemoteKelError, RemoteKelSource, +}; pub use standalone_attestation::GitAttestationStorage; pub use standalone_export::GitRefSink; pub use standalone_identity::GitIdentityStorage; +pub use sync::{PushOutcome, RegistrySyncError, pull_registry, push_registry}; pub use vfs::{FixedClock, OsVfs, Vfs}; diff --git a/crates/auths-storage/src/git/remote.rs b/crates/auths-storage/src/git/remote.rs index e4f8a03a..1b119619 100644 --- a/crates/auths-storage/src/git/remote.rs +++ b/crates/auths-storage/src/git/remote.rs @@ -11,9 +11,8 @@ //! before trusting the result. Size caps bound memory against a hostile remote; //! a truncation guard rejects a KEL whose inception (seq 0) is absent. -use std::ops::ControlFlow; - -use auths_id::ports::registry::{RegistryBackend, RegistryError}; +use auths_id::keri::kel_resolver::{KelResolveError, collect_kel_capped}; +use auths_id::ports::registry::RegistryError; use auths_keri::{Event, Prefix}; use git2::Repository; use tempfile::TempDir; @@ -115,6 +114,18 @@ impl RemoteKelSource { /// Args: /// * `prefix`: The identifier prefix to read. pub fn fetch_kel(&self, prefix: &Prefix) -> Result, RemoteKelError> { + let snapshot = self.fetch_snapshot()?; + read_kel_capped(snapshot.backend(), prefix, MAX_KEL_EVENTS, MAX_KEL_BYTES) + } + + /// Fetch the remote's whole packed registry into a throwaway snapshot. + /// + /// The snapshot is a read-only [`GitRegistryBackend`] over a temp repo — + /// the registry-wide primitive `registry pull` merges from. It is + /// **untrusted**: callers must run the validated merge (or the per-KEL + /// guards) before persisting anything it serves; the temp repo is deleted + /// when the snapshot drops. + pub fn fetch_snapshot(&self) -> Result { let tmp = TempDir::new().map_err(RemoteKelError::Setup)?; let repo = Repository::init(tmp.path()).map_err(|e| RemoteKelError::Fetch { url: self.remote_url.clone(), @@ -124,7 +135,22 @@ impl RemoteKelSource { let backend = GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(tmp.path())); - collect_capped_with(&backend, prefix, MAX_KEL_EVENTS, MAX_KEL_BYTES) + Ok(RegistrySnapshot { _tmp: tmp, backend }) + } +} + +/// A fetched remote registry held in a temp repo — read-only, untrusted, +/// deleted on drop. +pub struct RegistrySnapshot { + /// Owns the temp repo for the snapshot's lifetime. + _tmp: TempDir, + backend: GitRegistryBackend, +} + +impl RegistrySnapshot { + /// The backend reading the fetched registry. + pub fn backend(&self) -> &GitRegistryBackend { + &self.backend } } @@ -151,74 +177,35 @@ fn fetch_registry_ref(repo: &Repository, url: &str) -> Result<(), RemoteKelError Ok(()) } -/// Collect a prefix's KEL from `backend`, enforcing event-count and byte caps and -/// the inception-present (truncation) guard. Separated from [`RemoteKelSource`] -/// so the caps are unit-testable without a network fetch. +/// Read a prefix's KEL out of a fetched snapshot under the shared untrusted-read +/// guards ([`collect_kel_capped`]: caps + truncation), mapped into this +/// adapter's error vocabulary. /// /// Args: /// * `backend`: The (just-fetched) registry to read from. /// * `prefix`: The identifier prefix. /// * `max_events`: Hard cap on event count. /// * `max_bytes`: Hard cap on total serialized KEL size. -fn collect_capped_with( - backend: &dyn RegistryBackend, +fn read_kel_capped( + backend: &GitRegistryBackend, prefix: &Prefix, max_events: usize, max_bytes: usize, ) -> Result, RemoteKelError> { - let mut events = Vec::new(); - let mut total_bytes = 0usize; - let mut cap_error: Option = None; - - let visit = backend.visit_events(prefix, 0, &mut |event| { - if events.len() >= max_events { - cap_error = Some(RemoteKelError::Oversized { - what: format!("> {max_events} events"), - }); - return ControlFlow::Break(()); - } - match serde_json::to_vec(event) { - Ok(bytes) => { - total_bytes += bytes.len(); - if total_bytes > max_bytes { - cap_error = Some(RemoteKelError::Oversized { - what: format!("> {max_bytes} bytes"), - }); - return ControlFlow::Break(()); - } - } - Err(e) => { - cap_error = Some(RemoteKelError::Serialize(e.to_string())); - return ControlFlow::Break(()); - } - } - events.push(event.clone()); - ControlFlow::Continue(()) - }); - - match visit { - Ok(()) => {} - Err(RegistryError::NotFound { .. }) => { - return Err(RemoteKelError::NotFound(format!("did:keri:{prefix}"))); - } - Err(e) => return Err(RemoteKelError::Read(e)), - } - if let Some(err) = cap_error { - return Err(err); - } - if events.is_empty() { - return Err(RemoteKelError::NotFound(format!("did:keri:{prefix}"))); - } - if events[0].sequence().value() != 0 { - return Err(RemoteKelError::Truncated); - } - Ok(events) + collect_kel_capped(backend, prefix, max_events, max_bytes).map_err(|e| match e { + KelResolveError::NotFound(id) => RemoteKelError::NotFound(id), + KelResolveError::Oversized(what) => RemoteKelError::Oversized { what }, + KelResolveError::Truncated => RemoteKelError::Truncated, + KelResolveError::Backend(reason) => RemoteKelError::Read(RegistryError::Internal(reason)), + other => RemoteKelError::Serialize(other.to_string()), + }) } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use auths_id::ports::registry::RegistryBackend; use auths_keri::{ CesrKey, IcpEvent, KeriPublicKey, KeriSequence, Said, Threshold, VersionString, compute_next_commitment, finalize_icp_event, @@ -288,7 +275,7 @@ mod tests { let (event, prefix) = icp_event(); backend.append_event(&prefix, &event).unwrap(); - let err = collect_capped_with(&backend, &prefix, 0, MAX_KEL_BYTES).unwrap_err(); + let err = read_kel_capped(&backend, &prefix, 0, MAX_KEL_BYTES).unwrap_err(); assert!(matches!(err, RemoteKelError::Oversized { .. })); } @@ -301,7 +288,7 @@ mod tests { let (event, prefix) = icp_event(); backend.append_event(&prefix, &event).unwrap(); - let err = collect_capped_with(&backend, &prefix, MAX_KEL_EVENTS, 1).unwrap_err(); + let err = read_kel_capped(&backend, &prefix, MAX_KEL_EVENTS, 1).unwrap_err(); assert!(matches!(err, RemoteKelError::Oversized { .. })); } } diff --git a/crates/auths-storage/src/git/sync.rs b/crates/auths-storage/src/git/sync.rs new file mode 100644 index 00000000..255ee075 --- /dev/null +++ b/crates/auths-storage/src/git/sync.rs @@ -0,0 +1,214 @@ +//! Registry propagation over a git remote — push/pull of `refs/auths/registry`. +//! +//! The wire is plain git, so any git remote (a bare repo over `file://`, +//! `git://`, `ssh://`, `https://`) can carry a registry between machines — +//! no shared filesystem. +//! +//! **Push** publishes the local packed registry ref, fast-forward only: the +//! registry is append-only, so a non-fast-forward means the histories +//! diverged and the push is refused ([`RegistrySyncError::Diverged`]) rather +//! than forced. +//! +//! **Pull** fetches the remote's ref into a throwaway snapshot and runs the +//! validated KEL merge ([`merge_registries`]) into the local registry — +//! prefix binding, authenticated replay, rollback floor, fork refusal. +//! Nothing from the remote is ever persisted unvalidated. + +use std::cell::RefCell; +use std::path::Path; + +use auths_id::keri::sync::{KelCaps, MergedKel, RegistryMergeError, merge_registries}; +use auths_id::ports::registry::RegistryError; +use git2::Repository; + +use super::remote::{MAX_KEL_BYTES, MAX_KEL_EVENTS, RemoteKelError, RemoteKelSource}; +use super::{GitRegistryBackend, REGISTRY_REF, RegistryConfig}; + +/// Errors pushing or pulling a registry over a git remote. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RegistrySyncError { + /// The local registry repo could not be opened. + #[error("could not open the local registry at '{path}': {source}")] + OpenLocal { + /// The local registry path. + path: String, + /// The underlying git2 error. + #[source] + source: git2::Error, + }, + + /// The local registry has no packed registry ref to push. + #[error("local registry has no '{reference}' to push (run `auths init` first)")] + NothingToPush { + /// The expected ref name. + reference: String, + }, + + /// A git transport operation (connect / fetch / push) failed. + #[error("git transport with '{url}' failed: {source}")] + Transport { + /// The remote URL. + url: String, + /// The underlying git2 error. + #[source] + source: git2::Error, + }, + + /// The remote's registry history is not an ancestor of the local one — + /// pushing would discard remote events. The registry is append-only; + /// reconcile by pulling first. + #[error( + "remote registry has diverged from the local one (non-fast-forward); \ + pull first, then push" + )] + Diverged, + + /// The remote refused the ref update. + #[error("remote rejected the push of {reference}: {status}")] + PushRejected { + /// The ref the remote refused. + reference: String, + /// The remote's status message. + status: String, + }, + + /// Fetching the remote registry snapshot failed. + #[error(transparent)] + Fetch(#[from] RemoteKelError), + + /// The validated merge refused the fetched registry. + #[error(transparent)] + Merge(#[from] RegistryMergeError), + + /// The local registry backend failed. + #[error("local registry error: {0}")] + Storage(#[from] RegistryError), +} + +/// The result of a registry push. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PushOutcome { + /// The remote's registry ref was advanced (or created). + Updated, + /// The remote already holds the local tip — nothing to send. + AlreadyCurrent, +} + +/// Push the local packed registry ref to `url`, fast-forward only. +/// +/// Refuses with [`RegistrySyncError::Diverged`] when the remote holds history +/// the local registry does not — the registry is append-only, so a push never +/// rewrites a remote. +/// +/// Args: +/// * `local_root`: The local registry root (the `~/.auths` repo). +/// * `url`: The git remote to publish to. +/// +/// Usage: +/// ```ignore +/// let outcome = push_registry(&auths_home, "git://wire.local/registry.git")?; +/// ``` +pub fn push_registry(local_root: &Path, url: &str) -> Result { + let repo = Repository::open(local_root).map_err(|e| RegistrySyncError::OpenLocal { + path: local_root.display().to_string(), + source: e, + })?; + let local_oid = repo + .find_reference(REGISTRY_REF) + .ok() + .and_then(|r| r.target()) + .ok_or_else(|| RegistrySyncError::NothingToPush { + reference: REGISTRY_REF.to_string(), + })?; + + let transport = |source: git2::Error| RegistrySyncError::Transport { + url: url.to_string(), + source, + }; + let mut remote = repo.remote_anonymous(url).map_err(transport)?; + + // Learn the remote tip (if any) before deciding what a push would do: + // fetch the remote's registry ref into a throwaway local ref. A remote + // without the ref is a fresh wire — the fetch is a no-op and the temp ref + // never appears. (Deliberately not `Remote::list()`: that needs a second + // connection and an empty remote advertises zero refs.) + const INCOMING_REF: &str = "refs/auths/sync/incoming"; + remote + .fetch(&[format!("+{REGISTRY_REF}:{INCOMING_REF}")], None, None) + .map_err(transport)?; + let remote_oid = repo + .find_reference(INCOMING_REF) + .ok() + .and_then(|r| r.target()); + if let Ok(mut incoming) = repo.find_reference(INCOMING_REF) { + let _ = incoming.delete(); + } + + if let Some(remote_oid) = remote_oid { + if remote_oid == local_oid { + return Ok(PushOutcome::AlreadyCurrent); + } + // Fast-forward check — the remote commit is in the local odb from the + // fetch above. + let fast_forward = repo + .graph_descendant_of(local_oid, remote_oid) + .map_err(transport)?; + if !fast_forward { + return Err(RegistrySyncError::Diverged); + } + } + + // Plain (non-force) refspec: the ancestry was proven above, and the remote + // side's own fast-forward rule stays in force as a second guard. + let rejection: RefCell> = RefCell::new(None); + { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.push_update_reference(|reference, status| { + if let Some(status) = status { + *rejection.borrow_mut() = Some((reference.to_string(), status.to_string())); + } + Ok(()) + }); + let mut options = git2::PushOptions::new(); + options.remote_callbacks(callbacks); + remote + .push( + &[format!("{REGISTRY_REF}:{REGISTRY_REF}")], + Some(&mut options), + ) + .map_err(transport)?; + } + if let Some((reference, status)) = rejection.into_inner() { + return Err(RegistrySyncError::PushRejected { reference, status }); + } + Ok(PushOutcome::Updated) +} + +/// Pull the remote registry at `url` and merge its KELs into the local +/// registry under the validated-merge guards. +/// +/// Provisions the local registry if it does not exist yet, so a fresh machine +/// can pull before it ever runs `auths init`. Returns the per-identity merge +/// report. +/// +/// Args: +/// * `local_root`: The local registry root (the `~/.auths` repo). +/// * `url`: The git remote to fetch from. +/// +/// Usage: +/// ```ignore +/// let merged = pull_registry(&auths_home, "git://wire.local/registry.git")?; +/// ``` +pub fn pull_registry(local_root: &Path, url: &str) -> Result, RegistrySyncError> { + let snapshot = RemoteKelSource::new(url).fetch_snapshot()?; + let local = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(local_root)); + local.init_if_needed()?; + let caps = KelCaps { + max_events: MAX_KEL_EVENTS, + max_bytes: MAX_KEL_BYTES, + }; + Ok(merge_registries(snapshot.backend(), &local, &caps)?) +} diff --git a/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs b/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs index 0d6f5fa1..2ceb31ac 100644 --- a/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs +++ b/crates/auths-storage/tests/cases/mock_ed25519_keypairs.rs @@ -219,6 +219,13 @@ const ALL_PKCS8: [&[u8]; 20] = [ PKCS8_19, ]; +/// The pre-generated keypair at `index` (0..19) — e.g. to SIGN the events a +/// fixture appends (the current key of identity `i` is `mock_keypair(i * 2)`). +pub fn mock_keypair(index: usize) -> Ed25519KeyPair { + assert!(index < 20, "only 20 pre-generated keypairs available"); + Ed25519KeyPair::from_pkcs8(ALL_PKCS8[index]).unwrap() +} + /// Builds a signed inception event from pre-generated static keypairs. /// /// Each `index` (0..9) produces a unique identity. Uses keypair pair diff --git a/crates/auths-storage/tests/cases/mod.rs b/crates/auths-storage/tests/cases/mod.rs index 753d3f60..a2c1a0c3 100644 --- a/crates/auths-storage/tests/cases/mod.rs +++ b/crates/auths-storage/tests/cases/mod.rs @@ -5,3 +5,4 @@ mod concurrent_batch; mod concurrent_writes; mod credential_registry; mod registry_contract; +mod registry_sync; diff --git a/crates/auths-storage/tests/cases/registry_sync.rs b/crates/auths-storage/tests/cases/registry_sync.rs new file mode 100644 index 00000000..f49dd9e4 --- /dev/null +++ b/crates/auths-storage/tests/cases/registry_sync.rs @@ -0,0 +1,241 @@ +//! Registry push/pull over a git remote: fast-forward-only publish, and the +//! validated merge on pull (authenticated import/advance, unsigned refusal, +//! fork refusal, idempotence). + +use auths_id::keri::sync::{MergeOutcome, RegistryMergeError}; +use auths_id::ports::registry::RegistryBackend; +use auths_keri::{ + Event, IndexedSignature, IxnEvent, KeriSequence, Prefix, Said, Seal, VersionString, + finalize_ixn_event, serialize_attachment, serialize_for_signing, +}; +use auths_storage::git::sync::{PushOutcome, RegistrySyncError, pull_registry, push_registry}; +use auths_storage::git::{GitRegistryBackend, RegistryConfig}; +use ring::signature::Ed25519KeyPair; +use tempfile::TempDir; + +use super::mock_ed25519_keypairs::{mock_inception_event, mock_keypair}; + +/// Sign `event` with `signer`; return the CESR attachment bytes. +fn attachment_for(signer: &Ed25519KeyPair, event: &Event) -> Vec { + let sig = signer + .sign(&serialize_for_signing(event).unwrap()) + .as_ref() + .to_vec(); + serialize_attachment(&[IndexedSignature { + index: 0, + prior_index: None, + sig, + }]) + .unwrap() +} + +fn fresh_registry() -> (GitRegistryBackend, TempDir) { + let dir = tempfile::tempdir().unwrap(); + let backend = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir.path())); + backend.init_if_needed().unwrap(); + (backend, dir) +} + +/// A registry holding identity `index`'s fully-signed inception. +fn signed_registry(index: usize) -> (GitRegistryBackend, TempDir, Ed25519KeyPair, Event, Prefix) { + let (backend, dir) = fresh_registry(); + let signer = mock_keypair(index * 2); + let event = mock_inception_event(index); + let prefix = event.prefix().clone(); + let attachment = attachment_for(&signer, &event); + backend + .append_signed_event(&prefix, &event, &attachment) + .unwrap(); + (backend, dir, signer, event, prefix) +} + +/// A signed interaction event extending `prior` at sequence `seq`. +fn signed_ixn( + signer: &Ed25519KeyPair, + prefix: &Prefix, + prior: &Said, + seq: u128, + anchors: Vec, +) -> (Event, Vec) { + let ixn = IxnEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: prefix.clone(), + s: KeriSequence::new(seq), + p: prior.clone(), + a: anchors, + }; + let event = Event::Ixn(finalize_ixn_event(ixn).unwrap()); + let attachment = attachment_for(signer, &event); + (event, attachment) +} + +fn bare_remote() -> (TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + git2::Repository::init_bare(dir.path()).unwrap(); + let url = format!("file://{}", dir.path().display()); + (dir, url) +} + +#[test] +fn push_then_pull_imports_authenticated_kel() { + let (_src, src_dir, _signer, event, prefix) = signed_registry(0); + let (_remote_dir, url) = bare_remote(); + + assert_eq!( + push_registry(src_dir.path(), &url).unwrap(), + PushOutcome::Updated + ); + // Re-pushing the same tip is a no-op, not an error. + assert_eq!( + push_registry(src_dir.path(), &url).unwrap(), + PushOutcome::AlreadyCurrent + ); + + let dest_dir = tempfile::tempdir().unwrap(); + let merged = pull_registry(dest_dir.path(), &url).unwrap(); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].prefix, prefix); + assert!(matches!( + merged[0].outcome, + MergeOutcome::Imported { events: 1 } + )); + + // The destination's copy is the same event WITH its signature attachment — + // still authenticatable, not merely structurally replayable. + let dest = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dest_dir.path())); + assert_eq!(dest.get_event(&prefix, 0).unwrap(), event); + assert!(dest.get_attachment(&prefix, 0).unwrap().is_some()); +} + +#[test] +fn pull_advances_then_reports_already_current() { + let (src, src_dir, signer, event, prefix) = signed_registry(1); + let (_remote_dir, url) = bare_remote(); + push_registry(src_dir.path(), &url).unwrap(); + + let dest_dir = tempfile::tempdir().unwrap(); + pull_registry(dest_dir.path(), &url).unwrap(); + + // The source advances (a signed interaction) and re-publishes (fast-forward). + let (ixn, ixn_att) = signed_ixn(&signer, &prefix, event.said(), 1, vec![]); + src.append_signed_event(&prefix, &ixn, &ixn_att).unwrap(); + assert_eq!( + push_registry(src_dir.path(), &url).unwrap(), + PushOutcome::Updated + ); + + let merged = pull_registry(dest_dir.path(), &url).unwrap(); + assert!(matches!( + merged[0].outcome, + MergeOutcome::Advanced { events: 1 } + )); + + // Pulling again changes nothing — the merge is idempotent. + let merged = pull_registry(dest_dir.path(), &url).unwrap(); + assert!(matches!(merged[0].outcome, MergeOutcome::AlreadyCurrent)); +} + +#[test] +fn push_refuses_diverged_remote() { + let (_a, a_dir, _sa, _ea, _pa) = signed_registry(2); + let (_b, b_dir, _sb, _eb, _pb) = signed_registry(3); + let (_remote_dir, url) = bare_remote(); + + push_registry(a_dir.path(), &url).unwrap(); + // B's registry shares no history with A's — pushing it would discard A. + let err = push_registry(b_dir.path(), &url).unwrap_err(); + assert!(matches!(err, RegistrySyncError::Diverged)); +} + +#[test] +fn push_without_local_registry_fails_actionably() { + let empty = tempfile::tempdir().unwrap(); + let (_remote_dir, url) = bare_remote(); + let err = push_registry(empty.path(), &url).unwrap_err(); + assert!(matches!(err, RegistrySyncError::OpenLocal { .. })); +} + +#[test] +fn pull_refuses_unsigned_kel() { + // An event persisted WITHOUT its signature attachment cannot be + // authenticated — the pull refuses the whole registry (fail closed). + let (backend, src_dir) = fresh_registry(); + let event = mock_inception_event(4); + let prefix = event.prefix().clone(); + backend.append_event(&prefix, &event).unwrap(); + let (_remote_dir, url) = bare_remote(); + push_registry(src_dir.path(), &url).unwrap(); + + let dest_dir = tempfile::tempdir().unwrap(); + let err = pull_registry(dest_dir.path(), &url).unwrap_err(); + assert!(matches!( + err, + RegistrySyncError::Merge(RegistryMergeError::MissingSignature { .. }) + )); +} + +#[test] +fn pull_refuses_forked_kel() { + // Source and destination share an inception but diverge at sequence 1 — + // the merge must refuse, never silently pick a side. + let (src, src_dir, signer, event, prefix) = signed_registry(5); + let (ixn_src, att_src) = signed_ixn(&signer, &prefix, event.said(), 1, vec![]); + src.append_signed_event(&prefix, &ixn_src, &att_src) + .unwrap(); + let (_remote_dir, url) = bare_remote(); + push_registry(src_dir.path(), &url).unwrap(); + + let dest_dir = tempfile::tempdir().unwrap(); + let dest = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dest_dir.path())); + dest.init_if_needed().unwrap(); + dest.append_signed_event(&prefix, &event, &attachment_for(&signer, &event)) + .unwrap(); + let (ixn_dest, att_dest) = signed_ixn( + &signer, + &prefix, + event.said(), + 1, + vec![Seal::Digest { + d: event.said().clone(), + }], + ); + dest.append_signed_event(&prefix, &ixn_dest, &att_dest) + .unwrap(); + + let err = pull_registry(dest_dir.path(), &url).unwrap_err(); + assert!(matches!( + err, + RegistrySyncError::Merge(RegistryMergeError::Forked { sequence: 1, .. }) + )); +} + +#[test] +fn pull_into_unprovisioned_root_provisions_it() { + // A fresh machine (no ~/.auths at all) can pull before `auths init`. + let (_src, src_dir, _signer, _event, prefix) = signed_registry(6); + let (_remote_dir, url) = bare_remote(); + push_registry(src_dir.path(), &url).unwrap(); + + let dest_root = tempfile::tempdir().unwrap(); + let dest_path = dest_root.path().join("never-initialized"); + let merged = pull_registry(&dest_path, &url).unwrap(); + assert_eq!(merged.len(), 1); + + let dest = GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&dest_path)); + assert!(matches!( + dest.get_tip(&prefix), + Ok(tip) if tip.sequence == 0 + )); +} + +#[test] +fn pull_from_remote_without_registry_fails_actionably() { + let (_remote_dir, url) = bare_remote(); + let dest_dir = tempfile::tempdir().unwrap(); + let err = pull_registry(dest_dir.path(), &url).unwrap_err(); + assert!(matches!(err, RegistrySyncError::Fetch(_))); +} From cea89bf0273dfde41c0db33ced4c51e656fbfa31 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 02:13:50 +0100 Subject: [PATCH 19/32] =?UTF-8?q?feat(verify):=20offline=20transparency=20?= =?UTF-8?q?anchoring=20for=20artifact=20verify=20=E2=80=94=20--log-evidenc?= =?UTF-8?q?e=20under=20a=20pinned=20log=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artifact verify now consumes the offline Merkle inclusion evidence that 'auths log prove --out' mints and decides the verdict's anchored field by proof: Anchored only when the evidence binds to THIS artifact's canonical digest, the inclusion/consistency proof verifies, and the checkpoint is attested by the operator key the verifier pinned out of band. Bad evidence is a verdict (exit 1), never a skip; --log-evidence/--log-key are enforced as a pair at the clap boundary. The verdict logic lives in auths-verifier (verify_artifact_log_inclusion), composed from the same binding+inclusion helper the evidence-pack row verdicts now share — one source of truth for 'this proof is FOR this artifact'. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/artifact/mod.rs | 54 +++++++ .../auths-cli/src/commands/artifact/verify.rs | 90 ++++++++++- .../auths-cli/src/commands/unified_verify.rs | 2 + crates/auths-verifier/src/evidence_pack.rs | 149 +++++++++++++++++- crates/auths-verifier/src/types.rs | 4 +- 5 files changed, 291 insertions(+), 8 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index b271732b..8aec0230 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -239,6 +239,18 @@ pub enum ArtifactSubcommand { /// a digest mismatch — the witnessed log is the source of truth. #[arg(long, value_name = "ORG-DID", conflicts_with = "oidc_policy")] oidc_policy_did: Option, + + /// Offline transparency-log inclusion evidence for this artifact + /// (`auths log prove --out`). Verified fully offline; the verdict's + /// `anchored` field reports the outcome. Requires --log-key — an + /// inclusion proof without a pinned operator proves nothing. + #[arg(long, value_name = "EVIDENCE-FILE", requires = "log_key")] + log_evidence: Option, + + /// The log operator's Ed25519 public key (64 hex chars), pinned out + /// of band — never trusted from the evidence file itself. + #[arg(long, value_name = "HEX", requires = "log_evidence")] + log_key: Option, }, } @@ -629,6 +641,8 @@ pub fn handle_artifact( json, oidc_policy, oidc_policy_did, + log_evidence, + log_key, } => { if offline { return verify::handle_offline_verify( @@ -651,6 +665,8 @@ pub fn handle_artifact( verify_commit, oidc_policy, oidc_policy_did, + log_evidence, + log_key, }, )) } @@ -801,6 +817,44 @@ mod tests { } } + #[test] + fn verify_log_evidence_and_log_key_are_a_pair() { + // An inclusion proof without a pinned operator key proves nothing, + // and a pinned key with nothing to check is operator error — clap + // enforces both-or-neither. + assert!( + Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-evidence", "e.json"]) + .is_err(), + "--log-evidence without --log-key must be rejected" + ); + assert!( + Cli::try_parse_from(["test", "verify", "a.tar.gz", "--log-key", "ab12"]).is_err(), + "--log-key without --log-evidence must be rejected" + ); + + let ok = Cli::try_parse_from([ + "test", + "verify", + "a.tar.gz", + "--log-evidence", + "e.json", + "--log-key", + "ab12", + ]) + .unwrap(); + match ok.command { + ArtifactSubcommand::Verify { + log_evidence, + log_key, + .. + } => { + assert_eq!(log_evidence, Some(PathBuf::from("e.json"))); + assert_eq!(log_key.as_deref(), Some("ab12")); + } + _ => panic!("expected Verify"), + } + } + #[test] fn publish_forwards_signing_flags() { let cli = Cli::try_parse_from([ diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index 34d3bf26..88ff4824 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -5,6 +5,9 @@ use std::path::{Path, PathBuf}; use auths_keri::witness::SignedReceipt; use auths_verifier::core::Attestation; +use auths_verifier::evidence_pack::{ + TransparencyInclusion, parse_log_key_hex, verify_artifact_log_inclusion, +}; use auths_verifier::oidc_policy::{OidcPolicyJoin, OidcSubjectPolicy}; use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ @@ -62,6 +65,11 @@ pub struct VerifyArtifactArgs { /// Org `did:keri:` whose KEL-anchored OIDC-subject policy to resolve and /// JOIN — the witnessed log as the policy's source of truth. pub oidc_policy_did: Option, + /// Offline transparency-log inclusion evidence (`auths log prove --out`). + pub log_evidence: Option, + /// The log operator's pinned Ed25519 key (64 hex chars); paired with + /// `log_evidence` at the clap boundary. + pub log_key: Option, } /// Execute the `artifact verify` command. @@ -77,6 +85,8 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> verify_commit, oidc_policy, oidc_policy_did, + log_evidence, + log_key, } = args; let witness_keys = &witness_keys; let file_str = file.to_string_lossy().to_string(); @@ -215,7 +225,7 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> let chain = vec![attestation.clone()]; let chain_result = verify_chain(&chain, &root_pk).await; - let (chain_valid, chain_report) = match chain_result { + let (chain_valid, mut chain_report) = match chain_result { Ok(mut report) => { if let Ok(home) = auths_sdk::paths::auths_home() { let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home); @@ -244,6 +254,75 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> } }; + // 6b. Offline transparency anchoring. With inclusion evidence supplied, + // the verdict's `anchored` field is decided by the proof: Anchored + // only when the evidence binds to THIS artifact's digest, its Merkle + // inclusion verifies, and the checkpoint is attested by the pinned + // log key. Fail-closed: evidence that does not prove is a verdict + // (exit 1), never a skip. + let mut log_anchor_error: Option = None; + if let Some(evidence_path) = &log_evidence { + let raw = match fs::read_to_string(evidence_path) { + Ok(r) => r, + Err(e) => { + return output_error( + &file_str, + 2, + &format!("Failed to read log evidence {evidence_path:?}: {e}"), + ); + } + }; + let evidence: TransparencyInclusion = match serde_json::from_str(&raw) { + Ok(t) => t, + Err(e) => { + return output_error(&file_str, 2, &format!("Failed to parse log evidence: {e}")); + } + }; + // clap enforces the pair; a bare evidence path here is could-not-attempt. + let Some(key_hex) = log_key.as_deref() else { + return output_error(&file_str, 2, "--log-evidence requires --log-key"); + }; + let pinned_key = match parse_log_key_hex(key_hex) { + Ok(k) => k, + Err(e) => return output_error(&file_str, 2, &format!("{e}")), + }; + // The log's leaf data is the canonical `sha256:` digest string — + // derive it through the same parsed type the append path uses. + let canonical_digest = match auths_sdk::workflows::compliance::ArtifactDigest::parse( + &format!("{}:{}", file_digest.algorithm, file_digest.hex), + ) { + Ok(d) => d, + Err(e) => { + return output_error( + &file_str, + 2, + &format!("Cannot derive the artifact's canonical log leaf: {e}"), + ); + } + }; + match verify_artifact_log_inclusion(canonical_digest.as_str(), &evidence, &pinned_key) { + Ok(()) => { + if let Some(report) = chain_report.as_mut() { + report.anchored = Some(auths_keri::AnchorStatus::Anchored); + } + if !is_json_mode() { + eprintln!( + " Transparency: {} anchored in log '{}' \ + (offline inclusion proof, operator key pinned)", + canonical_digest.as_str(), + evidence.signed_checkpoint.checkpoint.origin + ); + } + } + Err(e) => { + if let Some(report) = chain_report.as_mut() { + report.anchored = Some(auths_keri::AnchorStatus::NotAnchored); + } + log_anchor_error = Some(format!("Transparency anchoring failed: {e}")); + } + } + } + // 7. Optional witness verification let witness_quorum = match verify_witnesses( &chain, @@ -269,6 +348,13 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> valid = false; } + if let Some(ref msg) = log_anchor_error { + valid = false; + if !is_json_mode() { + eprintln!(" {msg}"); + } + } + // 8a. Ephemeral attestation: verify commit signature transitively let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:"); if is_ephemeral && valid { @@ -424,7 +510,7 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> commit_sha: commit_sha_val, commit_verified, oidc_join, - error: oidc_error, + error: log_anchor_error.or(oidc_error), }, ) } diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index e432d891..2adbe8f8 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -200,6 +200,8 @@ pub async fn handle_verify_unified( verify_commit: false, oidc_policy: None, oidc_policy_did: None, + log_evidence: None, + log_key: None, }, ) .await diff --git a/crates/auths-verifier/src/evidence_pack.rs b/crates/auths-verifier/src/evidence_pack.rs index c4f43c2c..4dca1a43 100644 --- a/crates/auths-verifier/src/evidence_pack.rs +++ b/crates/auths-verifier/src/evidence_pack.rs @@ -275,6 +275,60 @@ pub fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), Ev Ok(()) } +/// Binding + inclusion: the evidence is FOR this artifact (its leaf hash +/// re-derives from the canonical `sha256:` digest string) AND the +/// Merkle inclusion/consistency proof checks out. Operator attestation of +/// the checkpoint is a separate axis — see [`verify_artifact_log_inclusion`]. +fn verify_inclusion_binds_artifact( + artifact_digest: &str, + t: &TransparencyInclusion, +) -> Result<(), EvidencePackError> { + if t.leaf_hash != hash_leaf(artifact_digest.as_bytes()) { + return Err(EvidencePackError::OfflineVerification(format!( + "transparency evidence is not for this artifact: leaf hash does not re-derive from {artifact_digest}" + ))); + } + verify_transparency_inclusion(t) +} + +/// Verify, fully offline, that an artifact's digest is anchored in a +/// transparency log operated by the **pinned** key — the all-or-nothing +/// verdict an artifact verifier needs (vs. the per-axis row verdicts of +/// [`verify_evidence_pack_offline`]). +/// +/// Three checks, each fail-closed: +/// 1. the evidence binds to *this* artifact (`leaf_hash = +/// hash_leaf(artifact_digest)`); +/// 2. the Merkle inclusion (and any consistency) proof verifies against the +/// embedded signed checkpoint; +/// 3. the checkpoint signature verifies under the caller's pinned log key — +/// never under the key the evidence itself carries. +/// +/// Args: +/// * `artifact_digest`: The canonical `sha256:<64 hex>` digest string — the +/// exact leaf data the log appended. +/// * `t`: The inclusion evidence (what `LogWriter::prove` mints). +/// * `pinned_log_key`: The log operator's Ed25519 key, pinned out of band. +/// +/// Usage: +/// ```ignore +/// verify_artifact_log_inclusion("sha256:ab…", &evidence, &pinned_key)?; +/// ``` +pub fn verify_artifact_log_inclusion( + artifact_digest: &str, + t: &TransparencyInclusion, + pinned_log_key: &Ed25519PublicKey, +) -> Result<(), EvidencePackError> { + verify_inclusion_binds_artifact(artifact_digest, t)?; + t.signed_checkpoint + .verify_log_signature(pinned_log_key) + .map_err(|e| { + EvidencePackError::OfflineVerification(format!( + "checkpoint is not attested by the pinned log key: {e}" + )) + }) +} + /// Verify an offline evidence pack with **zero network**. /// /// Checks the embedded [`AirGappedOrgBundle`] integrity (every event @@ -340,10 +394,10 @@ pub fn verify_evidence_pack_offline( // The proof must be FOR this row's artifact: the leaf is the canonical // digest string, so a valid proof over some other leaf is a mismatch, // not evidence. - let transparency_verified = row.transparency.as_ref().map(|t| { - t.leaf_hash == hash_leaf(row.artifact_digest.as_bytes()) - && verify_transparency_inclusion(t).is_ok() - }); + let transparency_verified = row + .transparency + .as_ref() + .map(|t| verify_inclusion_binds_artifact(&row.artifact_digest, t).is_ok()); // The operator axis: only decidable when the verifier pinned a log key // AND the row anchors to a checkpoint — anything else is honestly None. @@ -424,7 +478,7 @@ pub fn verify_evidence_pack_offline_json( } /// Parse a pinned log key from its hex wire form (fail-closed on malformed input). -fn parse_log_key_hex(hex_key: &str) -> Result { +pub fn parse_log_key_hex(hex_key: &str) -> Result { let bytes = hex::decode(hex_key.trim()) .map_err(|e| EvidencePackError::Decode(format!("pinned log key: invalid hex: {e}")))?; Ed25519PublicKey::try_from_slice(&bytes) @@ -642,4 +696,89 @@ mod tests { "inclusion not anchored to the checkpoint must fail closed" ); } + + /// A checkpoint genuinely signed by `signing_key` over its note body. + fn operator_signed_checkpoint( + size: u64, + root: MerkleHash, + signing_key: &ed25519_dalek::SigningKey, + ) -> SignedCheckpoint { + use ed25519_dalek::Signer; + let checkpoint = Checkpoint { + origin: LogOrigin::new("auths.dev/log").unwrap(), + size, + root, + timestamp: fixed_now(), + }; + let sig = signing_key.sign(checkpoint.to_note_body().as_bytes()); + SignedCheckpoint { + checkpoint, + log_signature: Ed25519Signature::from_bytes(sig.to_bytes()), + log_public_key: Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()), + witnesses: vec![], + ecdsa_checkpoint_signature: None, + ecdsa_checkpoint_key: None, + } + } + + /// Evidence for `digest` in a two-leaf log signed by `signing_key`. + fn artifact_evidence( + digest: &str, + signing_key: &ed25519_dalek::SigningKey, + ) -> TransparencyInclusion { + let leaf = hash_leaf(digest.as_bytes()); + let sibling = hash_leaf(b"sha256:other"); + let root = hash_children(&leaf, &sibling); + TransparencyInclusion { + leaf_hash: leaf, + inclusion_proof: InclusionProof { + index: 0, + size: 2, + root, + hashes: vec![sibling], + }, + signed_checkpoint: operator_signed_checkpoint(2, root, signing_key), + consistency_proof: None, + } + } + + #[test] + fn artifact_log_inclusion_verifies_under_the_pinned_operator() { + let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let evidence = artifact_evidence(digest, &key); + let pinned = Ed25519PublicKey::from_bytes(key.verifying_key().to_bytes()); + verify_artifact_log_inclusion(digest, &evidence, &pinned) + .expect("bound, included, operator-attested evidence verifies"); + } + + #[test] + fn artifact_log_inclusion_rejects_evidence_for_a_different_artifact() { + let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let evidence = artifact_evidence(digest, &key); + let pinned = Ed25519PublicKey::from_bytes(key.verifying_key().to_bytes()); + let other = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let err = verify_artifact_log_inclusion(other, &evidence, &pinned) + .expect_err("a valid proof for some OTHER leaf is a mismatch, not evidence"); + assert!( + err.to_string().contains("not for this artifact"), + "rejection must name the binding failure, got: {err}" + ); + } + + #[test] + fn artifact_log_inclusion_rejects_a_different_pinned_operator() { + let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let evidence = artifact_evidence(digest, &key); + let other_operator = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + let pinned = Ed25519PublicKey::from_bytes(other_operator.verifying_key().to_bytes()); + let err = verify_artifact_log_inclusion(digest, &evidence, &pinned) + .expect_err("a checkpoint from a different operator must fail the pinned-key check"); + assert!( + err.to_string().contains("pinned log key"), + "rejection must name the operator failure, got: {err}" + ); + } } diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index 002e74d1..3a293c1f 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -20,7 +20,9 @@ pub struct VerificationReport { /// Optional witness quorum result (present when witness verification was performed) #[serde(default, skip_serializing_if = "Option::is_none")] pub witness_quorum: Option, - /// Whether the attestation is anchored in the issuer's KEL via an ixn seal. + /// Whether the attestation is anchored in a verifiable log: the issuer's + /// KEL via an ixn seal, or a transparency log via an offline inclusion + /// proof verified under a pinned log key. #[serde(default, skip_serializing_if = "Option::is_none")] pub anchored: Option, /// Structured duplicity warning from the shared-KEL detector. From fa241976365022ce2db503fa15741e93e0b7e30d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 05:01:07 +0100 Subject: [PATCH 20/32] feat(auths-mobile-ffi): delegated device-link KEL verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validate_delegated_kel_json — the delegated/device-link counterpart of validate_kel_json. It carries the delegator (root) KEL alongside the device KEL, authenticates the delegator first, seeds a KelSealIndex from its proven anchoring seals, then replays the device's dip against it. The -G source-seal couple now travels in the device attachment and is rehydrated onto dip/drt events, so the bilateral delegation binding survives the JSON wire. Pairing is delegation-aware and shared with validate_kel_json (one source of truth). A forged delegator, unanchored dip, or back-reference mismatch all fail closed; the single-KEL entrypoint still refuses a dip by design. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .../auths-mobile-ffi/src/kel_verification.rs | 332 +++++++++++++++++- crates/auths-mobile-ffi/src/lib.rs | 4 +- 2 files changed, 321 insertions(+), 15 deletions(-) diff --git a/crates/auths-mobile-ffi/src/kel_verification.rs b/crates/auths-mobile-ffi/src/kel_verification.rs index bef0ca33..530a016e 100644 --- a/crates/auths-mobile-ffi/src/kel_verification.rs +++ b/crates/auths-mobile-ffi/src/kel_verification.rs @@ -12,9 +12,12 @@ //! Fail-closed properties, inherited from the core: //! - An absent or short attachment list is an UNAUTHENTICATED KEL and is //! refused outright — there is no structural-only fallback. -//! - A delegated (`dip`/`drt`) KEL is refused here: a single-KEL -//! entrypoint cannot supply the delegator's anchoring seals, so it -//! must resolve through a path that carries the delegator KEL too. +//! - A delegated (`dip`/`drt`) KEL is refused by [`validate_kel_json`]: a +//! single-KEL entrypoint cannot supply the delegator's anchoring seals. +//! The device-link path that *can* verify it — [`validate_delegated_kel_json`] +//! — carries the delegator (root) KEL alongside the device KEL, authenticates +//! both, and resolves each delegated event against the delegator's +//! independently-proven anchoring seals. use crate::MobileError; @@ -64,25 +67,152 @@ pub fn validate_kel_json( kel_json: String, attachments_json: String, ) -> Result { + // Pairing fails closed on a count mismatch (an unauthenticated KEL never + // degrades to a structural-only replay); the rule lives in auths-keri. + let signed = authenticate_kel(&kel_json, &attachments_json)?; + + // No delegator lookup: a delegated (`dip`/`drt`) KEL fails closed here, by + // design — use `validate_delegated_kel_json`, which carries the delegator KEL. + let state = auths_keri::validate_signed_kel(&signed, None) + .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + + verified_key_state(state) +} + +/// Authenticate a **delegated device KEL** against its delegator's KEL and +/// return the device's key state. +/// +/// A delegated identifier's inception (`dip`) and rotations (`drt`) are only +/// authorized when the delegator anchored them — its KEL must carry an `ixn` +/// seal back-referencing each delegated event. A single-KEL entrypoint +/// ([`validate_kel_json`]) therefore fails closed on a `dip`/`drt`: it has no +/// delegator KEL to resolve those anchoring seals against. This is the +/// device-link path the app's *device rows* need — it carries the delegator +/// (root) KEL alongside the device KEL, the same shape the WASM verifier's +/// `verifyDeviceLink` resolves. +/// +/// Both KELs are authenticated, not asserted-trusted: the delegator KEL is +/// replayed signature-by-signature first ([`auths_keri::validate_signed_kel`]), +/// and only its proven events seed the [`auths_keri::KelSealIndex`] the device +/// KEL's delegated events resolve through. A forged delegator KEL, a delegator +/// with no anchoring seal for the device's `dip`, or a device event whose `-G` +/// back-reference doesn't match the delegator's seal all fail closed — there is +/// no structural-only fallback on either side. +/// +/// Args: +/// * `device_kel_json`: JSON array of the delegated device's KEL events +/// (`dip` first, then `drt`/`ixn`). +/// * `device_attachments_json`: JSON array of the device KEL's per-event CESR +/// `-A##` indexed-signature groups, one per event. +/// * `delegator_kel_json`: JSON array of the delegator (root) KEL events. +/// * `delegator_attachments_json`: JSON array of the delegator KEL's per-event +/// CESR signature groups, one per event. +/// +/// Usage: +/// ```ignore +/// // iOS Swift — device rows flip amber→green once proven on-device: +/// let device = try validateDelegatedKelJson( +/// deviceKelJson: deviceKel, deviceAttachmentsJson: deviceAtts, +/// delegatorKelJson: rootKel, delegatorAttachmentsJson: rootAtts) +/// guard device.did == claimedDeviceDid else { /* registry lied */ } +/// ``` +#[uniffi::export] +pub fn validate_delegated_kel_json( + device_kel_json: String, + device_attachments_json: String, + delegator_kel_json: String, + delegator_attachments_json: String, +) -> Result { + // Authenticate the delegator first: only events proven against the + // delegator's own in-force key-state may anchor a delegated device event. + let delegator = authenticate_kel(&delegator_kel_json, &delegator_attachments_json)?; + let delegator_events: Vec = + delegator.iter().map(|s| s.event.clone()).collect(); + let seal_index = auths_keri::KelSealIndex::from_events(&delegator_events); + + let device = authenticate_kel(&device_kel_json, &device_attachments_json)?; + let state = auths_keri::validate_signed_kel(&device, Some(&seal_index)) + .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + + verified_key_state(state) +} + +/// Parse a KEL's events + CESR attachments and pair them into authenticatable +/// [`auths_keri::SignedEvent`]s, fail-closed on an absent/short attachment list +/// (the unauthenticated-KEL refusal lives once in `auths-keri`). +/// +/// Each attachment is parsed as a delegated attachment — the `-A##` +/// indexed-signature group, optionally followed by a `-G##` `SealSourceCouple`. +/// On a `dip`/`drt` the couple's [`auths_keri::SourceSeal`] is rehydrated onto +/// the event's `source_seal` (the JSON body never carries it — it is +/// `#[serde(skip)]`), so the bilateral delegation binding the delegator's +/// [`auths_keri::KelSealIndex`] is checked against survives the JSON wire. A +/// non-delegated event that carries a stray `-G` couple is rejected. +fn authenticate_kel( + kel_json: &str, + attachments_json: &str, +) -> Result, MobileError> { if kel_json.len() > MAX_KEL_INPUT_BYTES || attachments_json.len() > MAX_KEL_INPUT_BYTES { return Err(MobileError::Serialization(format!( "KEL input too large: max {MAX_KEL_INPUT_BYTES} bytes per field" ))); } - let events = auths_keri::parse_kel_json(&kel_json) + let events = auths_keri::parse_kel_json(kel_json) .map_err(|e| MobileError::Serialization(format!("Invalid KEL JSON: {e}")))?; - let attachments: Vec = serde_json::from_str(&attachments_json) + let attachments: Vec = serde_json::from_str(attachments_json) .map_err(|e| MobileError::Serialization(format!("Invalid attachments JSON: {e}")))?; - // Pairing fails closed on a count mismatch (an unauthenticated KEL never - // degrades to a structural-only replay); the rule lives in auths-keri. - let signed = auths_keri::pair_kel_attachments(events, &attachments) - .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + if attachments.len() != events.len() { + // An unsigned event is unauthenticatable; refuse before any work rather + // than degrade to a structural-only replay (the same rule + // `pair_kel_attachments` enforces — restated here because this path + // parses delegated attachments). + return Err(MobileError::KelVerificationFailed(format!( + "KEL/attachment count mismatch ({} events vs {} attachments): the KEL is unauthenticated, refusing", + events.len(), + attachments.len() + ))); + } - let state = auths_keri::validate_signed_kel(&signed, None) - .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + events + .into_iter() + .zip(attachments) + .map(|(mut event, att)| { + let (sigs, seals) = auths_keri::parse_delegated_attachment(att.as_bytes()) + .map_err(|e| MobileError::KelVerificationFailed(e.to_string()))?; + rehydrate_source_seal(&mut event, seals)?; + Ok(auths_keri::SignedEvent::new(event, sigs)) + }) + .collect() +} +/// Rehydrate a delegated event's `-G` source seal from its attachment, or reject +/// a couple attached to an event that has no delegate side. +fn rehydrate_source_seal( + event: &mut auths_keri::Event, + seals: Vec, +) -> Result<(), MobileError> { + let source_seal = match seals.into_iter().next() { + Some(seal) => seal, + None => return Ok(()), + }; + match event { + auths_keri::Event::Dip(dip) => dip.source_seal = Some(source_seal), + auths_keri::Event::Drt(drt) => drt.source_seal = Some(source_seal), + _ => { + return Err(MobileError::KelVerificationFailed( + "source-seal couple attached to a non-delegated event".to_string(), + )); + } + } + Ok(()) +} + +/// Project an authenticated [`auths_keri::KeyState`] into the FFI record. +fn verified_key_state( + state: auths_keri::KeyState, +) -> Result { let prefix = state.prefix.as_str().to_string(); Ok(VerifiedKeyState { did: format!("did:keri:{prefix}"), @@ -106,9 +236,10 @@ pub fn validate_kel_json( mod tests { use super::*; use auths_keri::{ - CesrKey, Event, IcpEvent, IndexedSignature, IxnEvent, KeriSequence, Prefix, Said, Seal, - Threshold, VersionString, finalize_icp_event, finalize_ixn_event, serialize_attachment, - serialize_for_signing, + CesrKey, DipEvent, DipEventInit, Event, IcpEvent, IndexedSignature, IxnEvent, KeriSequence, + Prefix, Said, Seal, SourceSeal, Threshold, VersionString, finalize_dip_event, + finalize_icp_event, finalize_ixn_event, serialize_attachment, serialize_for_signing, + serialize_source_seal_couples, }; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use p256::ecdsa::{SigningKey, signature::Signer}; @@ -255,4 +386,177 @@ mod tests { Err(MobileError::Serialization(_)) )); } + + // ----------------------------------------------------------------------- + // Delegated device-link path: validate_delegated_kel_json + // ----------------------------------------------------------------------- + + /// A delegator (root) KEL that anchors a device dip, plus the signed device + /// dip whose `-G` source-seal couple binds it back to that anchoring ixn. + struct DelegatedFixture { + device_kel_json: String, + device_attachments_json: String, + delegator_kel_json: String, + delegator_attachments_json: String, + device_prefix: String, + } + + /// Append the `-G` `SealSourceCouple` to an indexed-sig attachment so the + /// delegate side travels on the JSON wire (the dip body never carries it). + fn delegated_attachment(event: &Event, sk: &SigningKey, source_seal: &SourceSeal) -> String { + let mut att = cesr_attachment(event, sk); + let couple = serialize_source_seal_couples(std::slice::from_ref(source_seal)).unwrap(); + att.push_str(std::str::from_utf8(&couple).unwrap()); + att + } + + /// Build the delegator+device fixture. `anchor` controls whether the root + /// actually anchors the dip — `false` reproduces a delegation with no + /// authorizing seal, which must fail closed. + fn delegated_fixture(anchor: bool) -> DelegatedFixture { + // --- delegator (root) inception --- + let (root_icp, root_sk) = signed_icp(); + let root_prefix = root_icp.i.clone(); + + // --- device delegated inception (dip), delegated by the root --- + let device_sk = SigningKey::random(&mut OsRng); + let device_pk = device_sk.verifying_key().to_encoded_point(true); + let device_key = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(device_pk.as_bytes())); + let mut dip = finalize_dip_event(DipEvent::new(DipEventInit { + v: VersionString::placeholder(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: Threshold::Simple(1), + k: vec![CesrKey::new_unchecked(device_key)], + nt: Threshold::Simple(1), + n: vec![Said::new_unchecked("EDeviceNextCommitment".to_string())], + bt: Threshold::Simple(0), + b: vec![], + c: vec![], + a: vec![], + di: root_prefix.clone(), + })) + .unwrap(); + let device_prefix = dip.i.clone(); + let dip_said = dip.d.clone(); + + // --- delegator anchors the dip in an ixn (the authorizing seal) --- + let mut root_kel = vec![Event::Icp(root_icp.clone())]; + let mut root_atts = vec![cesr_attachment(&root_kel[0], &root_sk)]; + if anchor { + let ixn = finalize_ixn_event(IxnEvent { + v: VersionString::placeholder(), + d: Said::default(), + i: root_prefix.clone(), + s: KeriSequence::new(1), + p: root_icp.d.clone(), + a: vec![Seal::KeyEvent { + i: device_prefix.clone(), + s: KeriSequence::new(0), + d: dip_said.clone(), + }], + }) + .unwrap(); + // Delegate-side -G back-reference to the anchoring ixn. + dip.source_seal = Some(SourceSeal { + s: KeriSequence::new(1), + d: ixn.d.clone(), + }); + let ixn_event = Event::Ixn(ixn); + root_atts.push(cesr_attachment(&ixn_event, &root_sk)); + root_kel.push(ixn_event); + } + + // Sign the dip (over its body) and attach its -G couple if present. + let dip_event = Event::Dip(dip.clone()); + let device_att = match &dip.source_seal { + Some(seal) => delegated_attachment(&dip_event, &device_sk, seal), + None => cesr_attachment(&dip_event, &device_sk), + }; + + let (delegator_kel_json, delegator_attachments_json) = to_json(&root_kel, &root_atts); + let (device_kel_json, device_attachments_json) = + to_json(&[dip_event], &[device_att]); + + DelegatedFixture { + device_kel_json, + device_attachments_json, + delegator_kel_json, + delegator_attachments_json, + device_prefix: device_prefix.as_str().to_string(), + } + } + + #[test] + fn verifies_anchored_delegated_device_kel() { + let f = delegated_fixture(true); + let state = validate_delegated_kel_json( + f.device_kel_json, + f.device_attachments_json, + f.delegator_kel_json, + f.delegator_attachments_json, + ) + .expect("an anchored delegated device KEL must verify against its delegator"); + assert_eq!(state.prefix, f.device_prefix); + assert_eq!(state.did, format!("did:keri:{}", f.device_prefix)); + assert_eq!(state.sequence, 0); + } + + #[test] + fn single_kel_entrypoint_still_fails_closed_on_a_dip() { + // The delegated KEL must NOT verify through the single-KEL entrypoint — + // that path has no delegator seals and must refuse the dip. + let f = delegated_fixture(true); + let err = validate_kel_json(f.device_kel_json, f.device_attachments_json).unwrap_err(); + assert!( + matches!(err, MobileError::KelVerificationFailed(_)), + "got {err:?}" + ); + } + + #[test] + fn rejects_delegated_device_with_no_anchoring_seal() { + // The delegator never anchored the dip: no seal authorizes the + // delegation, so it must fail closed even with the delegator KEL present. + let f = delegated_fixture(false); + let err = validate_delegated_kel_json( + f.device_kel_json, + f.device_attachments_json, + f.delegator_kel_json, + f.delegator_attachments_json, + ) + .unwrap_err(); + assert!( + matches!(err, MobileError::KelVerificationFailed(_)), + "got {err:?}" + ); + } + + #[test] + fn rejects_delegated_device_against_forged_delegator() { + // A delegator KEL whose inception signature doesn't verify cannot seed a + // trusted seal index — the whole device-link verification fails closed. + let f = delegated_fixture(true); + let forged_delegator_atts = serde_json::to_string(&[ + // Replace the root's real signatures with an unrelated signer's. + { + let (decoy, sk) = signed_icp(); + cesr_attachment(&Event::Icp(decoy), &sk) + }, + "-AAB".to_string(), + ]) + .unwrap(); + let err = validate_delegated_kel_json( + f.device_kel_json, + f.device_attachments_json, + f.delegator_kel_json, + forged_delegator_atts, + ) + .unwrap_err(); + assert!( + matches!(err, MobileError::KelVerificationFailed(_)), + "got {err:?}" + ); + } } diff --git a/crates/auths-mobile-ffi/src/lib.rs b/crates/auths-mobile-ffi/src/lib.rs index 4b5108a9..000811e9 100644 --- a/crates/auths-mobile-ffi/src/lib.rs +++ b/crates/auths-mobile-ffi/src/lib.rs @@ -47,7 +47,9 @@ pub use delegated_inception_context::{ pub use identity_context::{ P256IdentityInceptionContext, assemble_p256_identity, build_p256_identity_inception_payload, }; -pub use kel_verification::{VerifiedKeyState, validate_kel_json}; +pub use kel_verification::{ + VerifiedKeyState, validate_delegated_kel_json, validate_kel_json, +}; pub use pairing_context::{ PairingBindingContext, assemble_pairing_response_body, build_pairing_binding_message, }; From 4afcffab1eab1fe480c15c9ae55cc1a0349ea5bb Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 05:35:24 +0100 Subject: [PATCH 21/32] feat(artifact): --curve flag for ephemeral CI signing The ephemeral CI signer minted its one-time key on a hard-coded P-256 curve. Add a --curve option to `artifact sign --ci`, parsed into a typed CurveType and threaded through EphemeralSignRequest into sign_artifact_ephemeral, which now mints the key on the requested curve. P-256 remains the default. - CurveType gains a single FromStr (the one source of truth for parsing a curve name) plus a ParseCurveError; clap derives the value parser from it. - TypedSeed::from_curve builds (curve, bytes) -> TypedSeed in one place; sign_artifact_ephemeral and sign_artifact_raw both route through it. - EphemeralSignRequest carries a parsed curve field so the signer never branches on a string. Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/artifact/mod.rs | 7 ++ crates/auths-cli/src/commands/demo.rs | 1 + crates/auths-crypto/src/key_ops.rs | 10 +++ crates/auths-crypto/src/lib.rs | 3 +- crates/auths-crypto/src/provider.rs | 50 ++++++++++++++ .../auths-sdk/src/domains/signing/service.rs | 33 +++++----- .../tests/cases/ephemeral_signing.rs | 65 +++++++++++++++++++ 7 files changed, 153 insertions(+), 16 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 8aec0230..590114c7 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -89,6 +89,11 @@ pub enum ArtifactSubcommand { #[arg(long)] ci: bool, + /// Curve for the ephemeral CI key (`p256` or `ed25519`). Only meaningful + /// with --ci; defaults to p256. + #[arg(long, value_name = "CURVE", requires = "ci")] + curve: Option, + /// CI platform override when --ci is used outside a detected CI environment. #[arg(long, requires = "ci")] ci_platform: Option, @@ -413,6 +418,7 @@ pub fn handle_artifact( commit, no_commit, ci, + curve, ci_platform, oidc_token, oidc_audience, @@ -506,6 +512,7 @@ pub fn handle_artifact( data: &data, artifact_name, commit_sha, + curve: curve.unwrap_or_default(), expires_in, note, ci_env: Some(ci_env_json), diff --git a/crates/auths-cli/src/commands/demo.rs b/crates/auths-cli/src/commands/demo.rs index 1fb818e3..26f6ad51 100644 --- a/crates/auths-cli/src/commands/demo.rs +++ b/crates/auths-cli/src/commands/demo.rs @@ -36,6 +36,7 @@ impl ExecutableCommand for DemoCommand { data, artifact_name: Some("demo.txt".into()), commit_sha: DEMO_COMMIT_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: Some("auths demo — local only".into()), ci_env: None, diff --git a/crates/auths-crypto/src/key_ops.rs b/crates/auths-crypto/src/key_ops.rs index bf301f67..a9127553 100644 --- a/crates/auths-crypto/src/key_ops.rs +++ b/crates/auths-crypto/src/key_ops.rs @@ -27,6 +27,16 @@ pub enum TypedSeed { } impl TypedSeed { + /// Builds a typed seed from raw 32-byte material on the named curve — the + /// one source of truth for `(curve, bytes) -> TypedSeed`. Adding a curve + /// makes this `match` non-exhaustive, so every caller is forced to handle it. + pub fn from_curve(curve: CurveType, bytes: [u8; 32]) -> Self { + match curve { + CurveType::Ed25519 => Self::Ed25519(bytes), + CurveType::P256 => Self::P256(bytes), + } + } + /// Returns the curve this seed belongs to. pub fn curve(&self) -> CurveType { match self { diff --git a/crates/auths-crypto/src/lib.rs b/crates/auths-crypto/src/lib.rs index bf4b5040..9b5a9aea 100644 --- a/crates/auths-crypto/src/lib.rs +++ b/crates/auths-crypto/src/lib.rs @@ -52,7 +52,8 @@ pub use pkcs8::Pkcs8Der; pub use provider::default_provider; pub use provider::{ CryptoError, CryptoProvider, CurveType, ED25519_PUBLIC_KEY_LEN, ED25519_SIGNATURE_LEN, - P256_PUBLIC_KEY_LEN, P256_SIGNATURE_LEN, SecureSeed, SeedDecodeError, decode_seed_hex, + P256_PUBLIC_KEY_LEN, P256_SIGNATURE_LEN, ParseCurveError, SecureSeed, SeedDecodeError, + decode_seed_hex, }; #[cfg(all(feature = "native", not(target_arch = "wasm32")))] pub use ring_provider::RingCryptoProvider; diff --git a/crates/auths-crypto/src/provider.rs b/crates/auths-crypto/src/provider.rs index c1acc52d..ab977686 100644 --- a/crates/auths-crypto/src/provider.rs +++ b/crates/auths-crypto/src/provider.rs @@ -651,6 +651,29 @@ impl std::fmt::Display for CurveType { } } +/// The error returned when a curve name cannot be parsed into a [`CurveType`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("unknown curve {got:?}: expected 'p256' or 'ed25519'")] +pub struct ParseCurveError { + /// The unrecognized input. + pub got: String, +} + +impl std::str::FromStr for CurveType { + type Err = ParseCurveError; + + /// Parses a curve name, case-insensitively, accepting the common spellings. + /// This is the single source of truth for turning a `--curve` argument into + /// a typed [`CurveType`] — round-trips with [`Display`](std::fmt::Display). + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "ed25519" => Ok(Self::Ed25519), + "p256" | "p-256" => Ok(Self::P256), + _ => Err(ParseCurveError { got: s.to_string() }), + } + } +} + impl CurveType { /// Returns the expected public key length for this curve. pub fn public_key_len(&self) -> usize { @@ -668,3 +691,30 @@ impl CurveType { } } } + +#[cfg(test)] +mod curve_type_tests { + use super::CurveType; + use std::str::FromStr; + + #[test] + fn parses_canonical_and_aliased_spellings() { + assert_eq!(CurveType::from_str("ed25519").unwrap(), CurveType::Ed25519); + assert_eq!(CurveType::from_str("Ed25519").unwrap(), CurveType::Ed25519); + assert_eq!(CurveType::from_str("p256").unwrap(), CurveType::P256); + assert_eq!(CurveType::from_str("P-256").unwrap(), CurveType::P256); + } + + #[test] + fn display_round_trips_through_from_str() { + for c in [CurveType::Ed25519, CurveType::P256] { + assert_eq!(CurveType::from_str(&c.to_string()).unwrap(), c); + } + } + + #[test] + fn rejects_unknown_curve() { + let err = CurveType::from_str("secp256k1").unwrap_err(); + assert_eq!(err.got, "secp256k1"); + } +} diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index 431763d5..44a6f35d 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -691,6 +691,11 @@ pub struct EphemeralSignRequest<'a> { pub artifact_name: Option, /// Git commit SHA this artifact was built from (required, 40 or 64 hex chars). pub commit_sha: String, + /// Curve the one-time ephemeral key is minted on. A parsed + /// [`CurveType`](auths_crypto::CurveType) (the boundary validated it), so + /// the signer never branches on a string. Defaults to P-256 via + /// `CurveType::default()`. + pub curve: auths_crypto::CurveType, /// Optional TTL in seconds. pub expires_in: Option, /// Optional attestation note. @@ -702,9 +707,9 @@ pub struct EphemeralSignRequest<'a> { pub oidc_binding: Option, } -/// Signs artifact bytes with a one-time ephemeral Ed25519 key. No keychain, no -/// identity storage, no passphrase — the key is generated, used, and zeroized -/// within this function call. +/// Signs artifact bytes with a one-time ephemeral key on the requested curve. +/// No keychain, no identity storage, no passphrase — the key is generated, +/// used, and zeroized within this function call. /// /// The ephemeral key signs "this artifact was built from this commit." Trust /// derives transitively: consumers verify the commit is signed by a maintainer, @@ -722,6 +727,7 @@ pub struct EphemeralSignRequest<'a> { /// data: b"artifact bytes", /// artifact_name: Some("release.tar.gz".into()), /// commit_sha: "abc123def456abc123def456abc123def456abc1".into(), +/// curve: auths_crypto::CurveType::default(), /// expires_in: None, /// note: None, /// ci_env: None, @@ -736,23 +742,23 @@ pub fn sign_artifact_ephemeral( data, artifact_name, commit_sha, + curve, expires_in, note, ci_env, oidc_binding, } = req; - // 1. Generate ephemeral P-256 seed and zeroize on drop + // 1. Generate the ephemeral seed and zeroize on drop let mut seed_bytes = Zeroizing::new([0u8; 32]); ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), seed_bytes.as_mut()) .map_err(|_| ArtifactSigningError::AttestationFailed("RNG failure".into()))?; - // 2. Derive pubkey and DIDs using P-256 (default curve) - let typed_seed = auths_crypto::TypedSeed::P256(*seed_bytes); + // 2. Derive pubkey and DIDs on the requested curve + let typed_seed = auths_crypto::TypedSeed::from_curve(curve, *seed_bytes); let pubkey_vec = auths_crypto::typed_public_key(&typed_seed) .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; - let device_did = - CanonicalDid::from_public_key_did_key(&pubkey_vec, auths_crypto::CurveType::P256); + let device_did = CanonicalDid::from_public_key_did_key(&pubkey_vec, curve); #[allow(clippy::disallowed_methods)] let identity_did = IdentityDID::new_unchecked(device_did.as_str()); @@ -794,11 +800,11 @@ pub fn sign_artifact_ephemeral( let mut seeds: HashMap = HashMap::new(); seeds.insert( identity_alias.as_str().to_string(), - (SecureSeed::new(*seed_bytes), auths_crypto::CurveType::P256), + (SecureSeed::new(*seed_bytes), curve), ); seeds.insert( device_alias.as_str().to_string(), - (SecureSeed::new(*seed_bytes), auths_crypto::CurveType::P256), + (SecureSeed::new(*seed_bytes), curve), ); let signer = SeedMapSigner { seeds }; let noop_provider = auths_core::PrefilledPassphraseProvider::new(""); @@ -811,7 +817,7 @@ pub fn sign_artifact_ephemeral( identity_did: &identity_did, subject: &device_did, device_public_key: &pubkey_vec, - device_curve: auths_crypto::CurveType::P256, + device_curve: curve, payload: Some(payload_value), meta: &meta, identity_alias: Some(&identity_alias), @@ -873,10 +879,7 @@ pub fn sign_artifact_raw( ) -> Result { // Default to P-256 per workspace convention. The raw seed is 32 bytes for both curves. let curve = auths_crypto::CurveType::default(); - let typed = match curve { - auths_crypto::CurveType::Ed25519 => auths_crypto::TypedSeed::Ed25519(*seed.as_bytes()), - auths_crypto::CurveType::P256 => auths_crypto::TypedSeed::P256(*seed.as_bytes()), - }; + let typed = auths_crypto::TypedSeed::from_curve(curve, *seed.as_bytes()); let pubkey = auths_crypto::typed_public_key(&typed) .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; diff --git a/crates/auths-sdk/tests/cases/ephemeral_signing.rs b/crates/auths-sdk/tests/cases/ephemeral_signing.rs index 76eb5950..d557956d 100644 --- a/crates/auths-sdk/tests/cases/ephemeral_signing.rs +++ b/crates/auths-sdk/tests/cases/ephemeral_signing.rs @@ -12,6 +12,7 @@ fn produces_valid_attestation_with_did_key_issuer() { data: b"test data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -43,6 +44,7 @@ fn signer_type_is_workload() { data: b"test data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -63,6 +65,7 @@ fn commit_sha_is_present() { data: b"test data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -83,6 +86,7 @@ fn each_call_uses_different_ephemeral_key() { data: b"data1", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -96,6 +100,7 @@ fn each_call_uses_different_ephemeral_key() { data: b"data2", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -127,6 +132,7 @@ fn ci_environment_in_payload() { data: b"test data", artifact_name: Some("test.tar.gz".into()), commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: Some(ci_env), @@ -152,6 +158,7 @@ fn empty_data_produces_valid_attestation() { data: b"", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -172,6 +179,7 @@ fn invalid_commit_sha_rejected() { data: b"test data", artifact_name: None, commit_sha: "not-a-valid-sha".into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -196,6 +204,7 @@ fn tamper_commit_sha_breaks_signature() { data: b"test data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -242,6 +251,7 @@ fn tamper_artifact_fails_digest_check() { data: b"original artifact data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -271,6 +281,7 @@ fn ephemeral_key_collision_check() { data: b"data", artifact_name: None, commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -321,6 +332,7 @@ fn oidc_binding_is_signature_covered() { data: b"release bytes", artifact_name: Some("release.tar.gz".into()), commit_sha: VALID_SHA.into(), + curve: auths_crypto::CurveType::default(), expires_in: None, note: None, ci_env: None, @@ -373,3 +385,56 @@ fn oidc_binding_is_signature_covered() { assert!(!r.is_valid(), "tampered binding must not verify"); } } + +/// The ephemeral signer mints its one-time key on the requested curve and the +/// produced attestation verifies under that curve — curve parity, not a +/// hard-coded default. Both supported curves round-trip. +#[test] +fn ephemeral_curve_is_honored_and_verifies() { + for curve in [ + auths_crypto::CurveType::P256, + auths_crypto::CurveType::Ed25519, + ] { + let result = sign_artifact_ephemeral( + Utc::now(), + EphemeralSignRequest { + data: b"curve parity", + artifact_name: None, + commit_sha: VALID_SHA.into(), + curve, + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, + ) + .expect("signing should succeed on every supported curve"); + + let att: Attestation = serde_json::from_str(&result.attestation_json).unwrap(); + + // The issuer DID resolves to a key on exactly the requested curve. + let (pk_bytes, resolved): (Vec, auths_crypto::CurveType) = + match auths_crypto::did_key_decode(att.issuer.as_str()).expect("did:key resolves") { + auths_crypto::DecodedDidKey::Ed25519(k) => { + (k.to_vec(), auths_crypto::CurveType::Ed25519) + } + auths_crypto::DecodedDidKey::P256(k) => (k, auths_crypto::CurveType::P256), + }; + assert_eq!(resolved, curve, "issuer key must be on the requested curve"); + + // And the attestation verifies as-signed under that curve. + let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes) + .expect("valid device pubkey"); + let rt = tokio::runtime::Runtime::new().unwrap(); + let report = rt + .block_on(auths_verifier::verify_chain( + std::slice::from_ref(&att), + &pk, + )) + .expect("verification should run"); + assert!( + report.is_valid(), + "{curve} ephemeral attestation must verify" + ); + } +} From 1bef4d0b40d7df63d617002a166fe3e2a2bf6deb Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 05:56:28 +0100 Subject: [PATCH 22/32] feat(artifact): --signature-only self-check for ephemeral verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ephemeral (did:key) CI signature trust-chains to a maintainer through its commit_sha; resolving that leg needs the maintainer's repo and pinned roots. The scrubbed runner that produced the signature has neither, so it could not confirm what it just emitted (verify forced the commit-anchor leg and returned valid=false). Add a typed EphemeralAnchor (Required | SignatureOnly) and an `artifact verify --signature-only` flag (conflicts with --verify-commit). SignatureOnly confines the verdict to digest-match + signature and defers the commit-anchor leg — the signer's self-check. It is not fail-open: a tampered payload still fails on the digest. The default (full chain) is unchanged. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/artifact/mod.rs | 15 ++++ .../auths-cli/src/commands/artifact/verify.rs | 85 +++++++++++++------ .../auths-cli/src/commands/unified_verify.rs | 1 + 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 590114c7..b3d732bc 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -212,6 +212,14 @@ pub enum ArtifactSubcommand { #[arg(long)] verify_commit: bool, + /// For an ephemeral (`did:key:`) attestation, confirm the signature over + /// the artifact digest WITHOUT chasing the commit-anchor leg. This is the + /// runner's self-check: it has no maintainer repo/roots, but it can prove + /// it signed what it emitted. A pass means "digest matches, ephemeral key + /// signed it", not "the signer trust-chains to a maintainer". + #[arg(long, conflicts_with = "verify_commit")] + signature_only: bool, + /// Verify an air-gapped org bundle entirely offline (no network access). #[arg(long)] offline: bool, @@ -641,6 +649,7 @@ pub fn handle_artifact( witness_keys, witness_threshold, verify_commit, + signature_only, offline, roots, member, @@ -660,6 +669,11 @@ pub fn handle_artifact( json, ); } + let ephemeral_anchor = if signature_only { + verify::EphemeralAnchor::SignatureOnly + } else { + verify::EphemeralAnchor::Required + }; let rt = tokio::runtime::Runtime::new()?; rt.block_on(verify::handle_verify( &file, @@ -670,6 +684,7 @@ pub fn handle_artifact( witness_keys, witness_threshold, verify_commit, + ephemeral_anchor, oidc_policy, oidc_policy_did, log_evidence, diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index 88ff4824..93055584 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -44,6 +44,26 @@ struct VerifyArtifactResult { error: Option, } +/// How an ephemeral (`did:key:`) attestation's commit-anchor leg is treated. +/// +/// An ephemeral CI signature trust-chains to a maintainer through its +/// `commit_sha` (artifact ← ephemeral key ← commit ← maintainer). Resolving +/// that leg needs the maintainer's repository and pinned roots — which the +/// scrubbed runner that *produced* the signature does not have. The runner +/// can still confirm what it just emitted: the artifact's digest and the +/// ephemeral signature over it. That standalone self-check is [`Self::SignatureOnly`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EphemeralAnchor { + /// Trust an ephemeral attestation only after its commit-anchor leg + /// resolves to a trusted maintainer. The full chain; the default. + Required, + /// Confirm an ephemeral attestation from its digest + signature alone, + /// without the commit-anchor leg. The signer's self-check: valid means + /// "this artifact's digest matches and the ephemeral key signed it", + /// not "this signer trust-chains to a maintainer". + SignatureOnly, +} + /// Inputs for [`handle_verify`] beyond the artifact path — named fields /// (the `AttestationInput` pattern) so call sites stay readable as the /// verify surface grows. @@ -60,6 +80,8 @@ pub struct VerifyArtifactArgs { pub witness_threshold: usize, /// Also verify the source commit's signing attestation. pub verify_commit: bool, + /// How to treat an ephemeral attestation's commit-anchor leg. + pub ephemeral_anchor: EphemeralAnchor, /// OIDC-subject policy to JOIN against the signed OIDC binding. pub oidc_policy: Option, /// Org `did:keri:` whose KEL-anchored OIDC-subject policy to resolve and @@ -83,6 +105,7 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> witness_keys, witness_threshold, verify_commit, + ephemeral_anchor, oidc_policy, oidc_policy_did, log_evidence, @@ -355,42 +378,56 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> } } - // 8a. Ephemeral attestation: verify commit signature transitively + // 8a. Ephemeral attestation: trust chains through the commit anchor. + // `--signature-only` confines the verdict to digest + signature — the + // self-check the runner that emitted the attestation can run without the + // maintainer's repo/roots. It does NOT chase the commit-anchor leg, so it + // never claims the signer trust-chains to a maintainer. let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:"); if is_ephemeral && valid { - match &attestation.commit_sha { - None => { + match ephemeral_anchor { + EphemeralAnchor::SignatureOnly => { if !is_json_mode() { eprintln!( - "Error: ephemeral attestation (did:key issuer) requires commit_sha. \ - This attestation is unsigned provenance without a commit anchor." + " Signature-only: ephemeral signature over the artifact digest \ + verifies; commit-anchor leg NOT checked (signer self-check)." ); } - valid = false; } - Some(sha) => { - // Verify the commit is signed by a trusted key. - // Uses in-process verification via auths-verifier (no git shell-out). - let commit_sig_ok = verify_commit_in_process(sha).await; - - if !commit_sig_ok { + EphemeralAnchor::Required => match &attestation.commit_sha { + None => { + if !is_json_mode() { + eprintln!( + "Error: ephemeral attestation (did:key issuer) requires commit_sha. \ + This attestation is unsigned provenance without a commit anchor." + ); + } valid = false; } + Some(sha) => { + // Verify the commit is signed by a trusted key. + // Uses in-process verification via auths-verifier (no git shell-out). + let commit_sig_ok = verify_commit_in_process(sha).await; - if !is_json_mode() { - if commit_sig_ok { - eprintln!( - " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer", - &sha[..8.min(sha.len())] - ); - } else { - eprintln!( - " Commit {} is not signed by a trusted maintainer.", - &sha[..8.min(sha.len())] - ); + if !commit_sig_ok { + valid = false; + } + + if !is_json_mode() { + if commit_sig_ok { + eprintln!( + " Trust chain: artifact <- ephemeral key <- commit {} <- maintainer", + &sha[..8.min(sha.len())] + ); + } else { + eprintln!( + " Commit {} is not signed by a trusted maintainer.", + &sha[..8.min(sha.len())] + ); + } } } - } + }, } } diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index 2adbe8f8..a6802c21 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -198,6 +198,7 @@ pub async fn handle_verify_unified( witness_keys: cmd.witness_keys, witness_threshold: cmd.witness_threshold, verify_commit: false, + ephemeral_anchor: crate::commands::artifact::verify::EphemeralAnchor::Required, oidc_policy: None, oidc_policy_did: None, log_evidence: None, From eb638af34676d734d8b390bcb95c5098364fe7bb Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 06:40:58 +0100 Subject: [PATCH 23/32] fix(org): resolve org signing key by DID so default-flag lifecycle works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org create/add-member/revoke-member/policy/compliance commands each reconstructed the org's keychain alias from a slug, but create derived it from the org NAME while the others derived it from the org DID — so the documented happy path failed with 'Key not found' for any org whose name differs from its DID. Add resolve_org_signing_alias() in the org domain as the one source of truth: the org's Primary key is stored keyed by its controller DID, so the keychain itself reports which alias holds it. Route every org-signing CLI command and compliance::resolve_org_signing through it; collapse the three divergent org_slug_alias copies to one fallback. An explicit --key still overrides, and a wrong alias still fails closed. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/compliance.rs | 25 +++----- crates/auths-cli/src/commands/org.rs | 33 ++++------ crates/auths-sdk/src/domains/org/service.rs | 70 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/crates/auths-cli/src/commands/compliance.rs b/crates/auths-cli/src/commands/compliance.rs index 71356df0..7f035102 100644 --- a/crates/auths-cli/src/commands/compliance.rs +++ b/crates/auths-cli/src/commands/compliance.rs @@ -25,6 +25,7 @@ use auths_sdk::workflows::compliance::{ build_offline_evidence_pack, discover_releases, load_witness_policy, sign_evidence_pack, sign_framework_report, verify_signed_evidence_pack_offline, }; +use auths_sdk::workflows::org::resolve_org_signing_alias; use auths_sdk::workflows::roots::parse_roots_typed; use auths_verifier::{Ed25519PublicKey, IdentityDID, Prefix}; @@ -33,26 +34,20 @@ use crate::config::CliConfig; use crate::factories::storage::build_auths_context; use crate::ux::format::{JsonResponse, Output, is_json_mode}; -/// Default keychain alias for an org's signing key (`org-{slug}`), matching the -/// `auths org` convention. -fn org_slug_alias(org: &str) -> String { - format!( - "org-{}", - org.chars() - .filter(|c| c.is_alphanumeric()) - .take(20) - .collect::() - .to_lowercase() - ) -} - -/// Resolve the org signing alias (defaulting to the slug alias) and its in-band curve. +/// Resolve the org signing alias and its in-band curve. +/// +/// The alias resolution is the same single source of truth the `auths org` +/// commands use ([`resolve_org_signing_alias`]): the org's Primary key is +/// located in the keychain by its DID, so the alias matches whatever `org +/// create` stored regardless of the org's human-readable name. fn resolve_org_signing( sdk_ctx: &AuthsContext, org: &str, key: Option, ) -> Result<(KeyAlias, CurveType)> { - let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(org))); + let org_prefix = org.strip_prefix("did:keri:").unwrap_or(org); + let org_alias = resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix, key) + .map_err(anyhow::Error::from)?; let (_pk, curve) = extract_public_key_bytes( sdk_ctx.key_storage.as_ref(), &org_alias, diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index df986594..040501b3 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -23,8 +23,8 @@ use auths_sdk::workflows::commit_trust::commit_signer_trailers; use auths_sdk::workflows::org::{ AuthorityAtSigning, Role, add_member, build_org_bundle, classify_authority_at_signing, create_org, fleet_metrics, list_members, list_offboarding_records, load_offboarding_record, - load_org_policy, member_role_order, revoke_member, set_org_oidc_policy, set_org_policy, - walk_delegation_chain, + load_org_policy, member_role_order, org_slug_alias, resolve_org_signing_alias, revoke_member, + set_org_oidc_policy, set_org_policy, walk_delegation_chain, }; use crate::factories::storage::build_auths_context; @@ -53,19 +53,6 @@ impl From for Role { } } -/// Default keychain alias for an org's signing key (`org-{slug}`), derived from -/// the org identifier when `--key` is not supplied. -fn org_slug_alias(org: &str) -> String { - format!( - "org-{}", - org.chars() - .filter(|c| c.is_alphanumeric()) - .take(20) - .collect::() - .to_lowercase() - ) -} - /// The `org` subcommand, handling member authorizations. #[derive(Parser, Debug, Clone)] #[command( @@ -763,7 +750,6 @@ pub fn handle_org( let org_prefix = Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); - let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org))); let member_alias = KeyAlias::new_unchecked(member_label.clone()); let capability_strings = capabilities.unwrap_or_else(|| role.default_capabilities()); @@ -773,6 +759,8 @@ pub fn handle_org( &ctx.env_config, Some(passphrase_provider.clone()), )?; + let org_alias = + resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?; let result = add_member( &sdk_ctx, &org_prefix, @@ -824,13 +812,14 @@ pub fn handle_org( let org_prefix = Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); - let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org))); let sdk_ctx = build_auths_context( &repo_path, &ctx.env_config, Some(passphrase_provider.clone()), )?; + let org_alias = + resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?; let record = revoke_member(&sdk_ctx, &org_prefix, &org_alias, &member, note) .context("Failed to revoke member")?; @@ -1092,8 +1081,6 @@ pub fn handle_org( let org_prefix = Prefix::new_unchecked( org.strip_prefix("did:keri:").unwrap_or(&org).to_string(), ); - let org_alias = - KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org))); let policy_json = fs::read(&file) .with_context(|| format!("Failed to read policy file {file:?}"))?; @@ -1102,6 +1089,11 @@ pub fn handle_org( &ctx.env_config, Some(passphrase_provider.clone()), )?; + let org_alias = resolve_org_signing_alias( + sdk_ctx.key_storage.as_ref(), + org_prefix.as_str(), + key, + )?; let result = set_org_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json) .context("Failed to set org policy")?; @@ -1141,7 +1133,6 @@ pub fn handle_org( OrgSubcommand::AnchorOidcPolicy { org, file, key } => { let org_prefix = Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string()); - let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org))); let policy_json = fs::read(&file) .with_context(|| format!("Failed to read OIDC policy file {file:?}"))?; @@ -1150,6 +1141,8 @@ pub fn handle_org( &ctx.env_config, Some(passphrase_provider.clone()), )?; + let org_alias = + resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix.as_str(), key)?; let result = set_org_oidc_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json) .context("Failed to anchor OIDC-subject policy")?; diff --git a/crates/auths-sdk/src/domains/org/service.rs b/crates/auths-sdk/src/domains/org/service.rs index 3dc92502..0b114059 100644 --- a/crates/auths-sdk/src/domains/org/service.rs +++ b/crates/auths-sdk/src/domains/org/service.rs @@ -324,6 +324,76 @@ pub fn create_org( }) } +/// The default keychain alias for an org's signing key, derived from its +/// identifier (`org-{slug}`). +/// +/// This is only the *fallback* convention used by [`resolve_org_signing_alias`] +/// when the keychain holds no Primary key for the org DID (e.g. a server-side +/// caller that pre-stored its key under this exact alias). The authoritative +/// resolution is the keychain lookup, not this slug — `org create` and the +/// org-signing commands once derived this slug from *different* inputs (the org +/// name vs. the org DID), so the two never matched. +pub fn org_slug_alias(org: &str) -> String { + format!( + "org-{}", + org.chars() + .filter(|c| c.is_alphanumeric()) + .take(20) + .collect::() + .to_lowercase() + ) +} + +/// Resolve the keychain alias of an organization's signing (Primary) key. +/// +/// The keychain is the single source of truth: an org's signing key is stored +/// under [`KeyRole::Primary`] keyed by the org's controller DID, regardless of +/// the human-readable alias chosen at `org create` time. Given the org DID we +/// ask the keychain which alias holds that Primary key, so every org-signing +/// command (`add-member`, `revoke-member`, `policy set`, `anchor-oidc-policy`, +/// compliance) resolves to the *same* alias `create` actually stored — no +/// fragile slug reconstruction that breaks when name and DID disagree. +/// +/// Args: +/// * `keychain`: The key storage port to query. +/// * `org_prefix`: The org's KERI prefix (the `did:keri:` identifier portion). +/// * `explicit`: An operator-supplied `--key` alias; when present it wins. +/// +/// Resolution order: +/// 1. `explicit` (the operator overrode the default). +/// 2. The single Primary alias bound to the org DID in the keychain. +/// 3. The `org-{slug}` fallback derived from the org prefix (only when the +/// keychain has no Primary key for the DID — a pre-seeded server key). +/// +/// Usage: +/// ```ignore +/// let alias = resolve_org_signing_alias(ctx.key_storage.as_ref(), org_prefix.as_str(), key)?; +/// ``` +pub fn resolve_org_signing_alias( + keychain: &(dyn auths_core::storage::keychain::KeyStorage + Send + Sync), + org_prefix: &str, + explicit: Option, +) -> Result { + if let Some(alias) = explicit { + return Ok(KeyAlias::new_unchecked(alias)); + } + + let org_did = auths_verifier::types::IdentityDID::from_prefix(org_prefix) + .map_err(|e| OrgError::InvalidDid(e.to_string()))?; + + let primaries = keychain + .list_aliases_for_identity_with_role( + &org_did, + auths_core::storage::keychain::KeyRole::Primary, + ) + .map_err(OrgError::CryptoError)?; + + match primaries.into_iter().next() { + Some(alias) => Ok(alias), + None => Ok(KeyAlias::new_unchecked(org_slug_alias(org_prefix))), + } +} + /// Look up a single org member by DID (O(1) with the right backend). pub fn get_organization_member( backend: &dyn RegistryBackend, From ac415e841ae7499f70c2c5716c66c3d30f0ef08b Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 07:23:27 +0100 Subject: [PATCH 24/32] feat(auths-mcp-server): configurable tool sandbox root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference MCP tool executors pinned their sandbox root to a /tmp const, so a relying party could not root tool execution at its own workspace. Make the root a parsed config value: McpServerConfig gains sandbox_root (default /tmp, builder with_sandbox_root, env AUTHS_MCP_SANDBOX_ROOT). The file-touching executors now take a tools::Sandbox — a once-canonicalized root parsed at the boundary so every resolve() can trust it (the traversal guard moves onto Sandbox::resolve). The route handler opens the Sandbox from config and passes it to the executors. Deletes the /tmp const. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-mcp-server/src/config.rs | 12 ++ crates/auths-mcp-server/src/main.rs | 5 + crates/auths-mcp-server/src/routes.rs | 8 +- crates/auths-mcp-server/src/tools.rs | 163 ++++++++++++++++---------- 4 files changed, 124 insertions(+), 64 deletions(-) diff --git a/crates/auths-mcp-server/src/config.rs b/crates/auths-mcp-server/src/config.rs index 776114df..1d12d580 100644 --- a/crates/auths-mcp-server/src/config.rs +++ b/crates/auths-mcp-server/src/config.rs @@ -39,6 +39,11 @@ pub struct McpServerConfig { /// Log level filter. pub log_level: String, + + /// Directory the file-touching tools (`read_file`, `write_file`) are + /// confined to. A relying party roots tool execution at its own workspace + /// instead of a pinned location. + pub sandbox_root: PathBuf, } impl Default for McpServerConfig { @@ -53,6 +58,7 @@ impl Default for McpServerConfig { jwks_cache_ttl_secs: 3600, enable_cors: false, log_level: "info".to_string(), + sandbox_root: PathBuf::from("/tmp"), } } } @@ -119,6 +125,12 @@ impl McpServerConfig { self.log_level = level.into(); self } + + /// Set the sandbox root the file-touching tools are confined to. + pub fn with_sandbox_root(mut self, root: impl Into) -> Self { + self.sandbox_root = root.into(); + self + } } /// Settings for the KERI presentation (no-issuer) authentication path. diff --git a/crates/auths-mcp-server/src/main.rs b/crates/auths-mcp-server/src/main.rs index b3c549e1..d68640f8 100644 --- a/crates/auths-mcp-server/src/main.rs +++ b/crates/auths-mcp-server/src/main.rs @@ -11,6 +11,7 @@ //! - `AUTHS_MCP_LEEWAY` - Clock skew tolerance in seconds (default: 5) //! - `AUTHS_MCP_CORS` - Enable CORS (set to "1" or "true") //! - `AUTHS_MCP_LOG_LEVEL` - Log level (default: info) +//! - `AUTHS_MCP_SANDBOX_ROOT` - Directory file-touching tools are confined to (default: /tmp) //! - `PORT` - Alternative port binding (cloud platform support) //! //! KERI presentation auth (`Authorization: Auths-Presentation`, no issuer in the @@ -81,6 +82,10 @@ fn main() -> anyhow::Result<()> { config = config.with_log_level(level); } + if let Ok(root) = env::var("AUTHS_MCP_SANDBOX_ROOT") { + config = config.with_sandbox_root(PathBuf::from(root)); + } + let keri_config = keri_presentation_config_from_env()?; init_tracing(&config.log_level, false); diff --git a/crates/auths-mcp-server/src/routes.rs b/crates/auths-mcp-server/src/routes.rs index 239bd436..239ff183 100644 --- a/crates/auths-mcp-server/src/routes.rs +++ b/crates/auths-mcp-server/src/routes.rs @@ -17,7 +17,7 @@ use crate::error::McpServerError; use crate::middleware::auth_middleware; use crate::state::McpServerState; use crate::tools::{ - DeployRequest, ReadFileRequest, ToolResponse, WriteFileRequest, execute_deploy, + DeployRequest, ReadFileRequest, Sandbox, ToolResponse, WriteFileRequest, execute_deploy, execute_read_file, execute_write_file, }; use crate::types::VerifiedAgent; @@ -120,12 +120,14 @@ async fn handle_tool_call( "read_file" => { let request: ReadFileRequest = serde_json::from_slice(&body) .map_err(|e| McpServerError::ToolError(format!("invalid request body: {e}")))?; - execute_read_file(request) + let sandbox = Sandbox::open(&state.config().sandbox_root)?; + execute_read_file(&sandbox, request) } "write_file" => { let request: WriteFileRequest = serde_json::from_slice(&body) .map_err(|e| McpServerError::ToolError(format!("invalid request body: {e}")))?; - execute_write_file(request) + let sandbox = Sandbox::open(&state.config().sandbox_root)?; + execute_write_file(&sandbox, request) } "deploy" => { let request: DeployRequest = serde_json::from_slice(&body) diff --git a/crates/auths-mcp-server/src/tools.rs b/crates/auths-mcp-server/src/tools.rs index c0229882..4b0ba946 100644 --- a/crates/auths-mcp-server/src/tools.rs +++ b/crates/auths-mcp-server/src/tools.rs @@ -2,6 +2,11 @@ //! //! These are demonstration tools that showcase Auths-backed authorization. //! Each tool requires a specific capability in the agent's JWT. +//! +//! File-touching tools execute inside a [`Sandbox`] rooted at a configurable +//! directory ([`McpServerConfig::sandbox_root`](crate::config::McpServerConfig)), +//! so a relying party roots tool execution at its own workspace rather than a +//! pinned location. use std::path::{Path, PathBuf}; @@ -9,8 +14,6 @@ use serde::{Deserialize, Serialize}; use crate::error::McpServerError; -const SANDBOX_ROOT: &str = "/tmp"; - /// Request body for the `read_file` tool. #[derive(Debug, Deserialize)] pub struct ReadFileRequest { @@ -37,12 +40,99 @@ pub struct ToolResponse { pub result: serde_json::Value, } -/// Execute the `read_file` tool. +/// A canonicalized directory that file-touching tools are confined to. +/// +/// Constructed once from the configured root; the canonical path is parsed at +/// the boundary so every [`resolve`](Sandbox::resolve) downstream can trust it +/// is an existing, absolute directory — no executor re-checks the root. +#[derive(Debug, Clone)] +pub struct Sandbox { + root: PathBuf, +} + +impl Sandbox { + /// Open the sandbox at `root`, resolving symlinks once. + /// + /// Args: + /// * `root`: The directory tool execution is confined to. It must exist. + pub fn open(root: impl AsRef) -> Result { + let root = root + .as_ref() + .canonicalize() + .map_err(|e| McpServerError::Internal(format!("sandbox root not accessible: {e}")))?; + Ok(Self { root }) + } + + /// The canonical sandbox root. + pub fn root(&self) -> &Path { + &self.root + } + + /// Resolve a user-supplied path to a location inside the sandbox. + /// + /// Directory traversal is rejected: the resolved path (or its parent, for a + /// not-yet-existing write target) must lie under the sandbox root. + /// + /// Args: + /// * `user_path`: The caller-supplied path, treated as relative to the root. + pub fn resolve(&self, user_path: &str) -> Result { + let requested = self + .root + .join(user_path.trim_start_matches('/').trim_start_matches("tmp/")); + + let canonical = if requested.exists() { + requested + .canonicalize() + .map_err(|e| McpServerError::ToolError(format!("path resolution failed: {e}")))? + } else { + // For writes, the file may not exist yet — canonicalize the parent. + let parent = requested.parent().ok_or_else(|| { + McpServerError::ToolError("invalid path: no parent directory".to_string()) + })?; + + if !parent.exists() { + return Err(McpServerError::ToolError(format!( + "parent directory does not exist: {}", + parent.display() + ))); + } + + let canonical_parent = parent.canonicalize().map_err(|e| { + McpServerError::ToolError(format!("parent path resolution failed: {e}")) + })?; + + if !canonical_parent.starts_with(&self.root) { + return Err(McpServerError::ToolError( + "path escapes sandbox".to_string(), + )); + } + + let filename = requested.file_name().ok_or_else(|| { + McpServerError::ToolError("invalid path: no filename".to_string()) + })?; + return Ok(canonical_parent.join(filename)); + }; + + if !canonical.starts_with(&self.root) { + return Err(McpServerError::ToolError( + "path escapes sandbox".to_string(), + )); + } + + Ok(canonical) + } +} + +/// Execute the `read_file` tool inside `sandbox`. /// /// Args: +/// * `sandbox`: The directory tool execution is confined to. /// * `request`: The file read request containing the path. -pub fn execute_read_file(request: ReadFileRequest) -> Result { - let safe_path = sandbox_path(&request.path)?; +pub fn execute_read_file( + sandbox: &Sandbox, + request: ReadFileRequest, +) -> Result { + let safe_path = sandbox.resolve(&request.path)?; let content = std::fs::read_to_string(&safe_path) .map_err(|e| McpServerError::ToolError(format!("read failed: {e}")))?; @@ -57,12 +147,16 @@ pub fn execute_read_file(request: ReadFileRequest) -> Result Result { - let safe_path = sandbox_path(&request.path)?; +pub fn execute_write_file( + sandbox: &Sandbox, + request: WriteFileRequest, +) -> Result { + let safe_path = sandbox.resolve(&request.path)?; std::fs::write(&safe_path, &request.content) .map_err(|e| McpServerError::ToolError(format!("write failed: {e}")))?; @@ -90,56 +184,3 @@ pub fn execute_deploy(request: DeployRequest) -> Result Result { - let sandbox = Path::new(SANDBOX_ROOT) - .canonicalize() - .map_err(|e| McpServerError::Internal(format!("sandbox root not accessible: {e}")))?; - - let requested = sandbox.join(user_path.trim_start_matches('/').trim_start_matches("tmp/")); - - let canonical = if requested.exists() { - requested - .canonicalize() - .map_err(|e| McpServerError::ToolError(format!("path resolution failed: {e}")))? - } else { - // For writes, the file may not exist yet — canonicalize the parent - let parent = requested.parent().ok_or_else(|| { - McpServerError::ToolError("invalid path: no parent directory".to_string()) - })?; - - if !parent.exists() { - return Err(McpServerError::ToolError(format!( - "parent directory does not exist: {}", - parent.display() - ))); - } - - let canonical_parent = parent.canonicalize().map_err(|e| { - McpServerError::ToolError(format!("parent path resolution failed: {e}")) - })?; - - if !canonical_parent.starts_with(&sandbox) { - return Err(McpServerError::ToolError( - "path escapes sandbox".to_string(), - )); - } - - if let Some(filename) = requested.file_name() { - return Ok(canonical_parent.join(filename)); - } - - return Err(McpServerError::ToolError( - "invalid path: no filename".to_string(), - )); - }; - - if !canonical.starts_with(&sandbox) { - return Err(McpServerError::ToolError( - "path escapes sandbox".to_string(), - )); - } - - Ok(canonical) -} From fa42f1f08d5bb4c942c543dd57b4cbffd3bb62f6 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 07:43:01 +0100 Subject: [PATCH 25/32] fix(cli): accept leading-hyphen base64url nonce values Challenge nonces are base64url and can begin with '-', but the clap args for the nonce flags lacked allow_hyphen_values, so the natural space form (--nonce -Pabc...) was parsed as an unknown short flag. Set allow_hyphen_values on every challenge-nonce flag: credential present --nonce, auth challenge --nonce, and auth verify --nonce. Co-Authored-By: Claude Fable 5 Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/auth.rs | 4 ++-- crates/auths-cli/src/commands/credential.rs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/auths-cli/src/commands/auth.rs b/crates/auths-cli/src/commands/auth.rs index 671e7c02..8e9444d9 100644 --- a/crates/auths-cli/src/commands/auth.rs +++ b/crates/auths-cli/src/commands/auth.rs @@ -46,7 +46,7 @@ pub enum AuthSubcommand { /// Sign an authentication challenge for DID-based login Challenge { /// The challenge nonce from the authentication server - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] nonce: String, /// The domain requesting authentication @@ -57,7 +57,7 @@ pub enum AuthSubcommand { /// Verify a challenge response offline against the registry's current key Verify { /// The challenge nonce this verifier issued - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] nonce: String, /// The domain the challenge was bound to diff --git a/crates/auths-cli/src/commands/credential.rs b/crates/auths-cli/src/commands/credential.rs index 15fa5f5c..8a46cef5 100644 --- a/crates/auths-cli/src/commands/credential.rs +++ b/crates/auths-cli/src/commands/credential.rs @@ -123,7 +123,11 @@ pub enum CredentialSubcommand { audience: String, /// The base64url challenge nonce issued by the relying party. - #[arg(long, help = "The base64url challenge nonce from /v1/auth/challenge.")] + #[arg( + long, + allow_hyphen_values = true, + help = "The base64url challenge nonce from /v1/auth/challenge." + )] nonce: String, }, } From 768c36dfe9c04fcba39f8d8443e30fa7b14a1159 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 15:43:50 +0100 Subject: [PATCH 26/32] fix(sign): confirm a signature landed before reporting a commit signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auths sign ` ran `git commit --amend` to embed the signer trailers and asked git to sign, but git only attaches a signature when a signing program is configured. With none configured the amend rewrote the commit unsigned, yet the CLI still printed "✔ Signed" — which `auths verify` then contradicted with "No signature found". After the amend, read each rewritten commit object and confirm a gpgsig SSH block actually landed (auths_verifier::commit_object_is_signed, the same detection the verdict path uses to call a commit unsigned). When none did, return an actionable error pointing at `auths doctor --fix` instead of claiming success, so the CLI's success line can never disagree with the verifier. Co-Authored-By: Claude Opus 4.8 (1M context) Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/sign.rs | 75 +++++++++++++++++++++++++++ crates/auths-verifier/src/commit.rs | 19 +++++++ crates/auths-verifier/src/lib.rs | 2 +- 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index f4d5c259..6acfd7d9 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -154,6 +154,77 @@ fn ensure_repo_root_pin(signer: &LocalSigner) { } } +/// Resolve a git ref/range into the list of commit SHAs the amend rewrote, so we +/// can confirm each one actually carries a signature. +/// +/// `HEAD`-style single refs resolve to that one commit; `base..tip` ranges resolve +/// to every commit the rebase re-signed. +fn resolve_signed_range_shas(range: &str) -> Result> { + let rev_arg = if range.contains("..") { + range.to_string() + } else { + format!("{range}^!") + }; + let output = crate::subprocess::git_command(&["rev-list", &rev_arg]) + .output() + .context("Failed to list commits to confirm signing")?; + if !output.status.success() { + return Err(anyhow!( + "Could not resolve '{}' to confirm the signature landed.\n\nGit reported: {}", + range, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +/// Confirm every commit in `range` actually carries an SSH signature after the amend. +/// +/// The amend embeds the trailers and *asks* git to sign, but git only signs when a +/// signing program is configured (`gpg.format ssh` + `gpg.ssh.program`). When it is +/// not, the rewrite lands unsigned — and `auths verify` would then call the commit +/// `No signature found`. We use the verifier's own `gpgsig` detection as the single +/// source of truth so success is never claimed for a commit the verifier rejects. +fn ensure_commits_signed(range: &str) -> Result<()> { + let shas = resolve_signed_range_shas(range)?; + for sha in &shas { + let raw = read_raw_commit_object(sha)?; + if !auths_verifier::commit_object_is_signed(&raw) { + return Err(anyhow!( + "Commit {} was amended but no signature was attached, so `auths verify` would \ + call it unsigned. Configure git SSH signing first — run `auths doctor --fix` \ + (sets gpg.format=ssh, gpg.ssh.program=auths-sign, commit.gpgsign=true).", + short_sha(sha) + )); + } + } + Ok(()) +} + +/// The raw git commit object (`git cat-file commit `) — the bytes a verifier +/// reads to decide whether a `gpgsig` SSH block is present. +fn read_raw_commit_object(sha: &str) -> Result { + let output = crate::subprocess::git_command(&["cat-file", "commit", sha]) + .output() + .context("Failed to read commit object to confirm signing")?; + if !output.status.success() { + return Err(anyhow!( + "git cat-file commit {} failed: {}", + short_sha(sha), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("Commit object is not valid UTF-8") +} + +/// First 8 chars of a SHA for human-readable messages. +fn short_sha(sha: &str) -> &str { + sha.get(..8).unwrap_or(sha) +} + /// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers /// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign /// via git's signing program; the trailer (idempotent via git's @@ -188,6 +259,10 @@ fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Res )); } } + // The amend succeeded, but git only attaches a signature when a signing program + // is configured. Confirm one actually landed before claiming success — otherwise + // `auths verify` would call this commit unsigned and the success line would lie. + ensure_commits_signed(range)?; if crate::ux::format::is_json_mode() { crate::ux::format::JsonResponse::success( "sign", diff --git a/crates/auths-verifier/src/commit.rs b/crates/auths-verifier/src/commit.rs index 92f85b13..f19f8d22 100644 --- a/crates/auths-verifier/src/commit.rs +++ b/crates/auths-verifier/src/commit.rs @@ -168,6 +168,16 @@ pub fn extract_ssh_signature( }) } +/// Whether a raw git commit object carries an SSH signature at all. +/// +/// This is the same `gpgsig` SSH-block detection [`extract_ssh_signature`] uses to +/// decide a commit is unsigned, exposed as a cheap predicate so callers that only +/// need "did a signature land?" (e.g. confirming an amend actually signed) share +/// the verifier's single source of truth instead of re-grepping the object. +pub fn commit_object_is_signed(commit_content: &str) -> bool { + extract_ssh_signature(commit_content).is_ok() +} + /// Construct the SSHSIG "signed data" blob. /// /// This is the data that the Ed25519 signature actually covers: @@ -258,6 +268,15 @@ mod tests { assert!(matches!(err, CommitVerificationError::UnsignedCommit)); } + #[test] + fn commit_object_is_signed_matches_extract() { + // The predicate agrees with extract_ssh_signature on every fixture: an SSH + // gpgsig block is "signed"; a plain or GPG-only commit is not. + assert!(commit_object_is_signed(SIGNED_COMMIT)); + assert!(!commit_object_is_signed(UNSIGNED_COMMIT)); + assert!(!commit_object_is_signed(GPG_COMMIT)); + } + #[test] fn extract_signature_present() { let result = extract_ssh_signature(SIGNED_COMMIT).unwrap(); diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index c3949d77..402c4fb7 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -143,7 +143,7 @@ pub use auths_keri::{ }; // Re-export commit verification types -pub use commit::VerifiedCommit; +pub use commit::{VerifiedCommit, commit_object_is_signed}; pub use commit_bundle::{BundleTrust, BundleTrustError, verify_commit_with_bundle_json}; pub use commit_kel::{ ANCHOR_SEQ_TRAILER, CommitVerdict, DEVICE_TRAILER, ID_TRAILER, SCOPE_TRAILER, From 3f747a7975ac1802cd6880abe87d360daf4b992a Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 16:02:36 +0100 Subject: [PATCH 27/32] fix(whoami): resolve a paired delegate via the single local-signer resolver Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/whoami.rs | 74 +++++++++++-------------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/crates/auths-cli/src/commands/whoami.rs b/crates/auths-cli/src/commands/whoami.rs index 6b46a0b6..d378d6b0 100644 --- a/crates/auths-cli/src/commands/whoami.rs +++ b/crates/auths-cli/src/commands/whoami.rs @@ -1,6 +1,5 @@ use anyhow::{Result, anyhow}; -use auths_sdk::ports::IdentityStorage; -use auths_sdk::storage::RegistryIdentityStorage; +use auths_sdk::domains::identity::local::{LocalSigner, resolve_local_signer}; use auths_sdk::storage_layout as layout; use clap::Parser; use serde::Serialize; @@ -48,19 +47,26 @@ fn current_key_hex(repo_path: &std::path::Path, did: &str) -> Option<(String, St Some((hex::encode(pk), format!("{curve:?}").to_lowercase())) } -/// Resolve this machine's device DID. Best-effort. -fn local_device_did(repo_path: &std::path::Path) -> Option { +/// Resolve this machine's signing identity, uniformly across root and delegate. +/// Best-effort: `None` lets the caller emit the un-initialised message. +fn local_signer(repo_path: &std::path::Path) -> Option { let env_config = auths_sdk::core_config::EnvironmentConfig::from_env(); let ctx = crate::factories::storage::build_auths_context(repo_path, &env_config, None).ok()?; - auths_sdk::domains::identity::local::resolve_local_signer(&ctx) - .ok() - .map(|signer| signer.signer_did.to_string()) + resolve_local_signer(&ctx).ok() } pub fn handle_whoami(_cmd: WhoamiCommand, repo: Option) -> Result<()> { let repo_path = layout::resolve_repo_path(repo).map_err(|e| anyhow!(e))?; - if crate::factories::storage::open_git_repo(&repo_path).is_err() { + // One resolver for both machine shapes: `resolve_local_signer` returns the + // root identity (`root_did`) and this machine's signer (`signer_did`), which + // differ only on a paired delegate. A missing repo or no local signer is the + // same "not initialised" outcome. + let signer = crate::factories::storage::open_git_repo(&repo_path) + .ok() + .and_then(|_| local_signer(&repo_path)); + + let Some(signer) = signer else { if is_json_mode() { JsonResponse::<()>::error( "whoami", @@ -72,42 +78,26 @@ pub fn handle_whoami(_cmd: WhoamiCommand, repo: Option) -> R out.print_error("No identity found. Run `auths init` to get started."); } return Ok(()); - } + }; - let storage = RegistryIdentityStorage::new(&repo_path); - match storage.load_identity() { - Ok(identity) => { - let identity_did = identity.controller_did.to_string(); - let key = current_key_hex(&repo_path, &identity_did); - let response = WhoamiResponse { - identity_did, - label: None, - device_did: local_device_did(&repo_path), - public_key_hex: key.as_ref().map(|(hex, _)| hex.clone()), - curve: key.as_ref().map(|(_, curve)| curve.clone()), - }; + // The signer's own KEL (the device dip on a delegate, the controller icp on a + // root) is always local, so its current key resolves on both shapes. + let key = current_key_hex(&repo_path, &signer.signer_did); + let response = WhoamiResponse { + identity_did: signer.root_did.clone(), + label: None, + device_did: Some(signer.signer_did.clone()), + public_key_hex: key.as_ref().map(|(hex, _)| hex.clone()), + curve: key.as_ref().map(|(_, curve)| curve.clone()), + }; - if is_json_mode() { - JsonResponse::success("whoami", &response).print()?; - } else { - let out = Output::new(); - out.println(&format!("Identity: {}", out.info(&response.identity_did))); - if let Some(ref device) = response.device_did { - out.println(&format!("Device: {}", out.dim(device))); - } - } - } - Err(_) => { - if is_json_mode() { - JsonResponse::<()>::error( - "whoami", - "No identity found. Run `auths init` to get started.", - ) - .print()?; - } else { - let out = Output::new(); - out.print_error("No identity found. Run `auths init` to get started."); - } + if is_json_mode() { + JsonResponse::success("whoami", &response).print()?; + } else { + let out = Output::new(); + out.println(&format!("Identity: {}", out.info(&response.identity_did))); + if let Some(ref device) = response.device_did { + out.println(&format!("Device: {}", out.dim(device))); } } From eee627c03f49d501e0e500559bc454a5cc7d06de Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 16:25:23 +0100 Subject: [PATCH 28/32] fix(cli): re-signing a commit replaces its Auths-* trailers instead of appending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git commit --amend --trailer defaults to addIfDifferentNeighbor, which appended a fresh Auths-Id/Auths-Device (and Auths-Anchor-Seq/Auths-Scope) pair on every re-sign rather than replacing — a twice-signed commit accreted duplicate trailers. Amend now runs with -c trailer.ifexists=replace on both the single-commit and the git rebase --exec range paths, so an existing Auths-* trailer is replaced in place and a re-signed commit keeps exactly one trailer per token (the latest value). Co-Authored-By: Claude Opus 4.8 (1M context) Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/auths-cli/src/commands/sign.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 6acfd7d9..67f87bf2 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -113,7 +113,12 @@ fn execute_git_rebase(base: &str, trailers: &[String]) -> Result<()> { .iter() .map(|t| format!(" --trailer '{}'", t)) .collect(); - let exec_cmd = format!("git commit --amend --no-edit --no-verify{trailer_flags}"); + // `-c trailer.ifexists=replace`: re-signing a commit that already carries an + // Auths-* trailer replaces it in place rather than appending a second copy, so + // a re-signed commit (the recovery rewrite) keeps exactly one trailer per token. + let exec_cmd = format!( + "git -c trailer.ifexists=replace commit --amend --no-edit --no-verify{trailer_flags}" + ); let output = crate::subprocess::git_command(&["rebase", "--exec", &exec_cmd, base]) .output() .context("Failed to spawn git rebase")?; @@ -227,8 +232,9 @@ fn short_sha(sha: &str) -> &str { /// Sign a Git commit range, embedding the `Auths-Id` / `Auths-Device` trailers /// in-band so a verifier knows which KEL to replay. Amending triggers auths-sign -/// via git's signing program; the trailer (idempotent via git's -/// `addIfDifferentNeighbor`) is part of the signed message body. +/// via git's signing program; the trailers (one per token — re-signing replaces +/// rather than appends, via `trailer.ifexists=replace`) are part of the signed +/// message body. /// /// Args: /// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD"). @@ -243,7 +249,17 @@ fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Res let base = parts[0]; execute_git_rebase(base, &trailers)?; } else { - let mut args: Vec<&str> = vec!["commit", "--amend", "--no-edit", "--no-verify"]; + // `-c trailer.ifexists=replace`: amending a commit that already carries an + // Auths-* trailer (a re-sign) replaces that trailer in place instead of + // appending a duplicate, so the message keeps exactly one trailer per token. + let mut args: Vec<&str> = vec![ + "-c", + "trailer.ifexists=replace", + "commit", + "--amend", + "--no-edit", + "--no-verify", + ]; for trailer in &trailers { args.push("--trailer"); args.push(trailer); From 0ca88ec7b0991c9e4009edc69235ee1d342de98d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 16:53:32 +0100 Subject: [PATCH 29/32] fix(xtask): exclude hidden dirs (incl .worktrees) from AST check file-walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six checks (curve-agnostic, anchor-discipline, verify-path-completeness, rfc6979, constant-time ×2) walked the repo root and recursed into nested git worktrees under .worktrees/, false-positiving on that checkout's legitimately curve-specific crypto-provider code (123 phantom violations). The directory filter now skips all hidden dirs — matching the existing "skip hidden dirs" comment that the code never actually honored — while keeping target/ and node_modules. Scans drop from 1740 → ~730 files (current tree only); all six report 0 violations. Co-Authored-By: Claude Opus 4.8 (1M context) Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- crates/xtask/src/check_anchor_discipline.rs | 2 +- crates/xtask/src/check_constant_time.rs | 4 ++-- crates/xtask/src/check_curve_agnostic.rs | 4 ++-- crates/xtask/src/check_rfc6979.rs | 2 +- crates/xtask/src/check_verify_path_completeness.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/xtask/src/check_anchor_discipline.rs b/crates/xtask/src/check_anchor_discipline.rs index 37eedb02..b07c6544 100644 --- a/crates/xtask/src/check_anchor_discipline.rs +++ b/crates/xtask/src/check_anchor_discipline.rs @@ -57,7 +57,7 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) diff --git a/crates/xtask/src/check_constant_time.rs b/crates/xtask/src/check_constant_time.rs index 7b3866c1..cc9766c5 100644 --- a/crates/xtask/src/check_constant_time.rs +++ b/crates/xtask/src/check_constant_time.rs @@ -63,7 +63,7 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) @@ -149,7 +149,7 @@ fn collect_secret_types(workspace_root: &Path) -> anyhow::Result .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) diff --git a/crates/xtask/src/check_curve_agnostic.rs b/crates/xtask/src/check_curve_agnostic.rs index 7d758a23..a4427dce 100644 --- a/crates/xtask/src/check_curve_agnostic.rs +++ b/crates/xtask/src/check_curve_agnostic.rs @@ -90,9 +90,9 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { .into_iter() .filter_entry(|e| { let name = e.file_name().to_string_lossy(); - // Skip non-Rust, hidden dirs, target/ + // Skip non-Rust, hidden dirs (.git, .worktrees, …), build output, vendored deps if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) diff --git a/crates/xtask/src/check_rfc6979.rs b/crates/xtask/src/check_rfc6979.rs index 7acdec85..0fd1afe3 100644 --- a/crates/xtask/src/check_rfc6979.rs +++ b/crates/xtask/src/check_rfc6979.rs @@ -76,7 +76,7 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) diff --git a/crates/xtask/src/check_verify_path_completeness.rs b/crates/xtask/src/check_verify_path_completeness.rs index fb0aacef..16f52eb1 100644 --- a/crates/xtask/src/check_verify_path_completeness.rs +++ b/crates/xtask/src/check_verify_path_completeness.rs @@ -69,7 +69,7 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { .filter_entry(|e| { let name = e.file_name().to_string_lossy(); if e.file_type().is_dir() { - return name != "target" && name != ".git" && name != "node_modules"; + return !name.starts_with('.') && name != "target" && name != "node_modules"; } name.ends_with(".rs") }) From 79d1890d47cbe7eaedfb4cdd8c6c548cd6943bb3 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 17:03:59 +0100 Subject: [PATCH 30/32] fix(ci): document shared-kel-rot endpoint + allow auths-rp git2 wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/api-spec.yaml: add POST /v1/pairing/sessions/{id}/shared-kel-rot (device-authored shared-KEL rotation submission) + SubmitSharedKelRotRequest schema — resolves the spec-drift gate (ADR 004) for the daemon API change. - deny.toml: add auths-rp to the git2 wrapper allowlist — its git2 use is the optional, feature-gated (git-sync) registry-fetch adapter, confined to the adapter layer exactly as the ban's reason permits. Co-Authored-By: Claude Opus 4.8 (1M context) Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- deny.toml | 3 ++- docs/api-spec.yaml | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 899043a4..acb349ab 100644 --- a/deny.toml +++ b/deny.toml @@ -64,7 +64,8 @@ deny = [ "auths-sdk", "auths-test-utils", "auths-api", - ], reason = "git2 must stay in storage/adapter layer; auths-sdk and auths-api git2 are dev-deps only" }, + "auths-rp", + ], reason = "git2 must stay in storage/adapter layer; auths-sdk and auths-api git2 are dev-deps only; auths-rp's git2 is the optional git-sync registry-fetch adapter (feature-gated off by default)" }, ] [advisories] diff --git a/docs/api-spec.yaml b/docs/api-spec.yaml index eba2ce1e..779a8384 100644 --- a/docs/api-spec.yaml +++ b/docs/api-spec.yaml @@ -181,6 +181,46 @@ paths: "503": $ref: "#/components/responses/CapacityExhausted" + /v1/pairing/sessions/{id}/shared-kel-rot: + post: + operationId: submitSharedKelRot + tags: [sessions] + summary: Submit a device-authored shared-KEL rotation. + description: | + Session-scoped signed. On a `rotate` session, the paired device + submits a signed shared-KEL rotation envelope for the host to + apply via `id rotate --from-device`. The envelope is verified + before the session accepts it. + security: + - AuthsSig: [] + parameters: + - $ref: "#/components/parameters/SessionId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitSharedKelRotRequest" + responses: + "200": + description: Rotation envelope accepted. + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/UnauthorizedSig" + "409": + $ref: "#/components/responses/Conflict" + "410": + $ref: "#/components/responses/SessionExpired" + "413": + $ref: "#/components/responses/PayloadTooLarge" + "429": + $ref: "#/components/responses/RateLimited" + /v1/pairing/sessions/{id}/confirm: post: operationId: submitPairingConfirmation @@ -603,6 +643,17 @@ components: serialize to keep future multi-sig inceptions from silently overflowing QR capacity. + SubmitSharedKelRotRequest: + type: object + required: [rot_envelope] + properties: + rot_envelope: + type: string + description: | + Base64url-no-pad wire envelope of the device-signed shared-KEL + rotation (`auths_keri::encode_signed_rot`). The host verifies the + envelope and applies it via `id rotate --from-device`. + SubmitConfirmationRequest: type: object properties: From 578f5a9d9b4b4e4ea16e599286aaeb3ddd8aaa17 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 17:07:10 +0100 Subject: [PATCH 31/32] chore: add CI identity bundle for signer verification Auths-Id: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG Auths-Device: did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG --- .auths/ci-bundle.json | 69 +++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/.auths/ci-bundle.json b/.auths/ci-bundle.json index 63b9bee0..09169e3a 100644 --- a/.auths/ci-bundle.json +++ b/.auths/ci-bundle.json @@ -39,55 +39,32 @@ "size": 5 }, "commit_sha": "d4443cd81077f396ec1a1366bc0356cc19b3bbf5" - } - ], - "kel": [ - { - "v": "KERI10JSON00012f_", - "t": "icp", - "d": "ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", - "i": "ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", - "s": "0", - "kt": "1", - "k": [ - "1AAJAvfTM0XIQXakgoZTmn50ze8cJm6zjjiXEe2xKfzHk43g" - ], - "nt": "1", - "n": [ - "EGdbaV-wPaf-_ipeasSHuXwmb0YtEt7FaMk68HLxD4mn" - ], - "bt": "0", - "b": [], - "c": [], - "a": [] - }, - { - "v": "KERI10JSON0000ff_", - "t": "ixn", - "d": "EDxwuXyK6UzdhpDUJOggQGHTdWpc0APpPHihmiUcye6y", - "i": "ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", - "s": "1", - "p": "ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", - "a": [ - { - "d": "EBZ9C975VziWzzdir1SctdzUDmqZMCKgG7J6zU9m8aES" - } - ] }, { - "v": "KERI10JSON0000ff_", - "t": "ixn", - "d": "EIrQ9Hly1PL1MkNynKgrQ_X9Mjspm5lxupzyOMasbZQu", - "i": "ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", - "s": "2", - "p": "EDxwuXyK6UzdhpDUJOggQGHTdWpc0APpPHihmiUcye6y", - "a": [ - { - "d": "EBSIjdFY0dh2tv8BsfpPIBQ-QkA7Ay8d7G3vG18vrkwS" - } - ] + "version": 1, + "rid": "sha256:5cb868032393d2735071912dac899f29191d2d294281f914812241f4565ad104", + "issuer": "did:keri:ELv6uW2irGkclnFq8lAsAexmoLwZ-3k-ocwjpFBZsIEG", + "subject": "did:key:zDnaeh7NXw4vXWstkivGmQaBGZkoG4Fwp6uRAh6Khe5hXmUSj", + "device_public_key": { + "curve": "p256", + "key": "02f7d33345c84176a48286539a7e74cdef1c266eb38e389711edb129fcc7938de0" + }, + "identity_signature": "725d554a771f832f75d2be0928e07b00d01bb43d220786daaedee41f885c895d007a5cb8cab9af990513686fabd4aea55d980244aaecc2d63423bef9b1b6fb6a", + "device_signature": "d823437bc8bb5ad3b0e09942b028225fb919ab68a8e108120c2e6baf1230db466f35cc5a43a952fb7763676a3a62a0250a536dfe20a32be474b9ff1a08fb7375", + "timestamp": "2026-06-10T01:10:14.307656Z", + "note": "example-verify-badge v0.1.0", + "payload": { + "artifact_type": "file", + "digest": { + "algorithm": "sha256", + "hex": "5cb868032393d2735071912dac899f29191d2d294281f914812241f4565ad104" + }, + "name": "example-verify-badge-v0.1.0.tar.gz", + "size": 5035 + }, + "commit_sha": "92a3c50319c37d85b7219a9efbc33679b522a4fb" } ], - "bundle_timestamp": "2026-06-10T00:32:04.746971Z", + "bundle_timestamp": "2026-06-13T16:06:44.921576Z", "max_valid_for_secs": 31536000 } From 0b7e80e38aeda3a397bd6845a5917b57179db3ff Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 13 Jun 2026 17:23:13 +0100 Subject: [PATCH 32/32] fix: update error docs --- crates/auths-cli/src/errors/registry.rs | 8 +++++++- docs/errors/AUTHS-E5317.md | 9 +++++++++ docs/errors/AUTHS-E5318.md | 9 +++++++++ docs/errors/AUTHS-E5628.md | 9 +++++++++ docs/errors/index.md | 3 +++ docs/errors/registry.lock | 3 +++ 6 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 docs/errors/AUTHS-E5317.md create mode 100644 docs/errors/AUTHS-E5318.md create mode 100644 docs/errors/AUTHS-E5628.md diff --git a/crates/auths-cli/src/errors/registry.rs b/crates/auths-cli/src/errors/registry.rs index a75cc4ec..49ddd02f 100644 --- a/crates/auths-cli/src/errors/registry.rs +++ b/crates/auths-cli/src/errors/registry.rs @@ -385,6 +385,8 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E5314" => Some("# AUTHS-E5314\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AgentNotFound`\n\n## Message\n\nagent not found: {did}\n"), "AUTHS-E5315" => Some("# AUTHS-E5315\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::Revoked`\n\n## Message\n\nagent {did} is revoked\n"), "AUTHS-E5316" => Some("# AUTHS-E5316\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::OutsideDelegatorScope`\n\n## Message\n\nrequested capability '{capability}' exceeds the delegator's scope\n"), + "AUTHS-E5317" => Some("# AUTHS-E5317\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AttestationError`\n\n## Message\n\nagent delegation attestation failed: {0}\n"), + "AUTHS-E5318" => Some("# AUTHS-E5318\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AnchorError`\n\n## Message\n\nagent delegation attestation anchoring failed: {0}\n"), // --- auths-sdk (RegistrationError) --- "AUTHS-E5401" => Some("# AUTHS-E5401\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::AlreadyRegistered`\n\n## Message\n\nidentity already registered at this registry\n"), @@ -431,6 +433,7 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E5625" => Some("# AUTHS-E5625\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::ChainCycle`\n\n## Message\n\ndelegation chain cycle detected at '{did}'\n"), "AUTHS-E5626" => Some("# AUTHS-E5626\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::ChainTooDeep`\n\n## Message\n\ndelegation chain exceeds the maximum depth of {max} hops\n"), "AUTHS-E5627" => Some("# AUTHS-E5627\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::ChainBrokenHop`\n\n## Message\n\ndelegation chain is broken: no KEL found for '{did}'\n"), + "AUTHS-E5628" => Some("# AUTHS-E5628\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::OidcPolicyInvalid`\n\n## Message\n\ninvalid OIDC-subject policy: {reason}\n"), // --- auths-sdk (ApprovalError) --- "AUTHS-E5701" => Some("# AUTHS-E5701\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::NotApprovalRequired`\n\n## Message\n\ndecision is not RequiresApproval\n"), @@ -779,6 +782,8 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E5314", "AUTHS-E5315", "AUTHS-E5316", + "AUTHS-E5317", + "AUTHS-E5318", "AUTHS-E5401", "AUTHS-E5402", "AUTHS-E5403", @@ -817,6 +822,7 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E5625", "AUTHS-E5626", "AUTHS-E5627", + "AUTHS-E5628", "AUTHS-E5701", "AUTHS-E5702", "AUTHS-E5703", @@ -898,6 +904,6 @@ mod tests { #[test] fn all_codes_count_matches_registry() { - assert_eq!(all_codes().len(), 363); + assert_eq!(all_codes().len(), 366); } } diff --git a/docs/errors/AUTHS-E5317.md b/docs/errors/AUTHS-E5317.md new file mode 100644 index 00000000..2e9c4976 --- /dev/null +++ b/docs/errors/AUTHS-E5317.md @@ -0,0 +1,9 @@ +# AUTHS-E5317 + +**Crate:** `auths-sdk` + +**Type:** `AgentError::AttestationError` + +## Message + +agent delegation attestation failed: {0} diff --git a/docs/errors/AUTHS-E5318.md b/docs/errors/AUTHS-E5318.md new file mode 100644 index 00000000..142bfc1a --- /dev/null +++ b/docs/errors/AUTHS-E5318.md @@ -0,0 +1,9 @@ +# AUTHS-E5318 + +**Crate:** `auths-sdk` + +**Type:** `AgentError::AnchorError` + +## Message + +agent delegation attestation anchoring failed: {0} diff --git a/docs/errors/AUTHS-E5628.md b/docs/errors/AUTHS-E5628.md new file mode 100644 index 00000000..cefda501 --- /dev/null +++ b/docs/errors/AUTHS-E5628.md @@ -0,0 +1,9 @@ +# AUTHS-E5628 + +**Crate:** `auths-sdk` + +**Type:** `OrgError::OidcPolicyInvalid` + +## Message + +invalid OIDC-subject policy: {reason} diff --git a/docs/errors/index.md b/docs/errors/index.md index 5e62d706..6744b800 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -283,6 +283,8 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E5314](AUTHS-E5314.md) | `auths-sdk` | `AgentError::AgentNotFound` | agent not found: {did} | | [AUTHS-E5315](AUTHS-E5315.md) | `auths-sdk` | `AgentError::Revoked` | agent {did} is revoked | | [AUTHS-E5316](AUTHS-E5316.md) | `auths-sdk` | `AgentError::OutsideDelegatorScope` | requested capability '{capability}' exceeds the delegator's scope | +| [AUTHS-E5317](AUTHS-E5317.md) | `auths-sdk` | `AgentError::AttestationError` | agent delegation attestation failed: {0} | +| [AUTHS-E5318](AUTHS-E5318.md) | `auths-sdk` | `AgentError::AnchorError` | agent delegation attestation anchoring failed: {0} | | [AUTHS-E5401](AUTHS-E5401.md) | `auths-sdk` | `RegistrationError::AlreadyRegistered` | identity already registered at this registry | | [AUTHS-E5402](AUTHS-E5402.md) | `auths-sdk` | `RegistrationError::QuotaExceeded` | registration quota exceeded — try again later | | [AUTHS-E5403](AUTHS-E5403.md) | `auths-sdk` | `RegistrationError::InvalidDidFormat` | invalid DID format: {did} | @@ -321,6 +323,7 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E5625](AUTHS-E5625.md) | `auths-sdk` | `OrgError::ChainCycle` | delegation chain cycle detected at '{did}' | | [AUTHS-E5626](AUTHS-E5626.md) | `auths-sdk` | `OrgError::ChainTooDeep` | delegation chain exceeds the maximum depth of {max} hops | | [AUTHS-E5627](AUTHS-E5627.md) | `auths-sdk` | `OrgError::ChainBrokenHop` | delegation chain is broken: no KEL found for '{did}' | +| [AUTHS-E5628](AUTHS-E5628.md) | `auths-sdk` | `OrgError::OidcPolicyInvalid` | invalid OIDC-subject policy: {reason} | | [AUTHS-E5701](AUTHS-E5701.md) | `auths-sdk` | `ApprovalError::NotApprovalRequired` | decision is not RequiresApproval | | [AUTHS-E5702](AUTHS-E5702.md) | `auths-sdk` | `ApprovalError::RequestNotFound` | approval request not found: {hash} | | [AUTHS-E5703](AUTHS-E5703.md) | `auths-sdk` | `ApprovalError::RequestExpired` | approval request expired at {expires_at} | diff --git a/docs/errors/registry.lock b/docs/errors/registry.lock index f138fbe7..7863820a 100644 --- a/docs/errors/registry.lock +++ b/docs/errors/registry.lock @@ -287,6 +287,8 @@ AUTHS-E5313 = auths-sdk::AgentError::DelegationError AUTHS-E5314 = auths-sdk::AgentError::AgentNotFound AUTHS-E5315 = auths-sdk::AgentError::Revoked AUTHS-E5316 = auths-sdk::AgentError::OutsideDelegatorScope +AUTHS-E5317 = auths-sdk::AgentError::AttestationError +AUTHS-E5318 = auths-sdk::AgentError::AnchorError AUTHS-E5401 = auths-sdk::RegistrationError::AlreadyRegistered AUTHS-E5402 = auths-sdk::RegistrationError::QuotaExceeded AUTHS-E5403 = auths-sdk::RegistrationError::InvalidDidFormat @@ -325,6 +327,7 @@ AUTHS-E5624 = auths-sdk::OrgError::PolicyIntegrity AUTHS-E5625 = auths-sdk::OrgError::ChainCycle AUTHS-E5626 = auths-sdk::OrgError::ChainTooDeep AUTHS-E5627 = auths-sdk::OrgError::ChainBrokenHop +AUTHS-E5628 = auths-sdk::OrgError::OidcPolicyInvalid AUTHS-E5701 = auths-sdk::ApprovalError::NotApprovalRequired AUTHS-E5702 = auths-sdk::ApprovalError::RequestNotFound AUTHS-E5703 = auths-sdk::ApprovalError::RequestExpired