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 } diff --git a/Cargo.lock b/Cargo.lock index ab5f8ead..00c59edd 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", @@ -827,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-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/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/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 1bca15c1..b3d732bc 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; @@ -87,10 +89,44 @@ 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, + /// 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, @@ -176,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, @@ -195,6 +239,31 @@ 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, + + /// 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, + + /// 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, }, } @@ -357,7 +426,12 @@ pub fn handle_artifact( commit, no_commit, ci, + curve, ci_platform, + oidc_token, + oidc_audience, + oidc_issuer, + oidc_jwks, log, allow_unlogged, } => { @@ -377,14 +451,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()), @@ -409,14 +487,45 @@ 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, + curve: curve.unwrap_or_default(), + expires_in, + note, + ci_env: Some(ci_env_json), + oidc_binding, + }, ) .map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?; @@ -540,11 +649,16 @@ pub fn handle_artifact( witness_keys, witness_threshold, verify_commit, + signature_only, offline, roots, member, signed_at, json, + oidc_policy, + oidc_policy_did, + log_evidence, + log_key, } => { if offline { return verify::handle_offline_verify( @@ -555,15 +669,27 @@ 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, - signature, - identity_bundle, - witness_receipts, - &witness_keys, - witness_threshold, - verify_commit, + verify::VerifyArtifactArgs { + signature, + identity_bundle, + witness_receipts, + witness_keys, + witness_threshold, + verify_commit, + ephemeral_anchor, + oidc_policy, + oidc_policy_did, + log_evidence, + log_key, + }, )) } } @@ -677,6 +803,80 @@ 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 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/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..93055584 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -5,6 +5,10 @@ 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::{ CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses, @@ -35,23 +39,112 @@ 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, } +/// 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. +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, + /// 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 + /// 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. /// /// 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, + ephemeral_anchor, + oidc_policy, + oidc_policy_did, + log_evidence, + log_key, + } = args; + let witness_keys = &witness_keys; let file_str = file.to_string_lossy().to_string(); + // 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) => { + 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}")); + } + } + } + (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 let sig_path = signature.unwrap_or_else(|| { let mut p = file.to_path_buf(); @@ -129,6 +222,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 @@ -154,7 +248,7 @@ pub async fn handle_verify( 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); @@ -183,6 +277,75 @@ pub async fn handle_verify( } }; + // 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, @@ -208,43 +371,113 @@ pub async fn handle_verify( valid = false; } - // 8a. Ephemeral attestation: verify commit signature transitively + if let Some(ref msg) = log_anchor_error { + valid = false; + if !is_json_mode() { + eprintln!(" {msg}"); + } + } + + // 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 { + 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())] + ); + } + } + } + }, + } + } + + // 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!( - " Commit {} is not signed by a trusted maintainer.", - &sha[..8.min(sha.len())] + " 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) @@ -313,11 +546,62 @@ pub async fn handle_verify( issuer: Some(identity_did.to_string()), commit_sha: commit_sha_val, commit_verified, - error: None, + oidc_join, + error: log_anchor_error.or(oidc_error), }, ) } +/// 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, @@ -421,6 +705,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/auth.rs b/crates/auths-cli/src/commands/auth.rs index b79fea81..8e9444d9 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 @@ -40,13 +46,34 @@ 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 #[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, allow_hyphen_values = true)] + 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-cli/src/commands/compliance.rs b/crates/auths-cli/src/commands/compliance.rs index 871d4431..7f035102 100644 --- a/crates/auths-cli/src/commands/compliance.rs +++ b/crates/auths-cli/src/commands/compliance.rs @@ -1,52 +1,53 @@ //! `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_verifier::{IdentityDID, Prefix}; +use auths_sdk::workflows::org::resolve_org_signing_alias; +use auths_sdk::workflows::roots::parse_roots_typed; +use auths_verifier::{Ed25519PublicKey, 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. -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, @@ -56,6 +57,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)] @@ -94,6 +103,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 { @@ -102,6 +115,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, } } } @@ -111,11 +126,24 @@ 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) + + 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. + # --log-key pins the log operator: every row's checkpoint + # signature must verify, not just its Merkle membership -Releases file (JSON array): +Releases file (JSON array, caller-asserted alternative to --discover): [{\"artifact_digest\":\"sha256:…\",\"signer\":\"did:keri:EMember\",\"signed_at\":41}]" )] pub struct ComplianceCommand { @@ -126,7 +154,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)] @@ -137,7 +191,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, @@ -150,8 +205,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 +234,40 @@ 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, + + /// 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. @@ -192,6 +287,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 +362,7 @@ pub fn handle_compliance( predicate, verifier_id, releases, + discover, offline, sign, key, @@ -213,13 +377,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 +389,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 +478,181 @@ pub fn handle_compliance( } Ok(()) } + + 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, + 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, + 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, pinned_log_key.is_some()); + } + + // 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, + log_key_pinned: 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, + "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, + log_key_pinned: 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 + )) + ); + 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, 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}", + 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/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, }, } diff --git a/crates/auths-cli/src/commands/demo.rs b/crates/auths-cli/src/commands/demo.rs index 888c95a6..26f6ad51 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,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + 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/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..da31794c 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!(); @@ -256,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 2e791a95..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; @@ -56,6 +59,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 +137,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 +172,7 @@ pub fn handle_pair( now, registry, cmd.no_qr, + cmd.print_uri, cmd.timeout, &cmd.capabilities, env_config, @@ -174,6 +187,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 +206,7 @@ pub fn handle_pair( now, DEFAULT_REGISTRY, cmd.no_qr, + cmd.print_uri, cmd.timeout, &cmd.capabilities, env_config, @@ -205,6 +220,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!(); 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-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-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/org.rs b/crates/auths-cli/src/commands/org.rs index 3b6ce453..040501b3 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, 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; @@ -52,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( @@ -266,6 +254,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 @@ -540,6 +545,7 @@ pub fn handle_org( delegated_by: None, commit_sha: None, signer_type: None, + oidc_binding: None, }, &signer, passphrase_provider.as_ref(), @@ -744,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()); @@ -754,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, @@ -805,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")?; @@ -1073,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:?}"))?; @@ -1083,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")?; @@ -1119,6 +1130,37 @@ 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 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 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")?; + + 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/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/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index f4d5c259..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")?; @@ -154,10 +159,82 @@ 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 -/// `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"). @@ -172,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); @@ -188,6 +275,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-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index 2c98e3fa..a6802c21 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -191,12 +191,19 @@ 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, + ephemeral_anchor: crate::commands::artifact::verify::EphemeralAnchor::Required, + oidc_policy: None, + oidc_policy_did: None, + log_evidence: None, + log_key: None, + }, ) .await } 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-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))); } } diff --git a/crates/auths-cli/src/errors/registry.rs b/crates/auths-cli/src/errors/registry.rs index 4dca5092..49ddd02f 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"), @@ -372,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"), @@ -412,15 +427,13 @@ 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"), "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"), @@ -537,6 +550,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", @@ -760,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", @@ -792,15 +816,13 @@ 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", "AUTHS-E5625", "AUTHS-E5626", "AUTHS-E5627", + "AUTHS-E5628", "AUTHS-E5701", "AUTHS-E5702", "AUTHS-E5703", @@ -882,6 +904,6 @@ mod tests { #[test] fn all_codes_count_matches_registry() { - assert_eq!(all_codes().len(), 357); + assert_eq!(all_codes().len(), 366); } } 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-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-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/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-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-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/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-keri/src/events.rs b/crates/auths-keri/src/events.rs index 7768abe8..48b2c0fa 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; @@ -1467,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] @@ -1477,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 8fc1756a..52c8d69a 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -84,9 +84,10 @@ 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, - parse_delegated_attachment, parse_source_seal_couples, serialize_attachment, - serialize_source_seal_couples, + SignedEvent, SourceSeal, WireSignedDip, WireSignedRot, decode_agent_scope, decode_signed_dip, + 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-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..1d12d580 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)] @@ -32,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 { @@ -46,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"), } } } @@ -112,4 +125,65 @@ 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. +/// +/// 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..d68640f8 100644 --- a/crates/auths-mcp-server/src/main.rs +++ b/crates/auths-mcp-server/src/main.rs @@ -11,11 +11,35 @@ //! - `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 +//! 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 +82,12 @@ 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); let telemetry = @@ -68,7 +98,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 +132,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..239ff183 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,16 +14,22 @@ 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, + DeployRequest, ReadFileRequest, Sandbox, ToolResponse, WriteFileRequest, execute_deploy, execute_read_file, execute_write_file, }; 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( @@ -101,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/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/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) -} 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/kel_verification.rs b/crates/auths-mobile-ffi/src/kel_verification.rs new file mode 100644 index 00000000..530a016e --- /dev/null +++ b/crates/auths-mobile-ffi/src/kel_verification.rs @@ -0,0 +1,562 @@ +//! 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 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; + +/// 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 { + // 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) + .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}")))?; + + 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() + ))); + } + + 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}"), + 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, 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}; + 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(_)) + )); + } + + // ----------------------------------------------------------------------- + // 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 7154ec3d..000811e9 100644 --- a/crates/auths-mobile-ffi/src/lib.rs +++ b/crates/auths-mobile-ffi/src/lib.rs @@ -20,8 +20,10 @@ 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 kel_verification; pub mod pairing_context; pub mod shared_kel_context; pub(crate) mod signature; @@ -31,16 +33,23 @@ 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, }; +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, }; @@ -80,6 +89,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), } // ============================================================================ @@ -226,7 +238,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(); @@ -312,8 +325,8 @@ pub fn parse_auth_challenge_uri(uri: String) -> Result, 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-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"); + } + } +} 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/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", + ), } } } diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index fda021b5..c31124a6 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -22,8 +22,11 @@ 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 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"; @@ -141,6 +144,118 @@ 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, 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) + && v.checkpoint_attested.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 — +/// 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. +/// +/// 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, 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()?; + 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 = 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()) + })?; + 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, pinned_log_key)?; + 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/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/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..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)] @@ -59,45 +60,29 @@ 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 -/// provenance / SPDX SBOM / CRA mapping) lands in fn-157.9; 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, -} - -/// 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 @@ -117,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 @@ -307,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). @@ -352,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") @@ -507,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(); @@ -560,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/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/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-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/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/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/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/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 e05c7003..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,106 +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; - -/// 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}"))) - } -} /// 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..0bbd0ac4 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 @@ -164,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")] @@ -230,10 +219,9 @@ 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::OidcPolicyInvalid { .. } => "AUTHS-E5628", Self::PolicyBlobMissing { .. } => "AUTHS-E5623", Self::PolicyIntegrity { .. } => "AUTHS-E5624", Self::ChainCycle { .. } => "AUTHS-E5625", @@ -293,15 +281,13 @@ 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", ), + 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 6b203c89..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 @@ -34,7 +36,10 @@ 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 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/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 0e3d5a41..382ed86a 100644 --- a/crates/auths-sdk/src/domains/org/offline_verify.rs +++ b/crates/auths-sdk/src/domains/org/offline_verify.rs @@ -1,263 +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. -fn authenticate_bundled_kel( - kel: &BundledKel, - lookup: Option<&dyn auths_keri::DelegatorKelLookup>, -) -> Result<(), OrgError> { - 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}")))?; - Ok(()) -} - -/// 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/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/domains/org/service.rs b/crates/auths-sdk/src/domains/org/service.rs index 842be39d..0b114059 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(), @@ -323,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, 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, diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index 5d187a84..44a6f35d 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,9 +680,36 @@ fn create_and_sign_attestation( .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string())) } -/// 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. +/// 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, + /// 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. + 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 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, @@ -690,41 +718,47 @@ 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(), +/// curve: auths_crypto::CurveType::default(), +/// 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 { - // 1. Generate ephemeral P-256 seed and zeroize on drop + let EphemeralSignRequest { + data, + artifact_name, + commit_sha, + curve, + expires_in, + note, + ci_env, + oidc_binding, + } = req; + // 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()); @@ -766,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(""); @@ -783,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), @@ -791,6 +825,7 @@ pub fn sign_artifact_ephemeral( delegated_by: None, commit_sha: Some(validated_sha), signer_type: Some(SignerType::Workload), + oidc_binding, }, &signer, &noop_provider, @@ -844,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()))?; @@ -909,6 +941,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/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, 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-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/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/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-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/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/compliance_query.rs b/crates/auths-sdk/tests/cases/compliance_query.rs index 29ae4cea..255ece21 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, }; @@ -212,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!( @@ -224,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" ); } @@ -272,13 +274,119 @@ 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" ); } +#[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, std::slice::from_ref(&org_did), None) + .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" + ); + 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] fn dsse_sign_and_verify_round_trips() { let (ctx, org_alias, org_prefix, org_did, _tmp) = setup(); @@ -331,6 +439,105 @@ 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), None) + .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), 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], None).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 +891,196 @@ fn cra_report_maps_obligations_and_ssdf_practices() { assert!(canonical.contains("\"policy_met\":false")); 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}; + +/// 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..d557956d 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,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -36,12 +40,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -53,12 +61,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -70,22 +82,30 @@ 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(), + curve: auths_crypto::CurveType::default(), + 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -108,12 +128,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: Some(ci_env), + oidc_binding: None, + }, ) .expect("signing should succeed"); @@ -128,8 +152,20 @@ 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(), + curve: auths_crypto::CurveType::default(), + 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 +175,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ); assert!(result.is_err(), "invalid commit SHA should be rejected"); @@ -160,12 +200,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -203,12 +247,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + expires_in: None, + note: None, + ci_env: None, + oidc_binding: None, + }, ) .unwrap(); @@ -229,12 +277,16 @@ 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(), + curve: auths_crypto::CurveType::default(), + 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 +301,140 @@ 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(), + curve: auths_crypto::CurveType::default(), + 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"); + } +} + +/// 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" + ); + } +} 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_delegation.rs b/crates/auths-sdk/tests/cases/org_delegation.rs index 6490b29e..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,44 @@ 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:?}" + ); +} + +#[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, OrgBundleError::Integrity { .. }), + "expected Integrity, got {err:?}" ); } 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); +} 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(_))); +} 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/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 26943eae..44f33b14 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, prove_inclusion, 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-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-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/commit.rs b/crates/auths-verifier/src/commit.rs index a7c108c6..f19f8d22 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(), - )); - } } } @@ -172,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: @@ -262,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/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/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/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`. diff --git a/crates/auths-verifier/src/evidence_pack.rs b/crates/auths-verifier/src/evidence_pack.rs new file mode 100644 index 00000000..4dca1a43 --- /dev/null +++ b/crates/auths-verifier/src/evidence_pack.rs @@ -0,0 +1,784 @@ +//! 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::core::Ed25519PublicKey; +use crate::org_bundle::{ + AirGappedOrgBundle, AuthorityAtSigning, classify_authority_in_bundle, verify_org_bundle, +}; +use crate::tlog::{ConsistencyProof, InclusionProof, MerkleHash, SignedCheckpoint, hash_leaf}; +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). 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, + /// The signed checkpoint the inclusion is anchored to (directly, or via the + /// 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. + #[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 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, + /// 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. +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(()) +} + +/// 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 +/// 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. 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, 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( + "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; + + // 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| 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. + 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) +} + +// ── 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. +/// * `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"]"#, None); +/// ``` +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, pinned_log_key_hex) { + 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()) +} + +/// Parse a pinned log key from its hex wire form (fail-closed on malformed input). +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) + .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!( + "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}")))?; + 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)] +#[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, 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", "[]", 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 { + 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" + ); + } + + /// 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/lib.rs b/crates/auths-verifier/src/lib.rs index 414cb046..402c4fb7 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). @@ -58,12 +60,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; @@ -93,6 +103,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; @@ -130,10 +143,12 @@ 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, 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/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/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..0ca816c9 --- /dev/null +++ b/crates/auths-verifier/src/tlog/checkpoint.rs @@ -0,0 +1,268 @@ +//! 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, +} + +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 +/// 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); + } + + 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") + .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..e76de350 --- /dev/null +++ b/crates/auths-verifier/src/tlog/error.rs @@ -0,0 +1,47 @@ +//! 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), + + /// 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 new file mode 100644 index 00000000..f2a36bec --- /dev/null +++ b/crates/auths-verifier/src/tlog/merkle.rs @@ -0,0 +1,691 @@ +//! 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()) + } +} + +/// 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::*; + + #[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 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); + } + + #[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..e2e9b300 --- /dev/null +++ b/crates/auths-verifier/src/tlog/mod.rs @@ -0,0 +1,31 @@ +//! 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, prove_inclusion, 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/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. diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index cf74dc85..772b1c1a 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -389,29 +389,22 @@ pub async fn wasm_validate_kel_json( let attachments: Vec = 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)))?; @@ -519,3 +512,102 @@ 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 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. +/// +/// 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. 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, + 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(), + ) +} 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 b856e2be..16f52eb1 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/"]; @@ -68,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") }) 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: 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-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-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/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 84d9ca6b..6744b800 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 | @@ -274,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} | @@ -306,15 +317,13 @@ 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}' | | [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 93ad87d1..7863820a 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 @@ -278,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 @@ -310,15 +321,13 @@ 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 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 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 --- 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,