diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..5592118f6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5419fcbb2..c7a665cc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,6 +107,7 @@ jobs: cargo build --release --locked --package auths-cli --package auths-mcp-gateway + --package auths-witness-node --target ${{ matrix.target }} - name: Package (Unix) @@ -116,6 +117,7 @@ jobs: cp target/${{ matrix.target }}/release/auths staging/ || true cp target/${{ matrix.target }}/release/auths-sign staging/ || true cp target/${{ matrix.target }}/release/auths-verify staging/ || true + cp target/${{ matrix.target }}/release/witness-node staging/ || true # No `|| true`: a silently absent gateway is how it shipped to nobody. cp target/${{ matrix.target }}/release/auths-mcp-gateway staging/ tar -czf ${{ matrix.asset_name }}${{ matrix.ext }} -C staging . @@ -128,6 +130,7 @@ jobs: Copy-Item target/${{ matrix.target }}/release/auths.exe staging/ -ErrorAction SilentlyContinue Copy-Item target/${{ matrix.target }}/release/auths-sign.exe staging/ -ErrorAction SilentlyContinue Copy-Item target/${{ matrix.target }}/release/auths-verify.exe staging/ -ErrorAction SilentlyContinue + Copy-Item target/${{ matrix.target }}/release/witness-node.exe staging/ -ErrorAction SilentlyContinue # No SilentlyContinue: a silently absent gateway is how it shipped to nobody. Copy-Item target/${{ matrix.target }}/release/auths-mcp-gateway.exe staging/ Compress-Archive -Path staging/* -DestinationPath ${{ matrix.asset_name }}${{ matrix.ext }} diff --git a/.gitignore b/.gitignore index ec5173b45..9141e2494 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,10 @@ RustAgentDemo/*.a concat_output.txt CLAUDE.md AGENTS.md -.cargo/ +# Ignore local .cargo contents (audit.toml, logs) but track the committed +# `cargo xtask` alias so a fresh clone resolves the shorthand the docs print. +.cargo/* +!.cargo/config.toml my-artifact.txt my-artifact.txt.auths.json diff --git a/Cargo.lock b/Cargo.lock index aff70f740..6824b4987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,7 @@ version = "0.1.12" dependencies = [ "anyhow", "assert_cmd", + "auths-anchor", "auths-crypto", "auths-index", "auths-infra-git", @@ -748,6 +749,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "tokio", ] [[package]] @@ -8756,6 +8758,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "tempfile", "tree-sitter", "tree-sitter-rust", "walkdir", diff --git a/crates/auths-anchor/Cargo.toml b/crates/auths-anchor/Cargo.toml index 27697af3d..d30f45c5e 100644 --- a/crates/auths-anchor/Cargo.toml +++ b/crates/auths-anchor/Cargo.toml @@ -46,11 +46,16 @@ thiserror.workspace = true getrandom = { version = "0.2", features = ["js"] } [features] -# No features: the whole crate is the pure protocol core. The verification half -# (freshness, finalized-anchor verification, duplicity-proof verification) is -# WASM-safe; the acceptance rule is pure but node-facing. Kept flag-free so -# `cargo check --target wasm32-unknown-unknown` exercises the real surface. +# No default features: the whole crate is the pure protocol core. The +# verification half (freshness, finalized-anchor verification, duplicity-proof +# verification) is WASM-safe; the acceptance rule is pure but node-facing. Kept +# flag-free so `cargo check --target wasm32-unknown-unknown` exercises the real +# surface. default = [] +# Exposes the deterministic, fixed-seed finalized-anchor fixtures (the same +# builders the in-crate unit tests use) to downstream test crates such as +# `auths-evidence`. Never enabled in a production build. +test-support = [] [dev-dependencies] # Deterministic (fixed-seed) key material for tests — no RNG, so vectors are diff --git a/crates/auths-anchor/src/accept.rs b/crates/auths-anchor/src/accept.rs index 32242f834..5dccd4bb3 100644 --- a/crates/auths-anchor/src/accept.rs +++ b/crates/auths-anchor/src/accept.rs @@ -21,6 +21,10 @@ pub enum Acceptance { /// The request is well-ordered and authorized — the caller should sign, /// CAS-store, log, and gossip it. CoSign(Box), + /// The request exactly matches the last co-signed anchor — accept as a + /// no-op; the caller must NOT re-store or re-cosign it. This is an + /// idempotent replay, not a rollback and not equivocation. + AlreadyAnchored(Box), /// The request equivocates against the prior anchor at the same index — the /// caller must refuse and publish the proof. Duplicity(Box), @@ -69,13 +73,16 @@ pub fn accept_anchor( } verify_party_signature(req, keys)?; if let Some(last) = prior { - if req.index == last.index && req.head != last.head { + if req.index == last.index { + if req.head == last.head { + return Ok(Acceptance::AlreadyAnchored(Box::new(last.clone()))); + } return Ok(Acceptance::Duplicity(Box::new(DuplicityProof::new( last, req, )?))); } - if req.index <= last.index { - return Err(AnchorError::NonMonotoneIndex { + if req.index < last.index { + return Err(AnchorError::IndexRollback { got: req.index, prior: last.index, }); @@ -149,21 +156,35 @@ mod tests { let fork = sample_anchor_with_head(5, [2u8; 32]); match accept_anchor(&fork, &keys, Some(&prior), now()).unwrap() { Acceptance::Duplicity(proof) => proof.verify().unwrap(), - Acceptance::CoSign(_) => panic!("expected duplicity"), + Acceptance::CoSign(_) => panic!("expected duplicity, got a co-sign"), + Acceptance::AlreadyAnchored(_) => { + panic!("expected duplicity, got an idempotent replay") + } } } #[test] - fn index_regression_is_rejected() { + fn lower_index_is_rollback() { let prior = sample_anchor(5); let keys = controller_keys_for(&prior); let stale = sample_anchor(3); assert!(matches!( accept_anchor(&stale, &keys, Some(&prior), now()), - Err(AnchorError::NonMonotoneIndex { got: 3, prior: 5 }) + Err(AnchorError::IndexRollback { got: 3, prior: 5 }) )); } + #[test] + fn exact_replay_is_already_anchored_not_error() { + let prior = sample_anchor(4); + let keys = controller_keys_for(&prior); + let replay = prior.clone(); + match accept_anchor(&replay, &keys, Some(&prior), now()).unwrap() { + Acceptance::AlreadyAnchored(anchor) => assert_eq!(anchor.index, 4), + other => panic!("expected an idempotent replay, got {other:?}"), + } + } + #[test] fn tampered_signature_is_rejected() { let mut req = sample_anchor(1); diff --git a/crates/auths-anchor/src/error.rs b/crates/auths-anchor/src/error.rs index 3ff21ccfb..744f431c8 100644 --- a/crates/auths-anchor/src/error.rs +++ b/crates/auths-anchor/src/error.rs @@ -18,9 +18,11 @@ pub enum AnchorError { #[error("party key is not a current key of the controller")] PartyKeyNotCurrent, - /// A new anchor's index did not strictly increase over the prior anchor. - #[error("non-monotone index: {got} does not exceed prior {prior}")] - NonMonotoneIndex { + /// A new anchor's index regressed below the prior anchor — a rollback + /// attempt. An exact-index, exact-head resubmission is a benign no-op and is + /// reported separately, so only a true rewind reaches this variant. + #[error("index {got} regresses below prior {prior} — fetch the latest anchor and extend it")] + IndexRollback { /// The submitted index. got: u64, /// This witness's last co-signed index for the seed. diff --git a/crates/auths-anchor/src/finalize.rs b/crates/auths-anchor/src/finalize.rs index bafc96009..17d2d2c26 100644 --- a/crates/auths-anchor/src/finalize.rs +++ b/crates/auths-anchor/src/finalize.rs @@ -315,4 +315,41 @@ mod tests { Err(AnchorError::SetInvalid(_)) )); } + + #[test] + fn flipped_cosig_byte_rejected() { + // A single flipped byte in a cosignature no longer verifies over the + // cosign message — the quorum fails whole. + let mut finalized = finalized_sample(3, 2); + let mut bytes = *finalized.cosignatures[0].signature.as_bytes(); + bytes[0] ^= 0x01; + finalized.cosignatures[0].signature = auths_verifier::Ed25519Signature::from_bytes(bytes); + assert!(matches!( + verify_finalized(&finalized, None), + Err(AnchorError::CosignatureInvalid { .. }) + )); + } + + #[test] + fn foreign_witness_cosig_rejected() { + // A cosignature attributed to a name outside the declared set is refused + // — cosigners must be inside the declared witness set. + let mut finalized = finalized_sample(3, 2); + finalized.cosignatures[0].witness_name = "outsider".to_string(); + assert!(matches!( + verify_finalized(&finalized, None), + Err(AnchorError::CosignerOutsideSet { .. }) + )); + } + + #[test] + fn threshold_above_member_count_rejected() { + // A threshold above the declared member count is a structurally invalid + // set — refused before any key it declares is trusted. + let finalized = finalized_sample(3, 5); + assert!(matches!( + verify_finalized(&finalized, None), + Err(AnchorError::SetInvalid(_)) + )); + } } diff --git a/crates/auths-anchor/src/lib.rs b/crates/auths-anchor/src/lib.rs index 56edbf8ff..48954e111 100644 --- a/crates/auths-anchor/src/lib.rs +++ b/crates/auths-anchor/src/lib.rs @@ -51,9 +51,14 @@ pub use auths_crypto::CurveType; /// consumer building a [`FinalizedAnchor`] needs only one dependency. pub use auths_transparency::{Checkpoint, InclusionProof, SignedCheckpoint, WitnessCosignature}; -#[cfg(test)] -pub(crate) mod test_support { - //! Deterministic, fixed-seed fixtures for the in-crate unit tests. +#[cfg(any(test, feature = "test-support"))] +pub mod test_support { + //! Deterministic, fixed-seed fixtures for the in-crate unit tests and, under + //! the `test-support` feature, for downstream test crates (`auths-evidence`). + // Fixtures are test-only material built from fixed seeds, so these unwraps are + // infallible; the `#[cfg(test)]` clippy exemption does not reach the + // feature-gated build, so allow them explicitly here. Never in production. + #![allow(clippy::unwrap_used, clippy::expect_used)] use crate::types::{ Anchor, ControllerKeys, CurrentKey, FinalizedAnchor, Head, OperatorInfo, PartySignature, @@ -76,12 +81,12 @@ pub(crate) mod test_support { } /// An Ed25519-signed anchor at `index` with a deterministic head. - pub(crate) fn sample_anchor(index: u64) -> Anchor { + pub fn sample_anchor(index: u64) -> Anchor { sample_anchor_with_head(index, [index as u8; 32]) } /// An Ed25519-signed anchor at `index` with an explicit head. - pub(crate) fn sample_anchor_with_head(index: u64, head: [u8; 32]) -> Anchor { + pub fn sample_anchor_with_head(index: u64, head: [u8; 32]) -> Anchor { let sk = party_sk(); let vk = sk.verifying_key(); let mut anchor = Anchor { @@ -106,7 +111,7 @@ pub(crate) mod test_support { } /// The controller-keys view that authorizes `anchor`'s party signature. - pub(crate) fn controller_keys_for(anchor: &Anchor) -> ControllerKeys { + pub fn controller_keys_for(anchor: &Anchor) -> ControllerKeys { ControllerKeys { current: vec![CurrentKey { curve: anchor.sig_party.curve, @@ -115,13 +120,37 @@ pub(crate) mod test_support { } } - /// A finalized anchor over `n` witnesses with the given `threshold`. + /// The raw Ed25519 seed the sample / `finalized_*` anchors are party-signed + /// with. Hand it to a document signer so the embedded-anchor party-key check + /// (party key ∈ the agent's current keys) passes. + pub fn party_seed_bytes() -> [u8; 32] { + [9u8; 32] + } + + /// The Ed25519 verifying-key bytes of [`party_seed_bytes`] — the party key + /// every sample anchor is signed under. + pub fn party_verifying_key_bytes() -> [u8; 32] { + party_sk().verifying_key().to_bytes() + } + + /// A finalized anchor over `n` witnesses at `threshold` whose tuple restates + /// a CALLER-chosen `(seed_id, index, head, cumulative)`. Everything the + /// embedded-anchor check restates against a document is caller-controlled, so + /// an `activity/v1` verifier test can build a document this anchor + /// legitimately restates. Party-signed by [`party_seed_bytes`]. /// /// The declared set is genuinely self-addressing (its SAID is computed from /// content and committed into the party-signed anchor), and every cosigner - /// carries a member-signed checkpoint rooting its inclusion proof — the - /// same material a real node must produce. - pub(crate) fn finalized_sample(n: u8, threshold: u32) -> FinalizedAnchor { + /// carries a member-signed checkpoint rooting its inclusion proof — the same + /// material a real node must produce. + pub fn finalized_matching( + seed_id: SeedId, + index: u64, + head: [u8; 32], + cumulative: u128, + n: u8, + threshold: u32, + ) -> FinalizedAnchor { let members: Vec = (0..n) .map(|i| WitnessRef { name: format!("witness-{i}"), @@ -142,12 +171,23 @@ pub(crate) mod test_support { }; witness_set.said = witness_set.computed_said().unwrap(); - let mut anchor = sample_anchor(1); - anchor.witness_set = WitnessSetRef { - said: witness_set.said.clone(), - threshold, - }; let sk = party_sk(); + let mut anchor = Anchor { + seed_id, + index, + head: Head::from_bytes(head), + cumulative, + timestamp: ts(index), + witness_set: WitnessSetRef { + said: witness_set.said.clone(), + threshold, + }, + sig_party: PartySignature { + curve: CurveType::Ed25519, + public_key: sk.verifying_key().as_bytes().to_vec(), + signature: Vec::new(), + }, + }; let message = anchor.party_signing_bytes().unwrap(); anchor.sig_party.signature = sk.sign(&message).to_bytes().to_vec(); @@ -177,9 +217,22 @@ pub(crate) mod test_support { } } + /// A finalized anchor over `n` witnesses with the given `threshold`, on the + /// default sample spend chain (`sample_anchor(1)`'s tuple). + pub fn finalized_sample(n: u8, threshold: u32) -> FinalizedAnchor { + finalized_matching( + SeedId::derive("did:keri:root", "did:keri:agent", "ESeal"), + 1, + [1u8; 32], + 100, + n, + threshold, + ) + } + /// A member-signed checkpoint over a one-leaf log containing `leaf`, with /// the inclusion proof rooted in it. - pub(crate) fn logged_inclusion_for( + pub fn logged_inclusion_for( name: &str, witness_key: &SigningKey, leaf: auths_transparency::MerkleHash, @@ -217,7 +270,7 @@ pub(crate) mod test_support { } /// Keep only the first `k` cosignatures (to fall below a threshold). - pub(crate) fn with_cosigners(mut finalized: FinalizedAnchor, k: usize) -> FinalizedAnchor { + pub fn with_cosigners(mut finalized: FinalizedAnchor, k: usize) -> FinalizedAnchor { finalized.cosignatures.truncate(k); finalized } diff --git a/crates/auths-anchor/tests/cases/invariants.rs b/crates/auths-anchor/tests/cases/invariants.rs index ab32c6377..6f46e6839 100644 --- a/crates/auths-anchor/tests/cases/invariants.rs +++ b/crates/auths-anchor/tests/cases/invariants.rs @@ -26,9 +26,35 @@ fn i_dup_1_never_two_heads_one_index() { match accept_anchor(&fork, &keys, Some(&prior), now()).unwrap() { Acceptance::Duplicity(proof) => proof.verify().unwrap(), Acceptance::CoSign(_) => panic!("co-signed a fork — I-DUP-1 violated"), + Acceptance::AlreadyAnchored(_) => panic!("treated a fork as an idempotent replay"), } } +/// A duplicity proof survives a JSON round-trip and still verifies, while a +/// single flipped head byte makes the re-hydrated proof unverifiable — the +/// valid-then-tampered control a counterparty relies on. +#[test] +fn duplicity_proof_round_trips_and_rejects_tampering() { + let a = signed_anchor(1, [1u8; 32], 2, "EWitSet"); + let b = signed_anchor(1, [2u8; 32], 2, "EWitSet"); + let proof = DuplicityProof::new(&a, &b).unwrap(); + + let wire = serde_json::to_string(&proof).unwrap(); + let rehydrated: DuplicityProof = serde_json::from_str(&wire).unwrap(); + rehydrated.verify().unwrap(); + + let mut tampered: DuplicityProof = serde_json::from_str(&wire).unwrap(); + let mut head = *tampered.anchor_b.head.as_bytes(); + head[0] ^= 0xff; + tampered.anchor_b.head = auths_anchor::Head::from_bytes(head); + let reserialized = serde_json::to_string(&tampered).unwrap(); + let reloaded: DuplicityProof = serde_json::from_str(&reserialized).unwrap(); + assert!(matches!( + reloaded.verify(), + Err(AnchorError::InvalidDuplicityProof(_)) + )); +} + /// I-DUP-2: a duplicity proof is self-contained and verifies offline by a /// stranger who reconstructs it from the two anchors alone. #[test] diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 30e85c057..8f56a4bc3 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -42,6 +42,9 @@ auths-index.workspace = true auths-crypto.workspace = true auths-sdk = { workspace = true, features = ["backend-git", "witness-server", "witness-client", "indexed-storage", "keychain-secure-enclave"] } auths-transparency = { workspace = true, features = ["native"] } +# Pure protocol core for `auths anchor verify` — offline duplicity-proof +# re-verification, no witness contacted, no registry consulted. +auths-anchor.workspace = true auths-pairing-protocol.workspace = true auths-telemetry = { workspace = true, features = ["sink-http"] } auths-verifier = { workspace = true, features = ["native"] } diff --git a/crates/auths-cli/src/bin/sign.rs b/crates/auths-cli/src/bin/sign.rs index 746d9777a..7653d9e2e 100644 --- a/crates/auths-cli/src/bin/sign.rs +++ b/crates/auths-cli/src/bin/sign.rs @@ -106,8 +106,7 @@ fn validate_verify_option(opt: &str) -> Result<()> { bail!( "disallowed verify option '-O {opt}'\n \ - Only these -O options are permitted: verify-time=, print-pubkey, hashalg=sha256, hashalg=sha512\n \ - [AUTHS-E0031]" + Only these -O options are permitted: verify-time=, print-pubkey, hashalg=sha256, hashalg=sha512" ); } diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index d3d57da2e..8af2a5083 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -5,6 +5,7 @@ use clap::{Parser, Subcommand}; use crate::commands::account::AccountCommand; use crate::commands::agent::AgentCommand; +use crate::commands::anchor::AnchorCommand; use crate::commands::approval::ApprovalCommand; use crate::commands::artifact::ArtifactCommand; use crate::commands::audit::AuditCommand; @@ -163,6 +164,8 @@ pub enum RootCommand { Agent(AgentCommand), /// Aggregate treasury cap across a manager's sub-delegated agents. Treasury(TreasuryCommand), + /// Verify witness-network anchor evidence (duplicity proofs) offline. + Anchor(AnchorCommand), #[command(hide = true)] Witness(WitnessCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/anchor.rs b/crates/auths-cli/src/commands/anchor.rs new file mode 100644 index 000000000..500c74e5a --- /dev/null +++ b/crates/auths-cli/src/commands/anchor.rs @@ -0,0 +1,86 @@ +//! Offline verification of witness-network anchor artifacts. +//! +//! The witness returns a portable, self-contained duplicity proof when a party +//! equivocates. This command re-checks such a proof with no witness contacted +//! and no registry consulted — the artifact a counterparty or a regulator can +//! verify for themselves. + +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use auths_anchor::DuplicityProof; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Verify witness-network anchor evidence, offline. +#[derive(Parser, Debug, Clone)] +pub struct AnchorCommand { + #[command(subcommand)] + pub subcommand: AnchorSubcommand, +} + +/// Anchor subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum AnchorSubcommand { + /// Verify a duplicity proof offline — no witness contacted, no registry. + /// + /// Re-checks that one party key signed two different heads at one + /// `(seed, index)`. Exits non-zero with a named verdict if the proof is + /// forged or tampered — the artifact you hand a counterparty or a court. + Verify { + /// Path to the duplicity-proof JSON (`-` reads stdin). + #[clap(long)] + proof: PathBuf, + }, +} + +/// Dispatch an `auths anchor` subcommand. +/// +/// Args: +/// * `cmd`: the parsed anchor command. +/// +/// Usage: +/// ```ignore +/// handle_anchor(cmd)?; +/// ``` +pub fn handle_anchor(cmd: AnchorCommand) -> Result<()> { + match cmd.subcommand { + AnchorSubcommand::Verify { proof } => verify_duplicity(proof), + } +} + +/// Read a duplicity proof (file path or `-` for stdin) and re-verify it offline. +fn verify_duplicity(path: PathBuf) -> Result<()> { + let bytes = if path.as_os_str() == "-" { + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut std::io::stdin(), &mut buf) + .map_err(|e| anyhow!("could not read the proof from stdin: {e}"))?; + buf + } else { + std::fs::read(&path).map_err(|e| anyhow!("could not read {}: {e}", path.display()))? + }; + let proof: DuplicityProof = serde_json::from_slice(&bytes) + .map_err(|e| anyhow!("not a readable duplicity proof: {e}"))?; + match proof.verify() { + Ok(()) => { + println!( + "DUPLICITY PROVEN (offline, no witness contacted)\n \ + seed {}\n index {}\n heads {} / {}\n \ + => one party key signed two heads at one index. CHEAT PROVEN.", + proof.seed_id.to_hex(), + proof.index, + proof.anchor_a.head.to_hex(), + proof.anchor_b.head.to_hex(), + ); + Ok(()) + } + Err(e) => Err(anyhow!("INVALID duplicity proof: {e}")), + } +} + +impl crate::commands::executable::ExecutableCommand for AnchorCommand { + fn execute(&self, _ctx: &CliConfig) -> Result<()> { + handle_anchor(self.clone()) + } +} diff --git a/crates/auths-cli/src/commands/doctor.rs b/crates/auths-cli/src/commands/doctor.rs index 300669f42..5db92419c 100644 --- a/crates/auths-cli/src/commands/doctor.rs +++ b/crates/auths-cli/src/commands/doctor.rs @@ -5,6 +5,7 @@ use crate::adapters::system_diagnostic::PosixDiagnosticAdapter; use crate::ux::format::{JsonResponse, Output, is_json_mode}; use anyhow::Result; use auths_sdk::keychain; +use auths_sdk::paths::resolve_registry_path; use auths_sdk::ports::diagnostics::{ CheckCategory, CheckResult, ConfigIssue, DiagnosticFix, FixApplied, }; @@ -13,6 +14,7 @@ use chrono::{DateTime, Utc}; use clap::Parser; use serde::Serialize; use std::io::IsTerminal; +use std::path::PathBuf; /// Health check command. #[derive(Parser, Debug, Clone)] @@ -65,8 +67,18 @@ pub struct DoctorReport { } /// Handle the doctor command. -pub fn handle_doctor(cmd: DoctorCommand) -> Result<()> { - let checks = run_checks(); +/// +/// Args: +/// * `cmd`: the parsed [`DoctorCommand`]. +/// * `repo_override`: the resolved storage-root override (`--repo`/`AUTHS_REPO`/ +/// `AUTHS_HOME`), so every check reports the same root `init`/`sign` wrote to. +/// +/// Usage: +/// ```ignore +/// handle_doctor(cmd, ctx.repo_path.clone())?; +/// ``` +pub fn handle_doctor(cmd: DoctorCommand, repo_override: Option) -> Result<()> { + let checks = run_checks(repo_override.clone()); let all_pass = checks.iter().all(|c| c.passed); let (final_checks, fixes_applied) = if cmd.fix && !all_pass { @@ -77,7 +89,7 @@ pub fn handle_doctor(cmd: DoctorCommand) -> Result<()> { }; let fixes = apply_fixes(&checks, out.as_ref()); if !fixes.is_empty() { - let rechecked = run_checks(); + let rechecked = run_checks(repo_override.clone()); (rechecked, fixes) } else { (checks, fixes) @@ -149,7 +161,7 @@ fn compute_exit_code(checks: &[Check]) -> i32 { /// Run all prerequisite checks. #[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() injected here -fn run_checks() -> Vec { +fn run_checks(repo_override: Option) -> Vec { let now = Utc::now(); let adapter = PosixDiagnosticAdapter; let workflow = DiagnosticsWorkflow::new(&adapter, &adapter); @@ -175,12 +187,12 @@ fn run_checks() -> Vec { // Domain checks are all Critical checks.push(check_keychain_accessible()); - checks.push(check_identity_home()); - checks.push(check_auths_repo()); - checks.push(check_identity_valid(now)); + checks.push(check_identity_home(repo_override.clone())); + checks.push(check_auths_repo(repo_override.clone())); + checks.push(check_identity_valid(now, repo_override.clone())); // Advisory: commit-trailer hook wiring (plain `git commit` verifiability) - checks.push(check_commit_hook_installed()); + checks.push(check_commit_hook_installed(repo_override)); checks.push(check_repo_hooks_path_override()); // Advisory: network connectivity @@ -191,8 +203,8 @@ fn run_checks() -> Vec { /// Advisory: the prepare-commit-msg hook is installed, current, and wired via /// the global `core.hooksPath` — what makes a plain `git commit` verifiable. -fn check_commit_hook_installed() -> Check { - let (passed, detail) = match auths_sdk::paths::auths_home() { +fn check_commit_hook_installed(repo_override: Option) -> Check { + let (passed, detail) = match resolve_registry_path(repo_override) { Ok(home) if !home.join("commit-trailers").exists() => { // No identity / pre-hook install — not a failure, just not set up yet. ( @@ -437,17 +449,18 @@ fn check_keychain_accessible() -> Check { } } -/// Advisory: report which identity home directory is in effect and why. +/// Advisory: report which identity storage root is in effect and why. /// -/// Resolution is `AUTHS_HOME` (env override, when set and non-empty) then the +/// Resolution is the shared override (`--repo`/`AUTHS_REPO`/`AUTHS_HOME`) then the /// `~/.auths` default. Surfacing the winner and its source turns "why can't it -/// find my identity" into a one-glance answer instead of a guessing game. -fn check_identity_home() -> Check { - let env_home = std::env::var("AUTHS_HOME").ok().filter(|s| !s.is_empty()); - let (passed, detail, suggestion) = match auths_sdk::paths::auths_home() { +/// find my identity" into a one-glance answer — and reports the *same* root +/// `init`/`sign` wrote to, not a default the other commands never used. +fn check_identity_home(repo_override: Option) -> Check { + let overridden = repo_override.is_some(); + let (passed, detail, suggestion) = match resolve_registry_path(repo_override) { Ok(path) => { - let source = if env_home.is_some() { - "from AUTHS_HOME" + let source = if overridden { + "from --repo/AUTHS_REPO" } else { "default ~/.auths" }; @@ -456,7 +469,7 @@ fn check_identity_home() -> Check { Err(e) => ( false, format!("cannot resolve: {e}"), - Some("Set AUTHS_HOME, or ensure a home directory is available".to_string()), + Some("Set AUTHS_REPO, or ensure a home directory is available".to_string()), ), }; Check { @@ -468,8 +481,8 @@ fn check_identity_home() -> Check { } } -fn check_auths_repo() -> Check { - let (passed, detail, suggestion) = match auths_sdk::paths::auths_home() { +fn check_auths_repo(repo_override: Option) -> Check { + let (passed, detail, suggestion) = match resolve_registry_path(repo_override) { Ok(path) => { if !path.exists() { ( @@ -507,7 +520,7 @@ fn check_auths_repo() -> Check { } } -fn check_identity_valid(now: DateTime) -> Check { +fn check_identity_valid(now: DateTime, repo_override: Option) -> Check { let (passed, detail, suggestion) = match keychain::get_platform_keychain() { Ok(keychain) => match keychain.list_aliases() { Ok(aliases) if aliases.is_empty() => ( @@ -517,7 +530,7 @@ fn check_identity_valid(now: DateTime) -> Check { ), Ok(aliases) => { let key_count = aliases.len(); - let expiry_info = check_attestation_expiry(now); + let expiry_info = check_attestation_expiry(now, repo_override); match expiry_info { ExpiryStatus::AllExpired(msg) => ( false, @@ -560,10 +573,10 @@ enum ExpiryStatus { AllExpired(String), } -fn check_attestation_expiry(now: DateTime) -> ExpiryStatus { +fn check_attestation_expiry(now: DateTime, repo_override: Option) -> ExpiryStatus { use auths_sdk::storage::RegistryAttestationStorage; - let repo_path = match auths_sdk::paths::auths_home() { + let repo_path = match resolve_registry_path(repo_override) { Ok(p) if p.exists() => p, _ => return ExpiryStatus::NoAttestations, }; @@ -713,8 +726,8 @@ fn print_report(report: &DoctorReport) { } impl crate::commands::executable::ExecutableCommand for DoctorCommand { - fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> { - handle_doctor(self.clone()) + fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { + handle_doctor(self.clone(), ctx.repo_path.clone()) } } diff --git a/crates/auths-cli/src/commands/error_lookup.rs b/crates/auths-cli/src/commands/error_lookup.rs index c484731db..b6c464c69 100644 --- a/crates/auths-cli/src/commands/error_lookup.rs +++ b/crates/auths-cli/src/commands/error_lookup.rs @@ -67,8 +67,32 @@ impl ErrorLookupCommand { } } +/// Normalize a user-typed error code to its canonical `AUTHS-E####` form. +/// +/// Accepts the fully-qualified form and the shorthands people actually type: +/// `E4203`, `4203`, `auths-e4203` all resolve to `AUTHS-E4203`. Anything that is +/// not a bare numeric code is upper-cased and passed through unchanged. +/// +/// Args: +/// * `code`: the raw code string from the CLI. +/// +/// Usage: +/// ```ignore +/// assert_eq!(normalize_error_code("E4203"), "AUTHS-E4203"); +/// ``` +pub fn normalize_error_code(code: &str) -> String { + let upper = code.trim().to_uppercase(); + let without_prefix = upper.strip_prefix("AUTHS-").unwrap_or(&upper); + let digits = without_prefix.strip_prefix('E').unwrap_or(without_prefix); + if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) { + format!("AUTHS-E{digits}") + } else { + upper + } +} + fn explain_code(code: &str, ctx: &CliConfig) -> anyhow::Result<()> { - let normalized = code.to_uppercase(); + let normalized = normalize_error_code(code); if ctx.is_json() { match registry::explain(&normalized) { @@ -120,3 +144,31 @@ fn list_codes(ctx: &CliConfig) -> anyhow::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::normalize_error_code; + use crate::errors::registry; + + #[test] + fn bare_and_prefixed_codes_resolve_the_same() { + // A user who types the short form must reach the same doc as the canonical + // one — `E4203`, `4203`, `auths-e4203` all mean AUTHS-E4203. + assert_eq!(normalize_error_code("E4203"), "AUTHS-E4203"); + assert_eq!(normalize_error_code("4203"), "AUTHS-E4203"); + assert_eq!(normalize_error_code("auths-e4203"), "AUTHS-E4203"); + assert_eq!(normalize_error_code("AUTHS-E4203"), "AUTHS-E4203"); + assert_eq!( + registry::explain(&normalize_error_code("E4203")), + registry::explain("AUTHS-E4203") + ); + assert!(registry::explain(&normalize_error_code("E4203")).is_some()); + } + + #[test] + fn non_numeric_input_passes_through_uppercased() { + // The `LIST` shorthand (and any unknown token) must not be mangled into a + // fake code. + assert_eq!(normalize_error_code("list"), "LIST"); + } +} diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index 67d3715e5..ddde4b68d 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -30,11 +30,21 @@ pub(crate) fn gather_developer_config( CreateDeveloperIdentityConfig, )> { out.print_info("Checking prerequisites..."); - let keychain = check_keychain_access(out)?; + let keychain = check_keychain_access(interactive, out)?; check_git_version(out)?; out.print_success("Prerequisites OK"); out.newline(); + // State the passphrase policy up front so an interactive user learns the rule + // before the prompt, not by failing it. Sourced from the same constant the + // validator and the failure message use, so the three can never drift. + if interactive { + out.print_info(&format!( + "Passphrase requirements: {}", + auths_sdk::keychain::PASSPHRASE_POLICY_HINT + )); + } + let alias = prompt_for_alias(interactive, cmd)?; let conflict_policy = prompt_for_conflict_policy(interactive, cmd, registry_path, out)?; let git_scope = prompt_for_git_scope(interactive, cmd.git_scope)?; @@ -133,7 +143,7 @@ pub(crate) fn gather_agent_config( .collect(); out.print_info("Checking prerequisites..."); - let keychain = check_keychain_access(out)?; + let keychain = check_keychain_access(interactive, out)?; out.print_success("Prerequisites OK"); out.newline(); @@ -255,9 +265,97 @@ pub(crate) fn ensure_registry_dir(registry_path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn check_keychain_access(out: &Output) -> Result> { - match get_platform_keychain() { - Ok(keychain) => { +/// What to do with a platform-selected keychain for a given run posture. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum KeychainSelection { + /// The selected backend is usable as-is. + UseSelected, + /// A headless run selected a hardware backend, but the file backend is + /// configured — switch to it instead of blocking on a Touch ID prompt. + SwitchToFile, + /// A headless run selected a hardware backend with no file backend + /// configured — fail fast with a coded error rather than hang. + FailFastHardware, +} + +/// Decide how to treat a platform-default keychain given the run posture. +/// +/// A hardware backend (Secure Enclave) needs an interactive Touch ID prompt; a +/// headless run that selects one blocks forever on a prompt that never arrives. +/// This pure decision keeps that policy testable without touching real key +/// storage. +/// +/// Args: +/// * `interactive`: whether there is a TTY to prompt at. +/// * `has_backend_override`: whether `AUTHS_KEYCHAIN_BACKEND` was set. +/// * `is_hardware_backend`: whether the selected backend signs in hardware. +/// * `file_backend_ready`: whether the encrypted-file backend is fully configured. +/// +/// Usage: +/// ```ignore +/// let choice = select_headless_keychain(false, false, true, false); +/// ``` +pub(crate) fn select_headless_keychain( + interactive: bool, + has_backend_override: bool, + is_hardware_backend: bool, + file_backend_ready: bool, +) -> KeychainSelection { + if interactive || has_backend_override || !is_hardware_backend { + return KeychainSelection::UseSelected; + } + if file_backend_ready { + KeychainSelection::SwitchToFile + } else { + KeychainSelection::FailFastHardware + } +} + +/// The typed error a headless run raises when the only usable backend needs an +/// interactive prompt. Renders as `[AUTHS-E4203]` with the file-backend remedy. +fn headless_hardware_error() -> anyhow::Error { + anyhow::Error::new(auths_sdk::error::InitError::Key( + auths_sdk::error::AgentError::BackendUnavailable { + backend: "secure-enclave", + reason: "Secure Enclave needs an interactive Touch ID prompt, \ + unavailable in this headless session" + .into(), + }, + )) +} + +pub(crate) fn check_keychain_access( + interactive: bool, + out: &Output, +) -> Result> { + #[allow(clippy::disallowed_methods)] // CLI boundary: backend override + file env + let has_backend_override = std::env::var_os("AUTHS_KEYCHAIN_BACKEND").is_some(); + let keychain = get_platform_keychain().map_err(|e| anyhow!("Keychain not accessible: {e}"))?; + + #[allow(clippy::disallowed_methods)] // CLI boundary: file-backend env presence + let file_backend_ready = std::env::var_os("AUTHS_KEYCHAIN_FILE").is_some() + && std::env::var_os("AUTHS_PASSPHRASE").is_some(); + + match select_headless_keychain( + interactive, + has_backend_override, + keychain.is_hardware_backend(), + file_backend_ready, + ) { + KeychainSelection::SwitchToFile => { + // SAFETY: single-threaded CLI context; the var is read immediately below. + unsafe { std::env::set_var("AUTHS_KEYCHAIN_BACKEND", "file") } + let keychain = + get_platform_keychain().map_err(|e| anyhow!("Keychain not accessible: {e}"))?; + out.println(&format!( + " Keychain: {} (headless, file backend)", + keychain.backend_name() + )); + preflight_env_passphrase(&*keychain)?; + Ok(keychain) + } + KeychainSelection::FailFastHardware => Err(headless_hardware_error()), + KeychainSelection::UseSelected => { out.println(&format!( " Keychain: {} (accessible)", out.success(keychain.backend_name()) @@ -265,7 +363,6 @@ pub(crate) fn check_keychain_access(out: &Output) -> Result Err(anyhow!("Keychain not accessible: {}", e)), } } @@ -274,11 +371,12 @@ pub(crate) fn check_keychain_access(out: &Output) -> Result Result<()> { auths_sdk::keychain::validate_passphrase(passphrase).map_err(|e| { - anyhow!( - "The passphrase from {source_name} fails the strength policy: {e}\n \ - Use at least 12 characters with 3 of 4 character classes \ - (lowercase, uppercase, digit, symbol)." - ) + // Route as the typed SetupError so the renderer prints [AUTHS-E5008] with its + // fix line — an untyped anyhow string loses the code and the lookup. + anyhow::Error::new(auths_sdk::error::SetupError::WeakPassphrase { + source_name: source_name.to_string(), + reason: e.to_string(), + }) }) } @@ -301,3 +399,63 @@ fn preflight_env_passphrase(keychain: &(dyn KeyStorage + Send + Sync)) -> Result pub(crate) fn map_ci_environment(detected: &Option) -> CiEnvironment { auths_sdk::domains::ci::map_ci_environment(detected) } + +#[cfg(test)] +mod tests { + use super::*; + use auths_sdk::error::AuthsErrorInfo; + + #[test] + fn headless_hardware_without_file_backend_fails_fast() { + // A non-interactive run that lands on a hardware backend with no file + // backend configured must fail fast, not select the enclave and hang on a + // Touch ID prompt that never arrives. + assert_eq!( + select_headless_keychain(false, false, true, false), + KeychainSelection::FailFastHardware + ); + } + + #[test] + fn headless_hardware_with_file_backend_switches_to_file() { + assert_eq!( + select_headless_keychain(false, false, true, true), + KeychainSelection::SwitchToFile + ); + } + + #[test] + fn interactive_or_override_or_software_uses_selected_backend() { + // Interactive runs may prompt; an explicit override is the user's choice; + // a software backend never blocks. All three keep the selected backend. + assert_eq!( + select_headless_keychain(true, false, true, false), + KeychainSelection::UseSelected + ); + assert_eq!( + select_headless_keychain(false, true, true, false), + KeychainSelection::UseSelected + ); + assert_eq!( + select_headless_keychain(false, false, false, false), + KeychainSelection::UseSelected + ); + } + + #[test] + fn headless_hardware_error_carries_the_key_code_and_file_remedy() { + // The fail-fast path must surface AUTHS-E4203 with the AUTHS_KEYCHAIN_BACKEND=file + // escape hatch — the code and remedy the headless user needs. + let err = headless_hardware_error(); + let init = err + .chain() + .find_map(|c| c.downcast_ref::()) + .expect("headless hardware failure must be a typed InitError"); + assert_eq!(init.error_code(), "AUTHS-E4203"); + let suggestion = init.suggestion().unwrap_or_default(); + assert!( + suggestion.contains("AUTHS_KEYCHAIN_BACKEND=file"), + "E4203 must name the file-backend escape hatch, got: {suggestion}" + ); + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 372ef06c4..f60ba17da 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod registry_overrides; pub mod account; pub mod agent; +pub mod anchor; pub mod approval; pub mod artifact; pub mod audit; diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index 26069efe6..ae6bcb025 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -144,7 +144,7 @@ pub struct UnifiedVerifyCommand { /// * `cmd` - Parsed UnifiedVerifyCommand. pub async fn handle_verify_unified( cmd: UnifiedVerifyCommand, - env_config: &auths_sdk::core_config::EnvironmentConfig, + ctx: &crate::config::CliConfig, ) -> Result<()> { match parse_verify_target(&cmd.target) { VerifyTarget::GitRef(ref_str) => { @@ -158,7 +158,7 @@ pub async fn handle_verify_unified( // here, silently verifying against the wrong/default trust root). identity_bundle: cmd.identity_bundle, }; - handle_verify_commit(commit_cmd, env_config).await + handle_verify_commit(commit_cmd, ctx).await } VerifyTarget::Attestation(path_str) => { let verify_cmd = VerifyCommand { @@ -200,7 +200,7 @@ pub async fn handle_verify_unified( impl crate::commands::executable::ExecutableCommand for UnifiedVerifyCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(handle_verify_unified(self.clone(), &ctx.env_config)) + rt.block_on(handle_verify_unified(self.clone(), ctx)) } } diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 7b0fc0d80..0d53e601d 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -1,8 +1,9 @@ +use crate::config::CliConfig; use crate::ux::format::is_json_mode; use anyhow::{Context, Result, anyhow}; use auths_keri::Event; use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup}; -use auths_sdk::core_config::EnvironmentConfig; +use auths_sdk::error::AuthsErrorInfo; use auths_sdk::ports::RegistryBackend; use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig}; use auths_verifier::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy}; @@ -132,16 +133,22 @@ impl VerifyCommitResult { /// Handle verify-commit command. /// Exit codes: 0=valid, 1=invalid/unsigned, 2=error #[allow(clippy::disallowed_methods)] -pub async fn handle_verify_commit( - cmd: VerifyCommitCommand, - env_config: &EnvironmentConfig, -) -> Result<()> { +pub async fn handle_verify_commit(cmd: VerifyCommitCommand, ctx: &CliConfig) -> Result<()> { // KEL-native verification: the trust root is the replayed KEL + the `.auths/roots` // pin, not an allowlist. No `ssh-keygen` subprocess, no `allowed_signers`. - let auths_home = match auths_sdk::paths::auths_home() { + // Resolve the SAME storage root `init`/`sign` wrote to (--repo/AUTHS_REPO), so a + // freshly-signed commit verifies instead of dead-ending on `~/.auths`. + let auths_home = match auths_sdk::paths::resolve_registry_path(ctx.repo_path.clone()) { Ok(h) => h, - Err(e) => return handle_error(&cmd, 2, &format!("Could not locate ~/.auths: {e}")), + Err(e) => { + return handle_error( + &cmd, + 2, + &format!("Could not locate the auths registry: {e}"), + ); + } }; + let env_config = &ctx.env_config; // Read-only SDK context over the same global registry, for org-policy evaluation // (E1.1). No passphrase — loading a policy never decrypts keys. let sdk_ctx = @@ -242,6 +249,48 @@ pub async fn handle_verify_commit( output_results(&results) } +/// A signer's KEL could not be resolved from the local registry or a bundle +/// during commit verification. Coded so `auths error show` resolves it and the +/// message carries the fetch remedy — the common case is verifying a teammate's +/// commit before their `refs/auths/*` has been fetched. +#[derive(Debug, thiserror::Error)] +pub(crate) enum SignerKelError { + /// The signer's KEL is not in the local registry and no bundle supplied it. + #[error("signer's KEL for {did} is not available locally: {reason}")] + Unavailable { + /// The `did:keri:` whose KEL could not be resolved. + did: String, + /// The underlying resolution error, rendered for display. + reason: String, + }, +} + +impl AuthsErrorInfo for SignerKelError { + fn error_code(&self) -> &'static str { + match self { + Self::Unavailable { .. } => "AUTHS-E6301", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::Unavailable { .. } => Some( + "Fetch the signer's KEL with `git fetch 'refs/auths/*:refs/auths/*'`, \ + or verify against an evidence bundle with `--identity-bundle`.", + ), + } + } +} + +impl SignerKelError { + /// Render this error as a single verify-result line carrying its code and remedy. + fn into_message(self) -> String { + let code = self.error_code(); + let suggestion = self.suggestion().unwrap_or_default(); + format!("[{code}] {self}. {suggestion}") + } +} + /// A bundle's authenticated KEL: the identity DID it self-certifies to plus the /// KEL events it carries. Built once by the `--identity-bundle` path and threaded /// into [`resolve_signer_kel`] so a stateless run (no identity store) can satisfy a @@ -458,7 +507,11 @@ async fn verify_one_commit( Err(e) => { return VerifyCommitResult::failure( sha, - format!("Device KEL for {device_did} could not be resolved: {e}"), + SignerKelError::Unavailable { + did: device_did.clone(), + reason: e, + } + .into_message(), ); } }; @@ -481,7 +534,11 @@ async fn verify_one_commit( Err(e) => { return VerifyCommitResult::failure( sha, - format!("Root KEL for {root_did} could not be resolved: {e}"), + SignerKelError::Unavailable { + did: root_did.clone(), + reason: e, + } + .into_message(), ); } }; @@ -734,7 +791,11 @@ fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResu result.error = Some("No signature found".to_string()); } CommitVerdict::GpgUnsupported => { - result.error = Some("GPG signatures not supported, use SSH signing".to_string()); + result.error = Some( + "GPG signatures are not verified by Auths — run `auths init` to sign with \ + did:keri commit trailers instead." + .to_string(), + ); } CommitVerdict::SshSignatureInvalid => { result.ssh_valid = Some(false); @@ -1051,7 +1112,7 @@ fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Res impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config)) + rt.block_on(handle_verify_commit(self.clone(), ctx)) } } @@ -1206,4 +1267,24 @@ mod tests { assert!(text.contains("INVALID")); assert!(text.contains("No signature found")); } + + #[test] + fn absent_signer_kel_message_carries_code_and_fetch_remedy() { + // A teammate's commit whose KEL is not local must name both the code (so it + // is lookupable) and the `git fetch refs/auths/*` remedy that resolves it. + let msg = SignerKelError::Unavailable { + did: "did:keri:Eteammate".into(), + reason: "KEL not found".into(), + } + .into_message(); + assert!( + msg.contains("AUTHS-E6301"), + "message must carry the code: {msg}" + ); + assert!( + msg.contains("git fetch"), + "message must name the fetch remedy: {msg}" + ); + assert!(msg.contains("refs/auths/*")); + } } diff --git a/crates/auths-cli/src/errors/registry.rs b/crates/auths-cli/src/errors/registry.rs index 78dc465a0..67f37e75d 100644 --- a/crates/auths-cli/src/errors/registry.rs +++ b/crates/auths-cli/src/errors/registry.rs @@ -26,10 +26,10 @@ pub fn explain(code: &str) -> Option<&'static str> { match code { // --- auths-crypto (CryptoError) --- "AUTHS-E1001" => Some("# AUTHS-E1001\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::InvalidSignature`\n\n## Message\n\nInvalid signature\n\n## Suggestion\n\nThe signature does not match the data or public key\n"), - "AUTHS-E1002" => Some("# AUTHS-E1002\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::InvalidKeyLength`\n\n## Message\n\nInvalid public key length: expected {expected}, got {actual}\n"), + "AUTHS-E1002" => Some("# AUTHS-E1002\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::InvalidKeyLength`\n\n## Message\n\nInvalid public key length: expected {expected}, got {actual}\n\n## Suggestion\n\nEnsure the key length matches the declared curve (32 bytes Ed25519, 33 bytes P-256 compressed SEC1)\n"), "AUTHS-E1003" => Some("# AUTHS-E1003\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::InvalidPrivateKey`\n\n## Message\n\nInvalid private key: {0}\n"), "AUTHS-E1004" => Some("# AUTHS-E1004\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::OperationFailed`\n\n## Message\n\nCrypto operation failed: {0}\n"), - "AUTHS-E1005" => Some("# AUTHS-E1005\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::UnsupportedTarget`\n\n## Message\n\nOperation not supported on current compilation target\n"), + "AUTHS-E1005" => Some("# AUTHS-E1005\n\n**Crate:** `auths-crypto`\n\n**Type:** `CryptoError::UnsupportedTarget`\n\n## Message\n\nOperation not supported on current compilation target\n\n## Suggestion\n\nThis operation is not available on the current platform\n"), // --- auths-crypto (DidKeyError) --- "AUTHS-E1101" => Some("# AUTHS-E1101\n\n**Crate:** `auths-crypto`\n\n**Type:** `DidKeyError::InvalidPrefix`\n\n## Message\n\nDID must start with 'did:key:z', got: {0}\n\n## Suggestion\n\nDID must start with 'did:key:z'\n"), @@ -38,105 +38,105 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E1104" => Some("# AUTHS-E1104\n\n**Crate:** `auths-crypto`\n\n**Type:** `DidKeyError::InvalidKeyLength`\n\n## Message\n\nInvalid key length: got {0} bytes\n"), // --- auths-keri (KeriDecodeError) --- - "AUTHS-E1201" => Some("# AUTHS-E1201\n\n**Crate:** `auths-keri`\n\n**Type:** `KeriDecodeError::UnsupportedKeyType`\n\n## Message\n\nUnsupported KERI key type: prefix '{0}'\n"), + "AUTHS-E1201" => Some("# AUTHS-E1201\n\n**Crate:** `auths-keri`\n\n**Type:** `KeriDecodeError::UnsupportedKeyType`\n\n## Message\n\nUnsupported KERI key type: prefix '{0}'\n\n## Suggestion\n\nSupported verkey prefixes: 'D'/'B' (Ed25519 transferable/non-transferable), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).\n"), "AUTHS-E1202" => Some("# AUTHS-E1202\n\n**Crate:** `auths-keri`\n\n**Type:** `KeriDecodeError::EmptyInput`\n\n## Message\n\nMissing KERI prefix: empty string\n\n## Suggestion\n\nProvide a non-empty KERI-encoded key string\n"), "AUTHS-E1203" => Some("# AUTHS-E1203\n\n**Crate:** `auths-keri`\n\n**Type:** `KeriDecodeError::DecodeError`\n\n## Message\n\nBase64url decode failed: {0}\n"), "AUTHS-E1204" => Some("# AUTHS-E1204\n\n**Crate:** `auths-keri`\n\n**Type:** `KeriDecodeError::InvalidLength`\n\n## Message\n\nInvalid key length: expected {expected} bytes, got {actual}\n"), // --- auths-crypto (SshKeyError) --- "AUTHS-E1301" => Some("# AUTHS-E1301\n\n**Crate:** `auths-crypto`\n\n**Type:** `SshKeyError::InvalidFormat`\n\n## Message\n\nMalformed or invalid OpenSSH public key: {0}\n\n## Suggestion\n\nCheck that the public key is a valid OpenSSH format\n"), - "AUTHS-E1302" => Some("# AUTHS-E1302\n\n**Crate:** `auths-crypto`\n\n**Type:** `SshKeyError::UnsupportedKeyType`\n\n## Message\n\nUnsupported key type: expected ssh-ed25519 or ecdsa-sha2-nistp256\n"), + "AUTHS-E1302" => Some("# AUTHS-E1302\n\n**Crate:** `auths-crypto`\n\n**Type:** `SshKeyError::UnsupportedKeyType`\n\n## Message\n\nUnsupported key type: expected ssh-ed25519 or ecdsa-sha2-nistp256\n\n## Suggestion\n\nSupported key types: ssh-ed25519, ecdsa-sha2-nistp256\n"), // --- auths-verifier (AttestationError) --- - "AUTHS-E2001" => Some("# AUTHS-E2001\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::IssuerSignatureFailed`\n\n## Message\n\nIssuer signature verification failed: {0}\n"), + "AUTHS-E2001" => Some("# AUTHS-E2001\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::IssuerSignatureFailed`\n\n## Message\n\nIssuer signature verification failed: {0}\n\n## Suggestion\n\nVerify the attestation was signed with the correct issuer key\n"), "AUTHS-E2002" => Some("# AUTHS-E2002\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DeviceSignatureFailed`\n\n## Message\n\nDevice signature verification failed: {0}\n\n## Suggestion\n\nVerify the device key matches the attestation\n"), "AUTHS-E2003" => Some("# AUTHS-E2003\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::AttestationExpired`\n\n## Message\n\nAttestation expired on {at}\n\n## Suggestion\n\nRequest a new attestation from the issuer\n"), - "AUTHS-E2004" => Some("# AUTHS-E2004\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::AttestationRevoked`\n\n## Message\n\nAttestation revoked\n"), + "AUTHS-E2004" => Some("# AUTHS-E2004\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::AttestationRevoked`\n\n## Message\n\nAttestation revoked\n\n## Suggestion\n\nThis device has been revoked; contact the identity admin\n"), "AUTHS-E2005" => Some("# AUTHS-E2005\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::TimestampInFuture`\n\n## Message\n\nAttestation timestamp {at} is in the future\n\n## Suggestion\n\nCheck system clock synchronization\n"), - "AUTHS-E2006" => Some("# AUTHS-E2006\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::MissingCapability`\n\n## Message\n\nMissing required capability: required {required:?}, available {available:?}\n"), - "AUTHS-E2007" => Some("# AUTHS-E2007\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::SigningError`\n\n## Message\n\nSigning failed: {0}\n"), + "AUTHS-E2006" => Some("# AUTHS-E2006\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::MissingCapability`\n\n## Message\n\nMissing required capability: required {required:?}, available {available:?}\n\n## Suggestion\n\nRequest an attestation with the required capability\n"), + "AUTHS-E2007" => Some("# AUTHS-E2007\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::SigningError`\n\n## Message\n\nSigning failed: {0}\n\n## Suggestion\n\nThe cryptographic signing operation failed; verify key material is valid\n"), "AUTHS-E2008" => Some("# AUTHS-E2008\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DidResolutionError`\n\n## Message\n\nDID resolution failed: {0}\n\n## Suggestion\n\nCheck that the DID is valid and resolvable\n"), - "AUTHS-E2009" => Some("# AUTHS-E2009\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::SerializationError`\n\n## Message\n\nSerialization error: {0}\n"), - "AUTHS-E2010" => Some("# AUTHS-E2010\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InputTooLarge`\n\n## Message\n\nInput too large: {0}\n"), - "AUTHS-E2011" => Some("# AUTHS-E2011\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InvalidInput`\n\n## Message\n\nInvalid input: {0}\n"), - "AUTHS-E2012" => Some("# AUTHS-E2012\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::CryptoError`\n\n## Message\n\nCrypto error: {0}\n"), - "AUTHS-E2013" => Some("# AUTHS-E2013\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InternalError`\n\n## Message\n\nInternal error: {0}\n"), - "AUTHS-E2014" => Some("# AUTHS-E2014\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgVerificationFailed`\n\n## Message\n\nOrganizational Attestation verification failed: {0}\n"), - "AUTHS-E2015" => Some("# AUTHS-E2015\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgAttestationExpired`\n\n## Message\n\nOrganizational Attestation expired\n"), - "AUTHS-E2016" => Some("# AUTHS-E2016\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgDidResolutionFailed`\n\n## Message\n\nOrganizational DID resolution failed: {0}\n"), - "AUTHS-E2017" => Some("# AUTHS-E2017\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::BundleExpired`\n\n## Message\n\nBundle is {age_secs}s old (max {max_secs}s). Refresh with: auths id export-bundle\n"), - "AUTHS-E2018" => Some("# AUTHS-E2018\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::AttestationTooOld`\n\n## Message\n\nAttestation is {age_secs}s old (max {max_secs}s)\n"), + "AUTHS-E2009" => Some("# AUTHS-E2009\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::SerializationError`\n\n## Message\n\nSerialization error: {0}\n\n## Suggestion\n\nFailed to serialize/deserialize attestation data; check JSON format\n"), + "AUTHS-E2010" => Some("# AUTHS-E2010\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InputTooLarge`\n\n## Message\n\nInput too large: {0}\n\n## Suggestion\n\nReduce the size of the JSON input or split into smaller batches\n"), + "AUTHS-E2011" => Some("# AUTHS-E2011\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InvalidInput`\n\n## Message\n\nInvalid input: {0}\n\n## Suggestion\n\nCheck the input parameters and ensure they match the expected format\n"), + "AUTHS-E2012" => Some("# AUTHS-E2012\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::CryptoError`\n\n## Message\n\nCrypto error: {0}\n\n## Suggestion\n\nA cryptographic operation failed; verify key material is valid\n"), + "AUTHS-E2013" => Some("# AUTHS-E2013\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::InternalError`\n\n## Message\n\nInternal error: {0}\n\n## Suggestion\n\nAn unexpected internal error occurred; please report this issue\n"), + "AUTHS-E2014" => Some("# AUTHS-E2014\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgVerificationFailed`\n\n## Message\n\nOrganizational Attestation verification failed: {0}\n\n## Suggestion\n\nVerify organizational identity is properly configured\n"), + "AUTHS-E2015" => Some("# AUTHS-E2015\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgAttestationExpired`\n\n## Message\n\nOrganizational Attestation expired\n\n## Suggestion\n\nRequest a new organizational attestation from the admin\n"), + "AUTHS-E2016" => Some("# AUTHS-E2016\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::OrgDidResolutionFailed`\n\n## Message\n\nOrganizational DID resolution failed: {0}\n\n## Suggestion\n\nCheck that the organization's DID is correctly configured\n"), + "AUTHS-E2017" => Some("# AUTHS-E2017\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::BundleExpired`\n\n## Message\n\nBundle is {age_secs}s old (max {max_secs}s). Refresh with: auths id export-bundle\n\n## Suggestion\n\nRe-export the bundle: auths id export-bundle --alias --output bundle.json --max-age-secs \n"), + "AUTHS-E2018" => Some("# AUTHS-E2018\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::AttestationTooOld`\n\n## Message\n\nAttestation is {age_secs}s old (max {max_secs}s)\n\n## Suggestion\n\nRequest a fresh attestation or increase the max_age threshold\n"), "AUTHS-E2019" => Some("# AUTHS-E2019\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::CapabilityEscalation`\n\n## Message\n\nDelegated attestation escalates capability beyond its delegator\n"), "AUTHS-E2020" => Some("# AUTHS-E2020\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DelegationOutlivesParent`\n\n## Message\n\nDelegated attestation outlives its delegator\n"), "AUTHS-E2021" => Some("# AUTHS-E2021\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DelegatorRevoked`\n\n## Message\n\nDelegator attestation is revoked\n"), - "AUTHS-E2022" => Some("# AUTHS-E2022\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DelegatorUnresolved`\n\n## Message\n\nDelegator attestation could not be resolved\n"), + "AUTHS-E2022" => Some("# AUTHS-E2022\n\n**Crate:** `auths-verifier`\n\n**Type:** `AttestationError::DelegatorUnresolved`\n\n## Message\n\nDelegator attestation could not be resolved\n\n## Suggestion\n\nRe-issue the delegated attestation within the delegator's capability and validity scope\n"), // --- auths-verifier (CommitVerificationError) --- - "AUTHS-E2101" => Some("# AUTHS-E2101\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnsignedCommit`\n\n## Message\n\ncommit is unsigned\n\n## Suggestion\n\nSign commits with: git commit -S\n"), - "AUTHS-E2102" => Some("# AUTHS-E2102\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::GpgNotSupported`\n\n## Message\n\nGPG signatures not supported, use SSH signing\n\n## Suggestion\n\nConfigure SSH signing: git config gpg.format ssh\n"), - "AUTHS-E2103" => Some("# AUTHS-E2103\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::SshSigParseFailed`\n\n## Message\n\nSSHSIG parse failed: {0}\n"), - "AUTHS-E2104" => Some("# AUTHS-E2104\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnsupportedKeyType`\n\n## Message\n\nunsupported SSH key type: {found}\n"), - "AUTHS-E2105" => Some("# AUTHS-E2105\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::NamespaceMismatch`\n\n## Message\n\nnamespace mismatch: expected \\\"{expected}\\\", found \\\"{found}\\\"\n"), - "AUTHS-E2106" => Some("# AUTHS-E2106\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::HashAlgorithmUnsupported`\n\n## Message\n\nunsupported hash algorithm: {0}\n"), - "AUTHS-E2107" => Some("# AUTHS-E2107\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::SignatureInvalid`\n\n## Message\n\nsignature verification failed\n"), - "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-E2101" => Some("# AUTHS-E2101\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnsignedCommit`\n\n## Message\n\ncommit is unsigned\n\n## Suggestion\n\nThis commit has no Auths-Id/Auths-Device trailer. Run `auths init` so the prepare-commit-msg hook signs future commits, or backfill with `auths sign `.\n"), + "AUTHS-E2102" => Some("# AUTHS-E2102\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::GpgNotSupported`\n\n## Message\n\nGPG signatures are not verified by Auths — use did:keri trailers via `auths init`\n\n## Suggestion\n\nAuths verifies its own did:keri commit trailers, not GPG or SSH signatures. Run `auths init` to enable Auths signing.\n"), + "AUTHS-E2103" => Some("# AUTHS-E2103\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::SshSigParseFailed`\n\n## Message\n\nSSHSIG parse failed: {0}\n\n## Suggestion\n\nThe SSH signature could not be parsed; verify the commit was signed correctly\n"), + "AUTHS-E2104" => Some("# AUTHS-E2104\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnsupportedKeyType`\n\n## Message\n\nunsupported SSH key type: {found}\n\n## Suggestion\n\nUse an Ed25519 or ECDSA P-256 SSH key for signing\n"), + "AUTHS-E2105" => Some("# AUTHS-E2105\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::NamespaceMismatch`\n\n## Message\n\nnamespace mismatch: expected \\\"{expected}\\\", found \\\"{found}\\\"\n\n## Suggestion\n\nThe signature namespace doesn't match; ensure git config gpg.ssh.defaultKeyCommand is set correctly\n"), + "AUTHS-E2106" => Some("# AUTHS-E2106\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::HashAlgorithmUnsupported`\n\n## Message\n\nunsupported hash algorithm: {0}\n\n## Suggestion\n\nUse SHA-256 or SHA-512 hash algorithm for signing\n"), + "AUTHS-E2107" => Some("# AUTHS-E2107\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::SignatureInvalid`\n\n## Message\n\nsignature verification failed\n\n## Suggestion\n\nThe commit signature does not match the signed data; the commit may have been modified after signing\n"), + "AUTHS-E2108" => Some("# AUTHS-E2108\n\n**Crate:** `auths-verifier`\n\n**Type:** `CommitVerificationError::UnknownSigner`\n\n## Message\n\nsigner identity is not trusted (no matching pinned root)\n\n## Suggestion\n\nThe signer's identity is not trusted here. Pin it with `auths trust pin --did `, or add it to .auths/roots.\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\n## Suggestion\n\nThe Git commit object is malformed; check repository integrity with `git fsck`\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-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\n## Suggestion\n\nThe bundle was modified after it was produced; obtain a fresh, untampered bundle\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\n## Suggestion\n\nThe bundle is incomplete; re-produce it with `auths org bundle`\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-E2204" => Some("# AUTHS-E2204\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::Canonicalize`\n\n## Message\n\ncanonicalization failed: {0}\n\n## Suggestion\n\nThe file is not a valid air-gapped org bundle; re-export it\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-E2206" => Some("# AUTHS-E2206\n\n**Crate:** `auths-verifier`\n\n**Type:** `OrgBundleError::RecordInvalid`\n\n## Message\n\noffboarding record invalid: {0}\n\n## Suggestion\n\nThe off-boarding record does not match the org KEL; obtain a fresh bundle from the org\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-E2301" => Some("# AUTHS-E2301\n\n**Crate:** `auths-verifier`\n\n**Type:** `EvidencePackError::Canonicalize`\n\n## Message\n\ncanonicalization failed: {0}\n\n## Suggestion\n\nThe file is not a valid evidence pack; re-export it with `auths compliance report`\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-E2303" => Some("# AUTHS-E2303\n\n**Crate:** `auths-verifier`\n\n**Type:** `EvidencePackError::OfflineVerification`\n\n## Message\n\noffline verification failed: {0}\n\n## Suggestion\n\nThe pack failed offline verification; obtain a fresh, untampered pack from the org\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"), - "AUTHS-E3003" => Some("# AUTHS-E3003\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::MissingPassphrase`\n\n## Message\n\nMissing Passphrase\n"), - "AUTHS-E3004" => Some("# AUTHS-E3004\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::SecurityError`\n\n## Message\n\nSecurity error: {0}\n"), - "AUTHS-E3005" => Some("# AUTHS-E3005\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::CryptoError`\n\n## Message\n\nCrypto error: {0}\n"), - "AUTHS-E3006" => Some("# AUTHS-E3006\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::KeyDeserializationError`\n\n## Message\n\nKey deserialization error: {0}\n"), - "AUTHS-E3007" => Some("# AUTHS-E3007\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::SigningFailed`\n\n## Message\n\nSigning failed: {0}\n"), - "AUTHS-E3008" => Some("# AUTHS-E3008\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::Proto`\n\n## Message\n\nProtocol error: {0}\n"), + "AUTHS-E3002" => Some("# AUTHS-E3002\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::IncorrectPassphrase`\n\n## Message\n\nIncorrect passphrase\n\n## Suggestion\n\nCheck your passphrase and try again. Set AUTHS_PASSPHRASE for automation, or run `auths agent start` for session caching\n"), + "AUTHS-E3003" => Some("# AUTHS-E3003\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::MissingPassphrase`\n\n## Message\n\nMissing Passphrase\n\n## Suggestion\n\nProvide a passphrase with --passphrase or set AUTHS_PASSPHRASE\n"), + "AUTHS-E3004" => Some("# AUTHS-E3004\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::SecurityError`\n\n## Message\n\nSecurity error: {0}\n\n## Suggestion\n\nRun `auths doctor` to check system keychain access and security configuration\n"), + "AUTHS-E3005" => Some("# AUTHS-E3005\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::CryptoError`\n\n## Message\n\nCrypto error: {0}\n\n## Suggestion\n\nA cryptographic operation failed; check key material with `auths key list`\n"), + "AUTHS-E3006" => Some("# AUTHS-E3006\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::KeyDeserializationError`\n\n## Message\n\nKey deserialization error: {0}\n\n## Suggestion\n\nThe stored key is corrupted; re-import with `auths key import`\n"), + "AUTHS-E3007" => Some("# AUTHS-E3007\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::SigningFailed`\n\n## Message\n\nSigning failed: {0}\n\n## Suggestion\n\nThe signing operation failed; verify your key is accessible with `auths key list`\n"), + "AUTHS-E3008" => Some("# AUTHS-E3008\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::Proto`\n\n## Message\n\nProtocol error: {0}\n\n## Suggestion\n\nA protocol error occurred; check that both sides are running compatible versions\n"), "AUTHS-E3009" => Some("# AUTHS-E3009\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::IO`\n\n## Message\n\nIO error: {0}\n\n## Suggestion\n\nCheck file permissions and that the filesystem is not read-only\n"), "AUTHS-E3010" => Some("# AUTHS-E3010\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::GitError`\n\n## Message\n\ngit error: {0}\n\n## Suggestion\n\nEnsure you're in a Git repository\n"), "AUTHS-E3011" => Some("# AUTHS-E3011\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::InvalidInput`\n\n## Message\n\nInvalid input: {0}\n\n## Suggestion\n\nCheck the command arguments and try again\n"), "AUTHS-E3012" => Some("# AUTHS-E3012\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::MutexError`\n\n## Message\n\nMutex lock poisoned: {0}\n\n## Suggestion\n\nA concurrency error occurred; restart the operation\n"), "AUTHS-E3013" => Some("# AUTHS-E3013\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::StorageError`\n\n## Message\n\nStorage error: {0}\n\n## Suggestion\n\nCheck file permissions and disk space\n"), - "AUTHS-E3014" => Some("# AUTHS-E3014\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::UserInputCancelled`\n\n## Message\n\nUser input cancelled\n"), - "AUTHS-E3015" => Some("# AUTHS-E3015\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::BackendUnavailable`\n\n## Message\n\nKeychain backend unavailable: {backend} - {reason}\n"), + "AUTHS-E3014" => Some("# AUTHS-E3014\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::UserInputCancelled`\n\n## Message\n\nUser input cancelled\n\n## Suggestion\n\nRun the command again and provide the required input\n"), + "AUTHS-E3015" => Some("# AUTHS-E3015\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::BackendUnavailable`\n\n## Message\n\nKeychain backend unavailable: {backend} - {reason}\n\n## Suggestion\n\nRun `auths doctor` to diagnose keychain issues\n"), "AUTHS-E3016" => Some("# AUTHS-E3016\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::StorageLocked`\n\n## Message\n\nStorage is locked, authentication required\n\n## Suggestion\n\nAuthenticate with your platform keychain\n"), - "AUTHS-E3017" => Some("# AUTHS-E3017\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::BackendInitFailed`\n\n## Message\n\nFailed to initialize keychain backend: {backend} - {error}\n"), - "AUTHS-E3018" => Some("# AUTHS-E3018\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::CredentialTooLarge`\n\n## Message\n\nCredential too large for backend (max {max_bytes} bytes, got {actual_bytes})\n"), - "AUTHS-E3019" => Some("# AUTHS-E3019\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::AgentLocked`\n\n## Message\n\nAgent is locked. Unlock with 'auths agent unlock' or restart the agent.\n"), - "AUTHS-E3020" => Some("# AUTHS-E3020\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::WeakPassphrase`\n\n## Message\n\nPassphrase too weak: {0}\n"), + "AUTHS-E3017" => Some("# AUTHS-E3017\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::BackendInitFailed`\n\n## Message\n\nFailed to initialize keychain backend: {backend} - {error}\n\n## Suggestion\n\nRun `auths doctor` to diagnose keychain issues\n"), + "AUTHS-E3018" => Some("# AUTHS-E3018\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::CredentialTooLarge`\n\n## Message\n\nCredential too large for backend (max {max_bytes} bytes, got {actual_bytes})\n\n## Suggestion\n\nReduce the credential size or use file-based storage with AUTHS_KEYCHAIN_BACKEND=file\n"), + "AUTHS-E3019" => Some("# AUTHS-E3019\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::AgentLocked`\n\n## Message\n\nAgent is locked. Unlock with 'auths agent unlock' or restart the agent.\n\n## Suggestion\n\nRun `auths agent unlock` or restart with `auths agent start`\n"), + "AUTHS-E3020" => Some("# AUTHS-E3020\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::WeakPassphrase`\n\n## Message\n\nPassphrase too weak: {0}\n\n## Suggestion\n\nUse at least 12 characters with uppercase, lowercase, and a digit or symbol\n"), "AUTHS-E3021" => Some("# AUTHS-E3021\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HsmPinLocked`\n\n## Message\n\nHSM PIN is locked — reset required\n\n## Suggestion\n\nReset the HSM PIN using your HSM vendor's admin tools\n"), "AUTHS-E3022" => Some("# AUTHS-E3022\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HsmDeviceRemoved`\n\n## Message\n\nHSM device removed\n\n## Suggestion\n\nReconnect the HSM device and try again\n"), "AUTHS-E3023" => Some("# AUTHS-E3023\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HsmSessionExpired`\n\n## Message\n\nHSM session expired\n\n## Suggestion\n\nRetry the operation — a new session will be opened\n"), - "AUTHS-E3024" => Some("# AUTHS-E3024\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HsmUnsupportedMechanism`\n\n## Message\n\nHSM does not support mechanism: {0}\n"), - "AUTHS-E3025" => Some("# AUTHS-E3025\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HardwareKeyNotExportable`\n\n## Message\n\nOperation '{operation}' requires a software-backed key; hardware-backed keys (e.g. Secure Enclave) cannot export raw material\n"), + "AUTHS-E3024" => Some("# AUTHS-E3024\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HsmUnsupportedMechanism`\n\n## Message\n\nHSM does not support mechanism: {0}\n\n## Suggestion\n\nCheck that your HSM supports Ed25519 (CKM_EDDSA)\n"), + "AUTHS-E3025" => Some("# AUTHS-E3025\n\n**Crate:** `auths-core`\n\n**Type:** `AgentError::HardwareKeyNotExportable`\n\n## Message\n\nOperation '{operation}' requires a software-backed key; hardware-backed keys (e.g. Secure Enclave) cannot export raw material\n\n## Suggestion\n\nUse a software-backed keychain backend for this operation, or re-initialize your identity without Secure Enclave\n"), // --- auths-core (TrustError) --- "AUTHS-E3101" => Some("# AUTHS-E3101\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::Io`\n\n## Message\n\nI/O error: {0}\n\n## Suggestion\n\nCheck disk space and file permissions\n"), - "AUTHS-E3102" => Some("# AUTHS-E3102\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::InvalidData`\n\n## Message\n\n{0}\n"), + "AUTHS-E3102" => Some("# AUTHS-E3102\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::InvalidData`\n\n## Message\n\n{0}\n\n## Suggestion\n\nThe trust store may be corrupted; delete and re-pin with `auths trust pin`\n"), "AUTHS-E3103" => Some("# AUTHS-E3103\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::NotFound`\n\n## Message\n\nnot found: {0}\n\n## Suggestion\n\nRun `auths trust list` to see pinned identities\n"), - "AUTHS-E3104" => Some("# AUTHS-E3104\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::Serialization`\n\n## Message\n\nserialization error: {0}\n"), + "AUTHS-E3104" => Some("# AUTHS-E3104\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::Serialization`\n\n## Message\n\nserialization error: {0}\n\n## Suggestion\n\nThe trust store data is corrupted; delete and re-pin with `auths trust pin`\n"), "AUTHS-E3105" => Some("# AUTHS-E3105\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::AlreadyExists`\n\n## Message\n\nalready exists: {0}\n\n## Suggestion\n\nRun `auths trust list` to see existing entries\n"), "AUTHS-E3106" => Some("# AUTHS-E3106\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::Lock`\n\n## Message\n\nlock acquisition failed: {0}\n\n## Suggestion\n\nCheck file permissions and try again\n"), "AUTHS-E3107" => Some("# AUTHS-E3107\n\n**Crate:** `auths-core`\n\n**Type:** `TrustError::PolicyRejected`\n\n## Message\n\npolicy rejected: {0}\n\n## Suggestion\n\nRun `auths trust pin` to pin this identity\n"), // --- auths-core (PairingError) --- "AUTHS-E3201" => Some("# AUTHS-E3201\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::Protocol`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nEnsure both devices are running compatible auths versions\n"), - "AUTHS-E3202" => Some("# AUTHS-E3202\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::QrCodeFailed`\n\n## Message\n\nQR code generation failed: {0}\n"), + "AUTHS-E3202" => Some("# AUTHS-E3202\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::QrCodeFailed`\n\n## Message\n\nQR code generation failed: {0}\n\n## Suggestion\n\nQR code generation failed; try relay-based pairing with `auths device pair --registry ` instead\n"), "AUTHS-E3203" => Some("# AUTHS-E3203\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::RelayError`\n\n## Message\n\nRelay error: {0}\n\n## Suggestion\n\nCheck your internet connection\n"), - "AUTHS-E3204" => Some("# AUTHS-E3204\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::LocalServerError`\n\n## Message\n\nLocal server error: {0}\n"), - "AUTHS-E3205" => Some("# AUTHS-E3205\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::MdnsError`\n\n## Message\n\nmDNS error: {0}\n"), + "AUTHS-E3204" => Some("# AUTHS-E3204\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::LocalServerError`\n\n## Message\n\nLocal server error: {0}\n\n## Suggestion\n\nThe local pairing server failed to start; check that the port is available\n"), + "AUTHS-E3205" => Some("# AUTHS-E3205\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::MdnsError`\n\n## Message\n\nmDNS error: {0}\n\n## Suggestion\n\nmDNS discovery failed; try relay-based pairing with `auths device pair --registry ` instead\n"), "AUTHS-E3206" => Some("# AUTHS-E3206\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::NoPeerFound`\n\n## Message\n\nNo peer found on local network\n\n## Suggestion\n\nEnsure both devices are on the same network\n"), "AUTHS-E3207" => Some("# AUTHS-E3207\n\n**Crate:** `auths-core`\n\n**Type:** `PairingError::LanTimeout`\n\n## Message\n\nLAN pairing timed out\n\n## Suggestion\n\nCheck your network and try again\n"), @@ -149,7 +149,7 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-keri (WitnessError) --- "AUTHS-E3401" => Some("# AUTHS-E3401\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::Network`\n\n## Message\n\nnetwork error: {0}\n\n## Suggestion\n\nCheck your internet connection\n"), - "AUTHS-E3402" => Some("# AUTHS-E3402\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::Duplicity`\n\n## Message\n\nduplicity detected: {0}\n"), + "AUTHS-E3402" => Some("# AUTHS-E3402\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::Duplicity`\n\n## Message\n\nduplicity detected: {0}\n\n## Suggestion\n\nThis identity may be compromised — investigate immediately\n"), "AUTHS-E3403" => Some("# AUTHS-E3403\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::Rejected`\n\n## Message\n\nevent rejected: {reason}\n"), "AUTHS-E3404" => Some("# AUTHS-E3404\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::Timeout`\n\n## Message\n\ntimeout after {0}ms\n\n## Suggestion\n\nCheck witness endpoint availability and retry\n"), "AUTHS-E3405" => Some("# AUTHS-E3405\n\n**Crate:** `auths-keri`\n\n**Type:** `WitnessError::InvalidSignature`\n\n## Message\n\ninvalid receipt signature from witness {witness_id}\n"), @@ -161,34 +161,34 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-core (StorageError) --- "AUTHS-E3501" => Some("# AUTHS-E3501\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::NotFound`\n\n## Message\n\nnot found: {path}\n"), "AUTHS-E3502" => Some("# AUTHS-E3502\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::AlreadyExists`\n\n## Message\n\nalready exists: {path}\n"), - "AUTHS-E3503" => Some("# AUTHS-E3503\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::CasConflict`\n\n## Message\n\ncompare-and-swap conflict\n"), + "AUTHS-E3503" => Some("# AUTHS-E3503\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::CasConflict`\n\n## Message\n\ncompare-and-swap conflict\n\n## Suggestion\n\nRetry the operation — another process made a concurrent change\n"), "AUTHS-E3504" => Some("# AUTHS-E3504\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::Io`\n\n## Message\n\nstorage I/O error: {0}\n\n## Suggestion\n\nCheck file permissions and disk space\n"), "AUTHS-E3505" => Some("# AUTHS-E3505\n\n**Crate:** `auths-core`\n\n**Type:** `StorageError::Internal`\n\n## Message\n\ninternal storage error: {0}\n"), // --- auths-core (NetworkError) --- "AUTHS-E3601" => Some("# AUTHS-E3601\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::Unreachable`\n\n## Message\n\nendpoint unreachable: {endpoint}\n\n## Suggestion\n\nCheck your internet connection\n"), "AUTHS-E3602" => Some("# AUTHS-E3602\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::Timeout`\n\n## Message\n\nrequest timed out: {endpoint}\n\n## Suggestion\n\nThe server may be overloaded — retry later\n"), - "AUTHS-E3603" => Some("# AUTHS-E3603\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::NotFound`\n\n## Message\n\nresource not found: {resource}\n"), + "AUTHS-E3603" => Some("# AUTHS-E3603\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::NotFound`\n\n## Message\n\nresource not found: {resource}\n\n## Suggestion\n\nThe requested resource was not found on the server; verify the URL or identifier\n"), "AUTHS-E3604" => Some("# AUTHS-E3604\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::Unauthorized`\n\n## Message\n\nunauthorized\n\n## Suggestion\n\nCheck your authentication credentials\n"), - "AUTHS-E3605" => Some("# AUTHS-E3605\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::InvalidResponse`\n\n## Message\n\ninvalid response: {detail}\n"), - "AUTHS-E3606" => Some("# AUTHS-E3606\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::Internal`\n\n## Message\n\ninternal network error: {0}\n"), + "AUTHS-E3605" => Some("# AUTHS-E3605\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::InvalidResponse`\n\n## Message\n\ninvalid response: {detail}\n\n## Suggestion\n\nThe server returned an unexpected response; check server compatibility\n"), + "AUTHS-E3606" => Some("# AUTHS-E3606\n\n**Crate:** `auths-core`\n\n**Type:** `NetworkError::Internal`\n\n## Message\n\ninternal network error: {0}\n\n## Suggestion\n\nThe server encountered an internal error; retry later or contact the server administrator\n"), // --- auths-core (ResolutionError) --- "AUTHS-E3701" => Some("# AUTHS-E3701\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::DidNotFound`\n\n## Message\n\nDID not found: {did}\n\n## Suggestion\n\nVerify the DID is correct and the identity exists\n"), - "AUTHS-E3702" => Some("# AUTHS-E3702\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::InvalidDid`\n\n## Message\n\ninvalid DID {did}: {reason}\n"), - "AUTHS-E3703" => Some("# AUTHS-E3703\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::KeyRevoked`\n\n## Message\n\nkey revoked for DID: {did}\n"), + "AUTHS-E3702" => Some("# AUTHS-E3702\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::InvalidDid`\n\n## Message\n\ninvalid DID {did}: {reason}\n\n## Suggestion\n\nCheck the DID format (e.g., did:key:z6Mk... or did:keri:E...)\n"), + "AUTHS-E3703" => Some("# AUTHS-E3703\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::KeyRevoked`\n\n## Message\n\nkey revoked for DID: {did}\n\n## Suggestion\n\nThis key has been revoked — contact the identity owner\n"), "AUTHS-E3704" => Some("# AUTHS-E3704\n\n**Crate:** `auths-core`\n\n**Type:** `ResolutionError::Network`\n\n## Message\n\nnetwork error: {0}\n\n## Suggestion\n\nCheck your internet connection\n"), // --- auths-core (PlatformError) --- - "AUTHS-E3801" => Some("# AUTHS-E3801\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::AuthorizationPending`\n\n## Message\n\nOAuth authorization pending\n"), - "AUTHS-E3802" => Some("# AUTHS-E3802\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::SlowDown`\n\n## Message\n\nOAuth slow down\n"), + "AUTHS-E3801" => Some("# AUTHS-E3801\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::AuthorizationPending`\n\n## Message\n\nOAuth authorization pending\n\n## Suggestion\n\nComplete the authorization on the linked device, then the CLI will continue automatically\n"), + "AUTHS-E3802" => Some("# AUTHS-E3802\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::SlowDown`\n\n## Message\n\nOAuth slow down\n\n## Suggestion\n\nThe authorization server is rate-limiting; the CLI will retry automatically\n"), "AUTHS-E3803" => Some("# AUTHS-E3803\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::AccessDenied`\n\n## Message\n\nOAuth access denied\n\n## Suggestion\n\nRe-run the command and approve the authorization request\n"), "AUTHS-E3804" => Some("# AUTHS-E3804\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::ExpiredToken`\n\n## Message\n\ndevice code expired\n\n## Suggestion\n\nThe device code expired — restart the flow\n"), "AUTHS-E3805" => Some("# AUTHS-E3805\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::Network`\n\n## Message\n\nnetwork error: {0}\n\n## Suggestion\n\nCheck your internet connection\n"), - "AUTHS-E3806" => Some("# AUTHS-E3806\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::Platform`\n\n## Message\n\nplatform error: {message}\n"), + "AUTHS-E3806" => Some("# AUTHS-E3806\n\n**Crate:** `auths-core`\n\n**Type:** `PlatformError::Platform`\n\n## Message\n\nplatform error: {message}\n\n## Suggestion\n\nA platform-specific error occurred; run `auths doctor` to diagnose\n"), // --- auths-core (SshAgentError) --- - "AUTHS-E3901" => Some("# AUTHS-E3901\n\n**Crate:** `auths-core`\n\n**Type:** `SshAgentError::CommandFailed`\n\n## Message\n\nssh-add command failed: {0}\n"), + "AUTHS-E3901" => Some("# AUTHS-E3901\n\n**Crate:** `auths-core`\n\n**Type:** `SshAgentError::CommandFailed`\n\n## Message\n\nssh-add command failed: {0}\n\n## Suggestion\n\nCheck that the key file exists and has correct permissions\n"), "AUTHS-E3902" => Some("# AUTHS-E3902\n\n**Crate:** `auths-core`\n\n**Type:** `SshAgentError::NotAvailable`\n\n## Message\n\nSSH agent not available: {0}\n\n## Suggestion\n\nStart the SSH agent: eval $(ssh-agent -s)\n"), "AUTHS-E3903" => Some("# AUTHS-E3903\n\n**Crate:** `auths-core`\n\n**Type:** `SshAgentError::IoError`\n\n## Message\n\nI/O error: {0}\n\n## Suggestion\n\nCheck file permissions\n"), @@ -197,24 +197,24 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E3952" => Some("# AUTHS-E3952\n\n**Crate:** `auths-core`\n\n**Type:** `ConfigStoreError::Write`\n\n## Message\n\nfailed to write config to {path}\n\n## Suggestion\n\nCheck file permissions for ~/.auths/config.toml\n"), // --- auths-core (NamespaceVerifyError) --- - "AUTHS-E3961" => Some("# AUTHS-E3961\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::UnsupportedEcosystem`\n\n## Message\n\nunsupported ecosystem: {ecosystem}\n"), - "AUTHS-E3962" => Some("# AUTHS-E3962\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::PackageNotFound`\n\n## Message\n\npackage '{package_name}' not found in {ecosystem}\n"), - "AUTHS-E3963" => Some("# AUTHS-E3963\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::OwnershipNotConfirmed`\n\n## Message\n\nownership of '{package_name}' on {ecosystem} not confirmed for the given identity\n"), + "AUTHS-E3961" => Some("# AUTHS-E3961\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::UnsupportedEcosystem`\n\n## Message\n\nunsupported ecosystem: {ecosystem}\n\n## Suggestion\n\nSupported ecosystems: npm, pypi, cargo, docker, go, maven, nuget\n"), + "AUTHS-E3962" => Some("# AUTHS-E3962\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::PackageNotFound`\n\n## Message\n\npackage '{package_name}' not found in {ecosystem}\n\n## Suggestion\n\nCheck the package name and ensure it exists on the registry\n"), + "AUTHS-E3963" => Some("# AUTHS-E3963\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::OwnershipNotConfirmed`\n\n## Message\n\nownership of '{package_name}' on {ecosystem} not confirmed for the given identity\n\n## Suggestion\n\nEnsure you are listed as an owner/collaborator on the upstream registry\n"), "AUTHS-E3964" => Some("# AUTHS-E3964\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::ChallengeExpired`\n\n## Message\n\nverification challenge expired\n\n## Suggestion\n\nStart a new verification challenge\n"), - "AUTHS-E3965" => Some("# AUTHS-E3965\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::InvalidToken`\n\n## Message\n\ninvalid verification token: {reason}\n"), - "AUTHS-E3966" => Some("# AUTHS-E3966\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::InvalidPackageName`\n\n## Message\n\ninvalid package name '{name}': {reason}\n"), + "AUTHS-E3965" => Some("# AUTHS-E3965\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::InvalidToken`\n\n## Message\n\ninvalid verification token: {reason}\n\n## Suggestion\n\nTokens must start with 'auths-verify-' followed by a hex string\n"), + "AUTHS-E3966" => Some("# AUTHS-E3966\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::InvalidPackageName`\n\n## Message\n\ninvalid package name '{name}': {reason}\n\n## Suggestion\n\nPackage names cannot be empty, contain control characters, or use path traversal\n"), "AUTHS-E3967" => Some("# AUTHS-E3967\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::NetworkError`\n\n## Message\n\nverification network error: {message}\n\n## Suggestion\n\nCheck your internet connection and try again\n"), "AUTHS-E3968" => Some("# AUTHS-E3968\n\n**Crate:** `auths-core`\n\n**Type:** `NamespaceVerifyError::RateLimited`\n\n## Message\n\nrate limited by {ecosystem} registry\n\n## Suggestion\n\nWait a moment and retry the verification\n"), // --- auths-id (FreezeError) --- "AUTHS-E4001" => Some("# AUTHS-E4001\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::Io`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nCheck file permissions and disk space\n"), - "AUTHS-E4002" => Some("# AUTHS-E4002\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::Deserialization`\n\n## Message\n\nfailed to parse freeze state: {0}\n"), - "AUTHS-E4003" => Some("# AUTHS-E4003\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::InvalidDuration`\n\n## Message\n\ninvalid duration format: {0}\n"), + "AUTHS-E4002" => Some("# AUTHS-E4002\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::Deserialization`\n\n## Message\n\nfailed to parse freeze state: {0}\n\n## Suggestion\n\nThe freeze state file may be corrupted; try deleting it\n"), + "AUTHS-E4003" => Some("# AUTHS-E4003\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::InvalidDuration`\n\n## Message\n\ninvalid duration format: {0}\n\n## Suggestion\n\nUse a valid duration format (e.g. '30m', '2h', '7d')\n"), "AUTHS-E4004" => Some("# AUTHS-E4004\n\n**Crate:** `auths-id`\n\n**Type:** `FreezeError::ZeroDuration`\n\n## Message\n\nduration must be greater than zero\n\n## Suggestion\n\nSpecify a positive duration\n"), // --- auths-id (StorageError) --- "AUTHS-E4101" => Some("# AUTHS-E4101\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Git`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nCheck that the Git repository is not corrupted\n"), - "AUTHS-E4102" => Some("# AUTHS-E4102\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Serialization`\n\n## Message\n\nserialization error: {0}\n"), + "AUTHS-E4102" => Some("# AUTHS-E4102\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Serialization`\n\n## Message\n\nserialization error: {0}\n\n## Suggestion\n\nFailed to serialize storage data; this may indicate a version mismatch\n"), "AUTHS-E4103" => Some("# AUTHS-E4103\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Io`\n\n## Message\n\nI/O error: {0}\n\n## Suggestion\n\nCheck file permissions and disk space\n"), "AUTHS-E4104" => Some("# AUTHS-E4104\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::NotFound`\n\n## Message\n\nnot found: {0}\n\n## Suggestion\n\nVerify the identity or resource exists\n"), "AUTHS-E4105" => Some("# AUTHS-E4105\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::InvalidData`\n\n## Message\n\n{0}\n\n## Suggestion\n\nThe stored data may be corrupted; try re-initializing\n"), @@ -224,12 +224,12 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-id (InitError) --- "AUTHS-E4201" => Some("# AUTHS-E4201\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Git`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nCheck that the Git repository is accessible\n"), "AUTHS-E4202" => Some("# AUTHS-E4202\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Keri`\n\n## Message\n\nKERI operation failed: {0}\n\n## Suggestion\n\nKERI event processing failed; check identity state\n"), - "AUTHS-E4203" => Some("# AUTHS-E4203\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Key`\n\n## Message\n\nkey operation failed: {0}\n\n## Suggestion\n\nCheck keychain access and passphrase\n"), - "AUTHS-E4204" => Some("# AUTHS-E4204\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::InvalidData`\n\n## Message\n\n{0}\n"), + "AUTHS-E4203" => Some("# AUTHS-E4203\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Key`\n\n## Message\n\nkey operation failed: {0}\n\n## Suggestion\n\nCheck keychain access and passphrase. Headless/CI (no Touch ID): set AUTHS_KEYCHAIN_BACKEND=file AUTHS_KEYCHAIN_FILE= AUTHS_PASSPHRASE=, or run `auths init --profile ci`.\n"), + "AUTHS-E4204" => Some("# AUTHS-E4204\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::InvalidData`\n\n## Message\n\n{0}\n\n## Suggestion\n\nIdentity data is malformed; try re-initializing with `auths init`\n"), "AUTHS-E4205" => Some("# AUTHS-E4205\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Storage`\n\n## Message\n\nstorage operation failed: {0}\n\n## Suggestion\n\nCheck storage backend connectivity\n"), "AUTHS-E4206" => Some("# AUTHS-E4206\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Registry`\n\n## Message\n\nregistry error: {0}\n\n## Suggestion\n\nCheck registry backend configuration\n"), - "AUTHS-E4207" => Some("# AUTHS-E4207\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Crypto`\n\n## Message\n\ncrypto operation failed: {0}\n"), - "AUTHS-E4208" => Some("# AUTHS-E4208\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Identity`\n\n## Message\n\nidentity error: {0}\n"), + "AUTHS-E4207" => Some("# AUTHS-E4207\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Crypto`\n\n## Message\n\ncrypto operation failed: {0}\n\n## Suggestion\n\nA cryptographic operation during initialization failed; check your keychain access\n"), + "AUTHS-E4208" => Some("# AUTHS-E4208\n\n**Crate:** `auths-id`\n\n**Type:** `InitError::Identity`\n\n## Message\n\nidentity error: {0}\n\n## Suggestion\n\nIdentity initialization failed; check storage and keychain configuration\n"), // --- auths-id (IdentityError) --- "AUTHS-E4401" => Some("# AUTHS-E4401\n\n**Crate:** `auths-id`\n\n**Type:** `IdentityError::Keri`\n\n## Message\n\nKERI error: {0}\n\n## Suggestion\n\nKERI operation failed; check identity state\n"), @@ -243,8 +243,8 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-id (StorageError) --- "AUTHS-E4409" => Some("# AUTHS-E4409\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::NotFound`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nVerify the storage path exists and is initialized\n"), - "AUTHS-E4410" => Some("# AUTHS-E4410\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::CasConflict`\n\n## Message\n\n_(transparent — see inner error)_\n"), - "AUTHS-E4411" => Some("# AUTHS-E4411\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Io`\n\n## Message\n\n_(transparent — see inner error)_\n"), + "AUTHS-E4410" => Some("# AUTHS-E4410\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::CasConflict`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nA concurrent modification was detected; retry the operation\n"), + "AUTHS-E4411" => Some("# AUTHS-E4411\n\n**Crate:** `auths-id`\n\n**Type:** `StorageError::Io`\n\n## Message\n\n_(transparent — see inner error)_\n\n## Suggestion\n\nCheck file permissions, disk space, and storage backend connectivity\n"), // --- auths-id (KelError) --- "AUTHS-E4601" => Some("# AUTHS-E4601\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::Git`\n\n## Message\n\nGit error: {0}\n\n## Suggestion\n\nCheck that the Git repository is accessible and not corrupted\n"), @@ -252,7 +252,7 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4603" => Some("# AUTHS-E4603\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::NotFound`\n\n## Message\n\nKEL not found for prefix: {0}\n\n## Suggestion\n\nInitialize the identity first with 'auths init'\n"), "AUTHS-E4604" => Some("# AUTHS-E4604\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::InvalidOperation`\n\n## Message\n\nInvalid operation: {0}\n"), "AUTHS-E4605" => Some("# AUTHS-E4605\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::InvalidData`\n\n## Message\n\nInvalid data: {0}\n\n## Suggestion\n\nThe KEL data may be corrupted; try re-syncing\n"), - "AUTHS-E4606" => Some("# AUTHS-E4606\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::ChainIntegrity`\n\n## Message\n\nChain integrity error: {0}\n"), + "AUTHS-E4606" => Some("# AUTHS-E4606\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::ChainIntegrity`\n\n## Message\n\nChain integrity error: {0}\n\n## Suggestion\n\nThe KEL has non-linear history; this indicates tampering\n"), "AUTHS-E4607" => Some("# AUTHS-E4607\n\n**Crate:** `auths-id`\n\n**Type:** `KelError::ValidationFailed`\n\n## Message\n\nValidation failed: {0}\n"), // --- auths-id (ResolveError) --- @@ -269,8 +269,8 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4822" => Some("# AUTHS-E4822\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::Validation`\n\n## Message\n\nValidation error: {0}\n"), "AUTHS-E4823" => Some("# AUTHS-E4823\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::KeyGeneration`\n\n## Message\n\nKey generation failed: {0}\n"), "AUTHS-E4824" => Some("# AUTHS-E4824\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::InvalidKey`\n\n## Message\n\nInvalid key: {0}\n"), - "AUTHS-E4825" => Some("# AUTHS-E4825\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::CommitmentMismatch`\n\n## Message\n\nKey commitment mismatch\n"), - "AUTHS-E4826" => Some("# AUTHS-E4826\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::IdentityAbandoned`\n\n## Message\n\nIdentity is abandoned (empty next commitment)\n"), + "AUTHS-E4825" => Some("# AUTHS-E4825\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::CommitmentMismatch`\n\n## Message\n\nKey commitment mismatch\n\n## Suggestion\n\nThe provided key does not match the pre-committed next key. Use the key that was generated during initialization or the last rotation.\n"), + "AUTHS-E4826" => Some("# AUTHS-E4826\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::IdentityAbandoned`\n\n## Message\n\nIdentity is abandoned (empty next commitment)\n\n## Suggestion\n\nThis identity has been permanently abandoned. Create a new identity with 'auths init'.\n"), "AUTHS-E4827" => Some("# AUTHS-E4827\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::Serialization`\n\n## Message\n\nSerialization error: {0}\n"), "AUTHS-E4828" => Some("# AUTHS-E4828\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::Storage`\n\n## Message\n\nStorage error: {0}\n"), "AUTHS-E4829" => Some("# AUTHS-E4829\n\n**Crate:** `auths-id`\n\n**Type:** `RotationError::RotationFailed`\n\n## Message\n\nRotation failed: {0}\n"), @@ -279,7 +279,7 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-id (TenantIdError) --- "AUTHS-E4851" => Some("# AUTHS-E4851\n\n**Crate:** `auths-id`\n\n**Type:** `TenantIdError::InvalidLength`\n\n## Message\n\nmust be 1–64 characters (got {0})\n\n## Suggestion\n\nTenant ID must be between 1 and 64 characters\n"), - "AUTHS-E4852" => Some("# AUTHS-E4852\n\n**Crate:** `auths-id`\n\n**Type:** `TenantIdError::InvalidCharacter`\n\n## Message\n\ncontains disallowed character {0:?} (only [a-z0-9_-] allowed)\n"), + "AUTHS-E4852" => Some("# AUTHS-E4852\n\n**Crate:** `auths-id`\n\n**Type:** `TenantIdError::InvalidCharacter`\n\n## Message\n\ncontains disallowed character {0:?} (only [a-z0-9_-] allowed)\n\n## Suggestion\n\nOnly lowercase letters, digits, hyphens, and underscores are allowed\n"), "AUTHS-E4853" => Some("# AUTHS-E4853\n\n**Crate:** `auths-id`\n\n**Type:** `TenantIdError::Reserved`\n\n## Message\n\n'{0}' is reserved\n\n## Suggestion\n\nChoose a different tenant ID; this name is reserved\n"), // --- auths-id (RegistryError) --- @@ -290,15 +290,15 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4865" => Some("# AUTHS-E4865\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::SequenceGap`\n\n## Message\n\nSequence gap for {prefix}: expected {expected}, got {got}\n\n## Suggestion\n\nEvents must be appended in strict sequence order\n"), "AUTHS-E4866" => Some("# AUTHS-E4866\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::NotFound`\n\n## Message\n\nNot found: {entity_type} '{id}'\n"), "AUTHS-E4867" => Some("# AUTHS-E4867\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::Serialization`\n\n## Message\n\nSerialization error: {0}\n"), - "AUTHS-E4868" => Some("# AUTHS-E4868\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::ConcurrentModification`\n\n## Message\n\nConcurrent modification: {0}\n"), + "AUTHS-E4868" => Some("# AUTHS-E4868\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::ConcurrentModification`\n\n## Message\n\nConcurrent modification: {0}\n\n## Suggestion\n\nRetry the operation; another process modified the registry\n"), "AUTHS-E4869" => Some("# AUTHS-E4869\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::SaidMismatch`\n\n## Message\n\nSAID mismatch: expected {expected}, got {actual}\n\n## Suggestion\n\nThe event content does not match its declared SAID\n"), "AUTHS-E4870" => Some("# AUTHS-E4870\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::InvalidEvent`\n\n## Message\n\nInvalid event: {reason}\n"), "AUTHS-E4871" => Some("# AUTHS-E4871\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::Io`\n\n## Message\n\nI/O error: {0}\n\n## Suggestion\n\nCheck file permissions and disk space\n"), "AUTHS-E4872" => Some("# AUTHS-E4872\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::Internal`\n\n## Message\n\nInternal error: {0}\n"), "AUTHS-E4873" => Some("# AUTHS-E4873\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::InvalidTenantId`\n\n## Message\n\ninvalid tenant ID '{tenant_id}': {kind}\n"), "AUTHS-E4874" => Some("# AUTHS-E4874\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::Attestation`\n\n## Message\n\nAttestation error: {0}\n"), - "AUTHS-E4875" => Some("# AUTHS-E4875\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::StaleAttestation`\n\n## Message\n\nStale attestation: {0}\n"), - "AUTHS-E4876" => Some("# AUTHS-E4876\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::NotImplemented`\n\n## Message\n\nNot implemented: {method}\n"), + "AUTHS-E4875" => Some("# AUTHS-E4875\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::StaleAttestation`\n\n## Message\n\nStale attestation: {0}\n\n## Suggestion\n\nThe attestation has been superseded by a newer version\n"), + "AUTHS-E4876" => Some("# AUTHS-E4876\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::NotImplemented`\n\n## Message\n\nNot implemented: {method}\n\n## Suggestion\n\nThis operation is not supported by the current backend\n"), "AUTHS-E4877" => Some("# AUTHS-E4877\n\n**Crate:** `auths-id`\n\n**Type:** `RegistryError::BatchValidationFailed`\n\n## Message\n\nBatch validation failed at index {index}: {source}\n"), // --- auths-id (InceptionError) --- @@ -307,15 +307,15 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4903" => Some("# AUTHS-E4903\n\n**Crate:** `auths-id`\n\n**Type:** `InceptionError::Storage`\n\n## Message\n\nStorage error: {0}\n\n## Suggestion\n\nCheck storage backend connectivity\n"), "AUTHS-E4904" => Some("# AUTHS-E4904\n\n**Crate:** `auths-id`\n\n**Type:** `InceptionError::Validation`\n\n## Message\n\nValidation error: {0}\n"), "AUTHS-E4905" => Some("# AUTHS-E4905\n\n**Crate:** `auths-id`\n\n**Type:** `InceptionError::Serialization`\n\n## Message\n\nSerialization error: {0}\n"), - "AUTHS-E4906" => Some("# AUTHS-E4906\n\n**Crate:** `auths-id`\n\n**Type:** `InceptionError::InvalidThreshold`\n\n## Message\n\nInvalid threshold {threshold} for key_count={key_count}: {reason}\n"), + "AUTHS-E4906" => Some("# AUTHS-E4906\n\n**Crate:** `auths-id`\n\n**Type:** `InceptionError::InvalidThreshold`\n\n## Message\n\nInvalid threshold {threshold} for key_count={key_count}: {reason}\n\n## Suggestion\n\nEnsure the threshold count does not exceed the number of keys, and that weighted clauses have one weight per key summing to at least 1\n"), // --- auths-id (IncrementalError) --- "AUTHS-E4951" => Some("# AUTHS-E4951\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::Kel`\n\n## Message\n\nKEL error: {0}\n"), - "AUTHS-E4952" => Some("# AUTHS-E4952\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::ChainContinuity`\n\n## Message\n\nChain continuity error: expected previous SAID {expected}, got {actual}\n"), - "AUTHS-E4953" => Some("# AUTHS-E4953\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::SequenceError`\n\n## Message\n\nSequence error: expected {expected}, got {actual}\n"), + "AUTHS-E4952" => Some("# AUTHS-E4952\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::ChainContinuity`\n\n## Message\n\nChain continuity error: expected previous SAID {expected}, got {actual}\n\n## Suggestion\n\nThe KEL chain is broken; clear the cache and retry\n"), + "AUTHS-E4953" => Some("# AUTHS-E4953\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::SequenceError`\n\n## Message\n\nSequence error: expected {expected}, got {actual}\n\n## Suggestion\n\nThe KEL has sequence gaps; re-sync from a trusted source\n"), "AUTHS-E4954" => Some("# AUTHS-E4954\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::MalformedSequence`\n\n## Message\n\nMalformed sequence number: {raw:?}\n"), "AUTHS-E4955" => Some("# AUTHS-E4955\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::InvalidEventType`\n\n## Message\n\nInvalid event type in KEL: {0}\n"), - "AUTHS-E4956" => Some("# AUTHS-E4956\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::NonLinearHistory`\n\n## Message\n\nKEL history is non-linear: commit {commit} has {parent_count} parents (expected 1)\n"), + "AUTHS-E4956" => Some("# AUTHS-E4956\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::NonLinearHistory`\n\n## Message\n\nKEL history is non-linear: commit {commit} has {parent_count} parents (expected 1)\n\n## Suggestion\n\nThe KEL has merge commits, indicating tampering\n"), "AUTHS-E4957" => Some("# AUTHS-E4957\n\n**Crate:** `auths-id`\n\n**Type:** `IncrementalError::MissingParent`\n\n## Message\n\nKEL history is corrupted: commit {commit} has no parent but is not inception\n\n## Suggestion\n\nThe KEL commit history is corrupted\n"), // --- auths-id (AnchorError) --- @@ -324,25 +324,25 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4963" => Some("# AUTHS-E4963\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::Serialization`\n\n## Message\n\nSerialization error: {0}\n"), "AUTHS-E4964" => Some("# AUTHS-E4964\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::InvalidDid`\n\n## Message\n\nInvalid DID format: {0}\n\n## Suggestion\n\nUse the format 'did:keri:E'\n"), "AUTHS-E4965" => Some("# AUTHS-E4965\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::NotFound`\n\n## Message\n\nKEL not found for prefix: {0}\n\n## Suggestion\n\nInitialize the identity first with 'auths init'\n"), - "AUTHS-E4966" => Some("# AUTHS-E4966\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::Signing`\n\n## Message\n\nSigning error: {0}\n"), - "AUTHS-E4967" => Some("# AUTHS-E4967\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::IxnForbidden`\n\n## Message\n\nIdentity cannot emit interaction events: {0}\n"), - "AUTHS-E4968" => Some("# AUTHS-E4968\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::WitnessQuorumNotMet`\n\n## Message\n\nWitness quorum not met: {0}\n"), + "AUTHS-E4966" => Some("# AUTHS-E4966\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::Signing`\n\n## Message\n\nSigning error: {0}\n\n## Suggestion\n\nCheck that the key alias exists and the passphrase is correct\n"), + "AUTHS-E4967" => Some("# AUTHS-E4967\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::IxnForbidden`\n\n## Message\n\nIdentity cannot emit interaction events: {0}\n\n## Suggestion\n\nDevice authorization requires a transferable identity (non-empty n[]) without establishment-only restriction (no EO in c[]). Create a new identity with auths init.\n"), + "AUTHS-E4968" => Some("# AUTHS-E4968\n\n**Crate:** `auths-id`\n\n**Type:** `AnchorError::WitnessQuorumNotMet`\n\n## Message\n\nWitness quorum not met: {0}\n\n## Suggestion\n\nCheck witness server connectivity and threshold configuration\n"), // --- auths-id (WitnessIntegrationError) --- - "AUTHS-E4971" => Some("# AUTHS-E4971\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::Collection`\n\n## Message\n\nReceipt collection failed: {0}\n"), + "AUTHS-E4971" => Some("# AUTHS-E4971\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::Collection`\n\n## Message\n\nReceipt collection failed: {0}\n\n## Suggestion\n\nCheck witness server connectivity and threshold configuration\n"), "AUTHS-E4972" => Some("# AUTHS-E4972\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::Storage`\n\n## Message\n\nReceipt storage failed: {0}\n\n## Suggestion\n\nCheck storage backend permissions\n"), "AUTHS-E4973" => Some("# AUTHS-E4973\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::Runtime`\n\n## Message\n\nTokio runtime error: {0}\n"), - "AUTHS-E4974" => Some("# AUTHS-E4974\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::QuorumNotMet`\n\n## Message\n\nwitness quorum not met: {valid} valid receipt(s), need {required}\n"), + "AUTHS-E4974" => Some("# AUTHS-E4974\n\n**Crate:** `auths-id`\n\n**Type:** `WitnessIntegrationError::QuorumNotMet`\n\n## Message\n\nwitness quorum not met: {valid} valid receipt(s), need {required}\n\n## Suggestion\n\nToo few witnesses returned a valid, verifiable receipt for this event\n"), // --- auths-id (CredentialRegistryError) --- - "AUTHS-E4981" => Some("# AUTHS-E4981\n\n**Crate:** `auths-id`\n\n**Type:** `CredentialRegistryError::ThresholdUnsupported`\n\n## Message\n\nissuer '{issuer}' is multi-signature (kt≥2); credential registry anchoring is single-author only\n"), + "AUTHS-E4981" => Some("# AUTHS-E4981\n\n**Crate:** `auths-id`\n\n**Type:** `CredentialRegistryError::ThresholdUnsupported`\n\n## Message\n\nissuer '{issuer}' is multi-signature (kt≥2); credential registry anchoring is single-author only\n\n## Suggestion\n\nCredential issuance currently requires a single-signature (kt=1) issuer\n"), "AUTHS-E4982" => Some("# AUTHS-E4982\n\n**Crate:** `auths-id`\n\n**Type:** `CredentialRegistryError::Tel`\n\n## Message\n\nTEL event error: {0}\n"), "AUTHS-E4983" => Some("# AUTHS-E4983\n\n**Crate:** `auths-id`\n\n**Type:** `CredentialRegistryError::Anchor`\n\n## Message\n\nKEL anchoring failed: {0}\n"), "AUTHS-E4984" => Some("# AUTHS-E4984\n\n**Crate:** `auths-id`\n\n**Type:** `CredentialRegistryError::Storage`\n\n## Message\n\nregistry storage error: {0}\n"), // --- auths-id (CacheError) --- - "AUTHS-E4986" => Some("# AUTHS-E4986\n\n**Crate:** `auths-id`\n\n**Type:** `CacheError::Io`\n\n## Message\n\nI/O error: {0}\n"), - "AUTHS-E4987" => Some("# AUTHS-E4987\n\n**Crate:** `auths-id`\n\n**Type:** `CacheError::Json`\n\n## Message\n\nJSON serialization error: {0}\n"), + "AUTHS-E4986" => Some("# AUTHS-E4986\n\n**Crate:** `auths-id`\n\n**Type:** `CacheError::Io`\n\n## Message\n\nI/O error: {0}\n\n## Suggestion\n\nCheck cache directory permissions; the cache is optional and can be cleared\n"), + "AUTHS-E4987" => Some("# AUTHS-E4987\n\n**Crate:** `auths-id`\n\n**Type:** `CacheError::Json`\n\n## Message\n\nJSON serialization error: {0}\n\n## Suggestion\n\nThe cache file may be corrupted; try clearing it with 'auths cache clear'\n"), "AUTHS-E4988" => Some("# AUTHS-E4988\n\n**Crate:** `auths-id`\n\n**Type:** `CacheError::InvalidDid`\n\n## Message\n\ninvalid DID: {0}\n\n## Suggestion\n\nThe DID must be a 'did:keri:' identity\n"), // --- auths-id (HookError) --- @@ -350,50 +350,50 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E4992" => Some("# AUTHS-E4992\n\n**Crate:** `auths-id`\n\n**Type:** `HookError::NotGitRepo`\n\n## Message\n\nNot a Git repository: {0}\n\n## Suggestion\n\nEnsure the path points to a valid Git repository\n"), // --- auths-sdk (SetupError) --- - "AUTHS-E5001" => Some("# AUTHS-E5001\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::IdentityAlreadyExists`\n\n## Message\n\nidentity already exists: {did}\n"), - "AUTHS-E5002" => Some("# AUTHS-E5002\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::KeychainUnavailable`\n\n## Message\n\nkeychain unavailable ({backend}): {reason}\n"), - "AUTHS-E5004" => Some("# AUTHS-E5004\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::GitConfigError`\n\n## Message\n\ngit config error: {0}\n"), - "AUTHS-E5006" => Some("# AUTHS-E5006\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::PlatformVerificationFailed`\n\n## Message\n\nplatform verification failed: {0}\n"), + "AUTHS-E5001" => Some("# AUTHS-E5001\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::IdentityAlreadyExists`\n\n## Message\n\nidentity already exists: {did}\n\n## Suggestion\n\nUse `auths id show` to inspect the existing identity\n"), + "AUTHS-E5002" => Some("# AUTHS-E5002\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::KeychainUnavailable`\n\n## Message\n\nkeychain unavailable ({backend}): {reason}\n\n## Suggestion\n\nRun `auths doctor` to diagnose keychain issues\n"), + "AUTHS-E5004" => Some("# AUTHS-E5004\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::GitConfigError`\n\n## Message\n\ngit config error: {0}\n\n## Suggestion\n\nEnsure Git is configured: git config --global user.name/email\n"), + "AUTHS-E5006" => Some("# AUTHS-E5006\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::PlatformVerificationFailed`\n\n## Message\n\nplatform verification failed: {0}\n\n## Suggestion\n\nPlatform identity verification failed; check your platform credentials and network connectivity\n"), "AUTHS-E5007" => Some("# AUTHS-E5007\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::InvalidSetupConfig`\n\n## Message\n\ninvalid setup config: {0}\n"), - "AUTHS-E5008" => Some("# AUTHS-E5008\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::WeakPassphrase`\n\n## Message\n\npassphrase from {source_name} is too weak: {reason}\n"), + "AUTHS-E5008" => Some("# AUTHS-E5008\n\n**Crate:** `auths-sdk`\n\n**Type:** `SetupError::WeakPassphrase`\n\n## Message\n\npassphrase from {source_name} is too weak: {reason}\n\n## Suggestion\n\nUse at least 12 characters with 3 of 4 character classes (lowercase, uppercase, digit, symbol)\n"), // --- auths-sdk (DeviceError) --- "AUTHS-E5101" => Some("# AUTHS-E5101\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::IdentityNotFound`\n\n## Message\n\nidentity not found: {did}\n\n## Suggestion\n\nRun `auths init` to create an identity first\n"), "AUTHS-E5102" => Some("# AUTHS-E5102\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::DeviceNotFound`\n\n## Message\n\ndevice not found: {did}\n\n## Suggestion\n\nRun `auths device list` to see linked devices\n"), - "AUTHS-E5103" => Some("# AUTHS-E5103\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::AttestationError`\n\n## Message\n\nattestation error: {0}\n"), + "AUTHS-E5103" => Some("# AUTHS-E5103\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::AttestationError`\n\n## Message\n\nattestation error: {0}\n\n## Suggestion\n\nThe attestation operation failed; run `auths device list` to check device status\n"), "AUTHS-E5105" => Some("# AUTHS-E5105\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::DeviceDidMismatch`\n\n## Message\n\ndevice DID mismatch: expected {expected}, got {actual}\n\n## Suggestion\n\nCheck that --device matches the key name\n"), - "AUTHS-E5106" => Some("# AUTHS-E5106\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::DelegationError`\n\n## Message\n\ndevice delegation failed: {0}\n"), + "AUTHS-E5106" => Some("# AUTHS-E5106\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceError::DelegationError`\n\n## Message\n\ndevice delegation failed: {0}\n\n## Suggestion\n\nThe device delegation could not be authored or anchored; check the root identity\n"), // --- auths-sdk (DeviceExtensionError) --- "AUTHS-E5201" => Some("# AUTHS-E5201\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::IdentityNotFound`\n\n## Message\n\nidentity not found\n\n## Suggestion\n\nRun `auths init` to create an identity first\n"), - "AUTHS-E5202" => Some("# AUTHS-E5202\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::NoAttestationFound`\n\n## Message\n\nno attestation found for device {device_did}\n"), - "AUTHS-E5203" => Some("# AUTHS-E5203\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::AlreadyRevoked`\n\n## Message\n\ndevice {device_did} is already revoked\n"), - "AUTHS-E5204" => Some("# AUTHS-E5204\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::AttestationFailed`\n\n## Message\n\nattestation creation failed: {0}\n"), + "AUTHS-E5202" => Some("# AUTHS-E5202\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::NoAttestationFound`\n\n## Message\n\nno attestation found for device {device_did}\n\n## Suggestion\n\nRun `auths device link` to create an attestation for this device\n"), + "AUTHS-E5203" => Some("# AUTHS-E5203\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::AlreadyRevoked`\n\n## Message\n\ndevice {device_did} is already revoked\n\n## Suggestion\n\nThis device has been revoked and cannot be extended; link a new device with `auths device link`\n"), + "AUTHS-E5204" => Some("# AUTHS-E5204\n\n**Crate:** `auths-sdk`\n\n**Type:** `DeviceExtensionError::AttestationFailed`\n\n## Message\n\nattestation creation failed: {0}\n\n## Suggestion\n\nFailed to create the extension attestation; check key access and try again\n"), // --- auths-sdk (RotationError) --- "AUTHS-E5301" => Some("# AUTHS-E5301\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::IdentityNotFound`\n\n## Message\n\nidentity not found at {path}\n\n## Suggestion\n\nRun `auths init` to create an identity first\n"), "AUTHS-E5302" => Some("# AUTHS-E5302\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::KeyNotFound`\n\n## Message\n\nkey not found: {0}\n\n## Suggestion\n\nRun `auths key list` to see available keys\n"), "AUTHS-E5303" => Some("# AUTHS-E5303\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::KeyDecryptionFailed`\n\n## Message\n\nkey decryption failed: {0}\n\n## Suggestion\n\nCheck your passphrase and try again\n"), "AUTHS-E5304" => Some("# AUTHS-E5304\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::KelHistoryFailed`\n\n## Message\n\nKEL history error: {0}\n\n## Suggestion\n\nRun `auths doctor` to check KEL integrity\n"), - "AUTHS-E5305" => Some("# AUTHS-E5305\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::RotationFailed`\n\n## Message\n\nrotation failed: {0}\n"), - "AUTHS-E5306" => Some("# AUTHS-E5306\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::PartialRotation`\n\n## Message\n\nrotation event committed to KEL but keychain write failed — manual recovery required: {0}\n"), - "AUTHS-E5307" => Some("# AUTHS-E5307\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::HardwareKeyNotRotatable`\n\n## Message\n\nrotation requires a software-backed key; alias '{alias}' is hardware-backed (Secure Enclave) and cannot export the raw key material rotation needs\n"), + "AUTHS-E5305" => Some("# AUTHS-E5305\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::RotationFailed`\n\n## Message\n\nrotation failed: {0}\n\n## Suggestion\n\nKey rotation failed; verify your current key is accessible with `auths key list`\n"), + "AUTHS-E5306" => Some("# AUTHS-E5306\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::PartialRotation`\n\n## Message\n\nrotation event committed to KEL but keychain write failed — manual recovery required: {0}\n\n## Suggestion\n\nRe-run the rotation with the same new key to complete the keychain write\n"), + "AUTHS-E5307" => Some("# AUTHS-E5307\n\n**Crate:** `auths-sdk`\n\n**Type:** `RotationError::HardwareKeyNotRotatable`\n\n## Message\n\nrotation requires a software-backed key; alias '{alias}' is hardware-backed (Secure Enclave) and cannot export the raw key material rotation needs\n\n## Suggestion\n\nHardware-backed keys (Secure Enclave / HSM) cannot be rotated in-place; provision a software-backed identity or rotate by creating a new identity\n"), // --- auths-sdk (AgentError) --- - "AUTHS-E5311" => Some("# AUTHS-E5311\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::IdentityNotFound`\n\n## Message\n\nidentity not found: {did}\n"), - "AUTHS-E5312" => Some("# AUTHS-E5312\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AlreadyDelegated`\n\n## Message\n\nan agent key already exists under alias '{alias}'\n"), - "AUTHS-E5313" => Some("# AUTHS-E5313\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::DelegationError`\n\n## Message\n\nagent delegation failed: {0}\n"), - "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-E5311" => Some("# AUTHS-E5311\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::IdentityNotFound`\n\n## Message\n\nidentity not found: {did}\n\n## Suggestion\n\nRun `auths init` to create a root identity first\n"), + "AUTHS-E5312" => Some("# AUTHS-E5312\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AlreadyDelegated`\n\n## Message\n\nan agent key already exists under alias '{alias}'\n\n## Suggestion\n\nAn agent already exists under this alias. Reuse it, rotate it with `auths id agent rotate`, or pass a fresh --label; `auths id agent list` shows existing agents.\n"), + "AUTHS-E5313" => Some("# AUTHS-E5313\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::DelegationError`\n\n## Message\n\nagent delegation failed: {0}\n\n## Suggestion\n\nThe agent delegation could not be authored or anchored; check the root identity\n"), + "AUTHS-E5314" => Some("# AUTHS-E5314\n\n**Crate:** `auths-sdk`\n\n**Type:** `AgentError::AgentNotFound`\n\n## Message\n\nagent not found: {did}\n\n## Suggestion\n\nRun `auths id agent list` to see the agents this identity has delegated\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\n## Suggestion\n\nThis agent was revoked and cannot be rotated; delegate a new agent instead\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\n## Suggestion\n\nNarrow the agent's --scope to a subset of the delegator's own capabilities\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\n## Suggestion\n\nThe delegation attestation could not be signed; check the keychain aliases\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\n## Suggestion\n\nThe delegation attestation could not be anchored; check the root identity's KEL\n"), // --- auths-sdk (RegistrationError) --- - "AUTHS-E5400" => Some("# AUTHS-E5400\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::NoRegistryConfigured`\n\n## Message\n\nno registry configured. Auths needs no registry: identity, signing and verification are local and git-native, and a signer's KEL reaches a verifier over the same git remote as the code. Pass --registry or set AUTHS_REGISTRY_URL only if you are running one.\n"), - "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"), + "AUTHS-E5400" => Some("# AUTHS-E5400\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::NoRegistryConfigured`\n\n## Message\n\nno registry configured. Auths needs no registry: identity, signing and verification are local and git-native, and a signer's KEL reaches a verifier over the same git remote as the code. Pass --registry or set AUTHS_REGISTRY_URL only if you are running one.\n\n## Suggestion\n\nRegistration is optional — signing and verification need no registry. Pass --registry or set AUTHS_REGISTRY_URL only if you run one\n"), + "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\n## Suggestion\n\nThis identity is already registered; use `auths id show` to see registration details\n"), "AUTHS-E5402" => Some("# AUTHS-E5402\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::QuotaExceeded`\n\n## Message\n\nregistration quota exceeded — try again later\n\n## Suggestion\n\nWait a few minutes and try again\n"), - "AUTHS-E5403" => Some("# AUTHS-E5403\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::InvalidDidFormat`\n\n## Message\n\ninvalid DID format: {did}\n"), + "AUTHS-E5403" => Some("# AUTHS-E5403\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::InvalidDidFormat`\n\n## Message\n\ninvalid DID format: {did}\n\n## Suggestion\n\nRun `auths doctor` to check local identity data\n"), "AUTHS-E5404" => Some("# AUTHS-E5404\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::IdentityLoadError`\n\n## Message\n\nidentity load error: {0}\n\n## Suggestion\n\nRun `auths doctor` to check local identity data\n"), "AUTHS-E5405" => Some("# AUTHS-E5405\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::RegistryReadError`\n\n## Message\n\nregistry read error: {0}\n\n## Suggestion\n\nRun `auths doctor` to check local identity data\n"), "AUTHS-E5406" => Some("# AUTHS-E5406\n\n**Crate:** `auths-sdk`\n\n**Type:** `RegistrationError::SerializationError`\n\n## Message\n\nserialization error: {0}\n\n## Suggestion\n\nRun `auths doctor` to check local identity data\n"), @@ -401,105 +401,109 @@ pub fn explain(code: &str) -> Option<&'static str> { // --- auths-sdk (McpAuthError) --- "AUTHS-E5501" => Some("# AUTHS-E5501\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::BridgeUnreachable`\n\n## Message\n\nbridge unreachable: {0}\n\n## Suggestion\n\nCheck network connectivity to the OIDC bridge\n"), "AUTHS-E5502" => Some("# AUTHS-E5502\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::TokenExchangeFailed`\n\n## Message\n\ntoken exchange failed (HTTP {status}): {body}\n\n## Suggestion\n\nVerify your credentials and try again\n"), - "AUTHS-E5503" => Some("# AUTHS-E5503\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::InvalidResponse`\n\n## Message\n\ninvalid response: {0}\n"), - "AUTHS-E5504" => Some("# AUTHS-E5504\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::InsufficientCapabilities`\n\n## Message\n\ninsufficient capabilities: requested {requested:?}\n"), + "AUTHS-E5503" => Some("# AUTHS-E5503\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::InvalidResponse`\n\n## Message\n\ninvalid response: {0}\n\n## Suggestion\n\nThe OIDC bridge returned an unexpected response; verify the bridge URL and try again\n"), + "AUTHS-E5504" => Some("# AUTHS-E5504\n\n**Crate:** `auths-sdk`\n\n**Type:** `McpAuthError::InsufficientCapabilities`\n\n## Message\n\ninsufficient capabilities: requested {requested:?}\n\n## Suggestion\n\nRequest fewer capabilities or contact your administrator\n"), // --- auths-sdk (TrustError) --- - "AUTHS-E5551" => Some("# AUTHS-E5551\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::UnknownIdentity`\n\n## Message\n\nUnknown identity '{did}' and trust policy is '{policy}'\n"), - "AUTHS-E5552" => Some("# AUTHS-E5552\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::KeyResolutionFailed`\n\n## Message\n\nFailed to resolve public key for identity {did}\n"), - "AUTHS-E5553" => Some("# AUTHS-E5553\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::InvalidTrustStore`\n\n## Message\n\nInvalid trust store: {0}\n"), - "AUTHS-E5554" => Some("# AUTHS-E5554\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::TofuRequiresInteraction`\n\n## Message\n\nTOFU trust decision required but running in non-interactive mode\n"), + "AUTHS-E5551" => Some("# AUTHS-E5551\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::UnknownIdentity`\n\n## Message\n\nUnknown identity '{did}' and trust policy is '{policy}'\n\n## Suggestion\n\nRun `auths trust pin --did ` or add the identity to .auths/roots.json\n"), + "AUTHS-E5552" => Some("# AUTHS-E5552\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::KeyResolutionFailed`\n\n## Message\n\nFailed to resolve public key for identity {did}\n\n## Suggestion\n\nVerify the identity exists and has a valid public key registered\n"), + "AUTHS-E5553" => Some("# AUTHS-E5553\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::InvalidTrustStore`\n\n## Message\n\nInvalid trust store: {0}\n\n## Suggestion\n\nCheck the format of your trust store (roots.json or ~/.auths/known_identities.json)\n"), + "AUTHS-E5554" => Some("# AUTHS-E5554\n\n**Crate:** `auths-sdk`\n\n**Type:** `TrustError::TofuRequiresInteraction`\n\n## Message\n\nTOFU trust decision required but running in non-interactive mode\n\n## Suggestion\n\nRun interactively (on a TTY), or pre-pin the identity with `auths trust pin` so no interactive decision is needed\n"), // --- auths-sdk (OrgError) --- - "AUTHS-E5601" => Some("# AUTHS-E5601\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::AdminNotFound`\n\n## Message\n\nno admin with the given public key found in organization '{org}'\n"), - "AUTHS-E5602" => Some("# AUTHS-E5602\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::MemberNotFound`\n\n## Message\n\nmember '{did}' not found in organization '{org}'\n"), - "AUTHS-E5603" => Some("# AUTHS-E5603\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::AlreadyRevoked`\n\n## Message\n\nmember '{did}' is already revoked\n"), - "AUTHS-E5604" => Some("# AUTHS-E5604\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::InvalidCapability`\n\n## Message\n\ninvalid capability '{cap}': {reason}\n"), + "AUTHS-E5601" => Some("# AUTHS-E5601\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::AdminNotFound`\n\n## Message\n\nno admin with the given public key found in organization '{org}'\n\n## Suggestion\n\nVerify you are using the correct admin key for this organization\n"), + "AUTHS-E5602" => Some("# AUTHS-E5602\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::MemberNotFound`\n\n## Message\n\nmember '{did}' not found in organization '{org}'\n\n## Suggestion\n\nRun `auths org list-members` to see current members\n"), + "AUTHS-E5603" => Some("# AUTHS-E5603\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::AlreadyRevoked`\n\n## Message\n\nmember '{did}' is already revoked\n\n## Suggestion\n\nThis member has already been revoked from the organization\n"), + "AUTHS-E5604" => Some("# AUTHS-E5604\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::InvalidCapability`\n\n## Message\n\ninvalid capability '{cap}': {reason}\n\n## Suggestion\n\nUse a valid capability (e.g., 'sign_commit', 'manage_members', 'admin')\n"), "AUTHS-E5605" => Some("# AUTHS-E5605\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::InvalidDid`\n\n## Message\n\ninvalid organization DID: {0}\n\n## Suggestion\n\nOrganization DIDs must be valid did:keri identifiers\n"), "AUTHS-E5606" => Some("# AUTHS-E5606\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::InvalidPublicKey`\n\n## Message\n\ninvalid public key: {0}\n\n## Suggestion\n\nPublic keys must be hex-encoded Ed25519 keys\n"), - "AUTHS-E5607" => Some("# AUTHS-E5607\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Signing`\n\n## Message\n\nsigning error: {0}\n"), - "AUTHS-E5608" => Some("# AUTHS-E5608\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Identity`\n\n## Message\n\nidentity error: {0}\n"), - "AUTHS-E5609" => Some("# AUTHS-E5609\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::KeyStorage`\n\n## Message\n\nkey storage error: {0}\n"), - "AUTHS-E5610" => Some("# AUTHS-E5610\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Storage`\n\n## Message\n\nstorage error: {0}\n"), + "AUTHS-E5607" => Some("# AUTHS-E5607\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Signing`\n\n## Message\n\nsigning error: {0}\n\n## Suggestion\n\nThe signing operation failed; check your key access with `auths key list`\n"), + "AUTHS-E5608" => Some("# AUTHS-E5608\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Identity`\n\n## Message\n\nidentity error: {0}\n\n## Suggestion\n\nFailed to load identity; run `auths id show` to check identity status\n"), + "AUTHS-E5609" => Some("# AUTHS-E5609\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::KeyStorage`\n\n## Message\n\nkey storage error: {0}\n\n## Suggestion\n\nFailed to access key storage; run `auths doctor` to diagnose\n"), + "AUTHS-E5610" => Some("# AUTHS-E5610\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Storage`\n\n## Message\n\nstorage error: {0}\n\n## Suggestion\n\nFailed to access organization storage; check repository permissions\n"), "AUTHS-E5611" => Some("# AUTHS-E5611\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Anchor`\n\n## Message\n\nanchor error: {0}\n\n## Suggestion\n\nKEL anchoring failed; check identity and registry state\n"), - "AUTHS-E5612" => Some("# AUTHS-E5612\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::OrgThresholdDelegationUnsupported`\n\n## Message\n\norganization '{org}' uses a multi-signature controller (kt≥2); KERI-native member delegation requires a single-signature (kt=1) org\n"), - "AUTHS-E5613" => Some("# AUTHS-E5613\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::MemberKeyExists`\n\n## Message\n\na member key already exists under alias '{alias}'\n"), - "AUTHS-E5614" => Some("# AUTHS-E5614\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Delegation`\n\n## Message\n\nmember delegation failed: {0}\n"), - "AUTHS-E5615" => Some("# AUTHS-E5615\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::IdentityExists`\n\n## Message\n\nan identity already exists at {location}; refusing to create an organization over it\n"), - "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-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-E5612" => Some("# AUTHS-E5612\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::OrgThresholdDelegationUnsupported`\n\n## Message\n\norganization '{org}' uses a multi-signature controller (kt≥2); KERI-native member delegation requires a single-signature (kt=1) org\n\n## Suggestion\n\nMulti-signature org anchoring is not yet supported; use a single-signature (kt=1) org\n"), + "AUTHS-E5613" => Some("# AUTHS-E5613\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::MemberKeyExists`\n\n## Message\n\na member key already exists under alias '{alias}'\n\n## Suggestion\n\nChoose a different member alias; run `auths org list-members` to see existing members\n"), + "AUTHS-E5614" => Some("# AUTHS-E5614\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::Delegation`\n\n## Message\n\nmember delegation failed: {0}\n\n## Suggestion\n\nThe member delegation could not be authored or anchored; check the org identity\n"), + "AUTHS-E5615" => Some("# AUTHS-E5615\n\n**Crate:** `auths-sdk`\n\n**Type:** `OrgError::IdentityExists`\n\n## Message\n\nan identity already exists at {location}; refusing to create an organization over it\n\n## Suggestion\n\nAn identity already exists here; use a fresh repository path to create a new organization\n"), + "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\n## Suggestion\n\nFailed to initialize the org identity; check key access and repository state\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\n## Suggestion\n\nFailed to sign the admin attestation; check your key access with `auths key list`\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\n## Suggestion\n\nThe member must first incept a delegated identity naming this org as delegator (pairing) before it can be off-boarded\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\n## Suggestion\n\nFix the policy JSON (a serialized `Expr`); see `auths org policy show` for the current policy\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\n## Suggestion\n\nThe policy blob is missing; re-anchor it with `auths org policy set`\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\n## Suggestion\n\nThe stored policy was modified after anchoring; re-anchor a trusted policy with `auths org policy set`\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\n## Suggestion\n\nThe delegation chain is malformed (a cycle); inspect the identifiers' KELs\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\n## Suggestion\n\nThe delegation chain is too deep; reduce delegation nesting\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\n## Suggestion\n\nA KEL in the delegation chain is missing; ensure all delegators' KELs are present\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\n## Suggestion\n\nFix the OIDC-subject policy JSON (issuer + repository, optional workflow_ref); nothing was anchored\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"), - "AUTHS-E5702" => Some("# AUTHS-E5702\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::RequestNotFound`\n\n## Message\n\napproval request not found: {hash}\n"), + "AUTHS-E5701" => Some("# AUTHS-E5701\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::NotApprovalRequired`\n\n## Message\n\ndecision is not RequiresApproval\n\n## Suggestion\n\nThis operation does not require approval; run it directly without the --approve flag\n"), + "AUTHS-E5702" => Some("# AUTHS-E5702\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::RequestNotFound`\n\n## Message\n\napproval request not found: {hash}\n\n## Suggestion\n\nRun `auths approval list` to see pending requests\n"), "AUTHS-E5703" => Some("# AUTHS-E5703\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::RequestExpired`\n\n## Message\n\napproval request expired at {expires_at}\n\n## Suggestion\n\nSubmit a new approval request\n"), "AUTHS-E5704" => Some("# AUTHS-E5704\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::ApprovalAlreadyUsed`\n\n## Message\n\napproval already used (JTI: {jti})\n\n## Suggestion\n\nSubmit a new approval request\n"), "AUTHS-E5705" => Some("# AUTHS-E5705\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::PartialApproval`\n\n## Message\n\napproval partially applied — attestation stored but nonce/cleanup failed: {0}\n\n## Suggestion\n\nCheck approval status and retry if needed\n"), "AUTHS-E5706" => Some("# AUTHS-E5706\n\n**Crate:** `auths-sdk`\n\n**Type:** `ApprovalError::ApprovalStorage`\n\n## Message\n\nstorage error: {0}\n\n## Suggestion\n\nCheck file permissions and disk space\n"), // --- auths-sdk (ArtifactSigningError) --- - "AUTHS-E5850" => Some("# AUTHS-E5850\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::IdentityNotFound`\n\n## Message\n\nidentity not found in configured identity storage\n"), - "AUTHS-E5851" => Some("# AUTHS-E5851\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyResolutionFailed`\n\n## Message\n\nkey resolution failed: {0}\n"), + "AUTHS-E5850" => Some("# AUTHS-E5850\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::IdentityNotFound`\n\n## Message\n\nidentity not found in configured identity storage\n\n## Suggestion\n\nRun `auths init` to create an identity, or `auths key import` to restore one\n"), + "AUTHS-E5851" => Some("# AUTHS-E5851\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyResolutionFailed`\n\n## Message\n\nkey resolution failed: {0}\n\n## Suggestion\n\nRun `auths status` to see available device aliases\n"), "AUTHS-E5852" => Some("# AUTHS-E5852\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyDecryptionFailed`\n\n## Message\n\nkey decryption failed: {0}\n\n## Suggestion\n\nCheck your passphrase and try again\n"), "AUTHS-E5853" => Some("# AUTHS-E5853\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::DigestFailed`\n\n## Message\n\ndigest computation failed: {0}\n\n## Suggestion\n\nVerify the file exists and is readable\n"), "AUTHS-E5854" => Some("# AUTHS-E5854\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::AttestationFailed`\n\n## Message\n\nattestation creation failed: {0}\n\n## Suggestion\n\nCheck identity storage with `auths status`\n"), - "AUTHS-E5855" => Some("# AUTHS-E5855\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::ResignFailed`\n\n## Message\n\nattestation re-signing failed: {0}\n"), - "AUTHS-E5856" => Some("# AUTHS-E5856\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::InvalidCommitSha`\n\n## Message\n\ninvalid commit SHA: {0} (expected 40 or 64 hex characters)\n"), - "AUTHS-E5857" => Some("# AUTHS-E5857\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::DeviceRevoked`\n\n## Message\n\ndevice revoked: {0}\n"), - "AUTHS-E5858" => Some("# AUTHS-E5858\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyRotatedOut`\n\n## Message\n\nsigning key rotated out of the KEL: {0}\n"), + "AUTHS-E5855" => Some("# AUTHS-E5855\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::ResignFailed`\n\n## Message\n\nattestation re-signing failed: {0}\n\n## Suggestion\n\nVerify your device key is accessible with `auths status`\n"), + "AUTHS-E5856" => Some("# AUTHS-E5856\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::InvalidCommitSha`\n\n## Message\n\ninvalid commit SHA: {0} (expected 40 or 64 hex characters)\n\n## Suggestion\n\nProvide a full SHA-1 (40 hex chars) or SHA-256 (64 hex chars) commit hash\n"), + "AUTHS-E5857" => Some("# AUTHS-E5857\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::DeviceRevoked`\n\n## Message\n\ndevice revoked: {0}\n\n## Suggestion\n\nThis device has been removed; pair a new device or sign from an active one\n"), + "AUTHS-E5858" => Some("# AUTHS-E5858\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyRotatedOut`\n\n## Message\n\nsigning key rotated out of the KEL: {0}\n\n## Suggestion\n\nThis key was rotated out; sign with the identity's current key\n"), // --- auths-sdk (SigningError) --- "AUTHS-E5901" => Some("# AUTHS-E5901\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::IdentityFrozen`\n\n## Message\n\nidentity is frozen: {0}\n\n## Suggestion\n\nTo unfreeze: auths emergency unfreeze\n"), "AUTHS-E5902" => Some("# AUTHS-E5902\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::KeyResolution`\n\n## Message\n\nkey resolution failed: {0}\n\n## Suggestion\n\nRun `auths key list` to check available keys\n"), - "AUTHS-E5903" => Some("# AUTHS-E5903\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::SigningFailed`\n\n## Message\n\nsigning operation failed: {0}\n"), + "AUTHS-E5903" => Some("# AUTHS-E5903\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::SigningFailed`\n\n## Message\n\nsigning operation failed: {0}\n\n## Suggestion\n\nThe signing operation failed; verify your key is accessible with `auths key list`\n"), "AUTHS-E5904" => Some("# AUTHS-E5904\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::InvalidPassphrase`\n\n## Message\n\ninvalid passphrase\n\n## Suggestion\n\nCheck your passphrase and try again\n"), - "AUTHS-E5905" => Some("# AUTHS-E5905\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::PemEncoding`\n\n## Message\n\nPEM encoding failed: {0}\n"), + "AUTHS-E5905" => Some("# AUTHS-E5905\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::PemEncoding`\n\n## Message\n\nPEM encoding failed: {0}\n\n## Suggestion\n\nFailed to encode the key in PEM format; the key material may be corrupted\n"), "AUTHS-E5906" => Some("# AUTHS-E5906\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::AgentUnavailable`\n\n## Message\n\nagent unavailable: {0}\n\n## Suggestion\n\nStart the agent with `auths agent start`\n"), "AUTHS-E5907" => Some("# AUTHS-E5907\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::AgentSigningFailed`\n\n## Message\n\nagent signing failed\n\n## Suggestion\n\nCheck agent logs with `auths agent status`\n"), - "AUTHS-E5908" => Some("# AUTHS-E5908\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::PassphraseExhausted`\n\n## Message\n\npassphrase exhausted after {attempts} attempt(s)\n"), + "AUTHS-E5908" => Some("# AUTHS-E5908\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::PassphraseExhausted`\n\n## Message\n\npassphrase exhausted after {attempts} attempt(s)\n\n## Suggestion\n\nThe passphrase you entered is incorrect (tried 3 times). Verify it matches what you set during init, or try: auths key export --key-alias --format pub\n"), "AUTHS-E5909" => Some("# AUTHS-E5909\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::KeychainUnavailable`\n\n## Message\n\nkeychain unavailable: {0}\n\n## Suggestion\n\nRun `auths doctor` to diagnose keychain issues\n"), "AUTHS-E5910" => Some("# AUTHS-E5910\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::KeyDecryptionFailed`\n\n## Message\n\nkey decryption failed: {0}\n\n## Suggestion\n\nCheck your passphrase and try again\n"), + "AUTHS-E5911" => Some("# AUTHS-E5911\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::KeyNotFound`\n\n## Message\n\nno signing key under alias '{alias}'\n\n## Suggestion\n\nRun `auths key list` to see available aliases, or `auths init` to create one\n"), // --- auths-sdk (AuthChallengeError) --- "AUTHS-E6001" => Some("# AUTHS-E6001\n\n**Crate:** `auths-sdk`\n\n**Type:** `AuthChallengeError::EmptyNonce`\n\n## Message\n\nnonce must not be empty\n\n## Suggestion\n\nProvide the nonce from the authentication challenge\n"), "AUTHS-E6002" => Some("# AUTHS-E6002\n\n**Crate:** `auths-sdk`\n\n**Type:** `AuthChallengeError::EmptyDomain`\n\n## Message\n\ndomain must not be empty\n\n## Suggestion\n\nProvide the domain (e.g. auths.dev)\n"), - "AUTHS-E6003" => Some("# AUTHS-E6003\n\n**Crate:** `auths-sdk`\n\n**Type:** `AuthChallengeError::Canonicalization`\n\n## Message\n\ncanonical JSON serialization failed: {0}\n"), + "AUTHS-E6003" => Some("# AUTHS-E6003\n\n**Crate:** `auths-sdk`\n\n**Type:** `AuthChallengeError::Canonicalization`\n\n## Message\n\ncanonical JSON serialization failed: {0}\n\n## Suggestion\n\nThis is an internal error; please report it as a bug\n"), // --- auths-sdk (CredentialError) --- - "AUTHS-E6101" => Some("# AUTHS-E6101\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::IssueeNotFound`\n\n## Message\n\nissuee identity not found (no KEL): {did}\n"), - "AUTHS-E6102" => Some("# AUTHS-E6102\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::RegistryError`\n\n## Message\n\ncredential registry error: {0}\n"), - "AUTHS-E6103" => Some("# AUTHS-E6103\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::AlreadyRevoked`\n\n## Message\n\ncredential already revoked: {said}\n"), - "AUTHS-E6104" => Some("# AUTHS-E6104\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::KtThresholdUnsupported`\n\n## Message\n\nissuer is multi-signature (kt≥2); credential anchoring is single-author only\n"), - "AUTHS-E6105" => Some("# AUTHS-E6105\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::SchemaUnknown`\n\n## Message\n\ncapability schema unknown or uncomputable\n"), - "AUTHS-E6106" => Some("# AUTHS-E6106\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::StaleOrUnresolvable`\n\n## Message\n\ncredential status is stale or unresolvable: {reason}\n"), - "AUTHS-E6107" => Some("# AUTHS-E6107\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::MalformedUsageCap`\n\n## Message\n\nmalformed quantitative usage cap '{cap}': a calls: capability must carry a non-negative integer bound (e.g. calls:3)\n"), + "AUTHS-E6101" => Some("# AUTHS-E6101\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::IssueeNotFound`\n\n## Message\n\nissuee identity not found (no KEL): {did}\n\n## Suggestion\n\nThe issuee must have an incepted identity (KEL) before it can be credentialed\n"), + "AUTHS-E6102" => Some("# AUTHS-E6102\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::RegistryError`\n\n## Message\n\ncredential registry error: {0}\n\n## Suggestion\n\nCheck the issuer identity and registry storage are reachable\n"), + "AUTHS-E6103" => Some("# AUTHS-E6103\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::AlreadyRevoked`\n\n## Message\n\ncredential already revoked: {said}\n\n## Suggestion\n\nThis credential is already revoked; no further action is needed\n"), + "AUTHS-E6104" => Some("# AUTHS-E6104\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::KtThresholdUnsupported`\n\n## Message\n\nissuer is multi-signature (kt≥2); credential anchoring is single-author only\n\n## Suggestion\n\nCredential issuance currently requires a single-signature (kt=1) issuer\n"), + "AUTHS-E6105" => Some("# AUTHS-E6105\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::SchemaUnknown`\n\n## Message\n\ncapability schema unknown or uncomputable\n\n## Suggestion\n\nThe compiled-in capability schema is unavailable; this is a build defect\n"), + "AUTHS-E6106" => Some("# AUTHS-E6106\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::StaleOrUnresolvable`\n\n## Message\n\ncredential status is stale or unresolvable: {reason}\n\n## Suggestion\n\nNo fresh witnessed tip was reachable; sync the issuer KEL/receipts and retry, or relax --require-witnesses\n"), + "AUTHS-E6107" => Some("# AUTHS-E6107\n\n**Crate:** `auths-sdk`\n\n**Type:** `CredentialError::MalformedUsageCap`\n\n## Message\n\nmalformed quantitative usage cap '{cap}': a calls: capability must carry a non-negative integer bound (e.g. calls:3)\n\n## Suggestion\n\nUse a quantitative cap with a non-negative integer bound, e.g. --cap calls:3\n"), + + // --- auths-cli (SignerKelError) --- + "AUTHS-E6301" => Some("# AUTHS-E6301\n\n**Crate:** `auths-cli`\n\n**Type:** `SignerKelError::Unavailable`\n\n## Message\n\nsigner's KEL for {did} is not available locally: {reason}\n\n## Suggestion\n\nFetch the signer's KEL with `git fetch 'refs/auths/*:refs/auths/*'`, or verify against an evidence bundle with `--identity-bundle`.\n"), // --- auths-oidc-port (OidcError) --- - "AUTHS-E8001" => Some("# AUTHS-E8001\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::JwtDecode`\n\n## Message\n\nJWT decode failed: {0}\n"), - "AUTHS-E8002" => Some("# AUTHS-E8002\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::SignatureVerificationFailed`\n\n## Message\n\nsignature verification failed\n"), - "AUTHS-E8003" => Some("# AUTHS-E8003\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::ClaimsValidationFailed`\n\n## Message\n\nclaim validation failed - {claim}: {reason}\n"), - "AUTHS-E8004" => Some("# AUTHS-E8004\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::UnknownKeyId`\n\n## Message\n\nunknown key ID: {0}\n"), - "AUTHS-E8005" => Some("# AUTHS-E8005\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::JwksResolutionFailed`\n\n## Message\n\nJWKS resolution failed: {0}\n"), - "AUTHS-E8006" => Some("# AUTHS-E8006\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::AlgorithmMismatch`\n\n## Message\n\nalgorithm mismatch: expected {expected}, got {got}\n"), - "AUTHS-E8007" => Some("# AUTHS-E8007\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::ClockSkewExceeded`\n\n## Message\n\ntoken expired (exp: {token_exp}, now: {current_time}, leeway: {leeway}s)\n"), - "AUTHS-E8008" => Some("# AUTHS-E8008\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::TokenReplayDetected`\n\n## Message\n\ntoken replay detected (jti: {0})\n"), + "AUTHS-E8001" => Some("# AUTHS-E8001\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::JwtDecode`\n\n## Message\n\nJWT decode failed: {0}\n\n## Suggestion\n\nVerify the token format and ensure it is a valid JWT\n"), + "AUTHS-E8002" => Some("# AUTHS-E8002\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::SignatureVerificationFailed`\n\n## Message\n\nsignature verification failed\n\n## Suggestion\n\nCheck that the JWKS endpoint is up-to-date and the token is from a trusted issuer\n"), + "AUTHS-E8003" => Some("# AUTHS-E8003\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::ClaimsValidationFailed`\n\n## Message\n\nclaim validation failed - {claim}: {reason}\n\n## Suggestion\n\nThe token has expired; acquire a new token from the OIDC provider\n"), + "AUTHS-E8004" => Some("# AUTHS-E8004\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::UnknownKeyId`\n\n## Message\n\nunknown key ID: {0}\n\n## Suggestion\n\nThe JWKS cache may be stale; refresh the JWKS from the issuer endpoint\n"), + "AUTHS-E8005" => Some("# AUTHS-E8005\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::JwksResolutionFailed`\n\n## Message\n\nJWKS resolution failed: {0}\n\n## Suggestion\n\nCheck network connectivity to the JWKS endpoint and ensure the issuer URL is correct\n"), + "AUTHS-E8006" => Some("# AUTHS-E8006\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::AlgorithmMismatch`\n\n## Message\n\nalgorithm mismatch: expected {expected}, got {got}\n\n## Suggestion\n\nVerify that the expected algorithm matches the algorithm used by the OIDC provider\n"), + "AUTHS-E8007" => Some("# AUTHS-E8007\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::ClockSkewExceeded`\n\n## Message\n\ntoken expired (exp: {token_exp}, now: {current_time}, leeway: {leeway}s)\n\n## Suggestion\n\nSynchronize the system clock or increase the configured clock skew tolerance\n"), + "AUTHS-E8008" => Some("# AUTHS-E8008\n\n**Crate:** `auths-oidc-port`\n\n**Type:** `OidcError::TokenReplayDetected`\n\n## Message\n\ntoken replay detected (jti: {0})\n\n## Suggestion\n\nA token with this ID has already been used; acquire a new token from the OIDC provider\n"), // --- auths-core (LogError) --- - "AUTHS-E9001" => Some("# AUTHS-E9001\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::SubmissionRejected`\n\n## Message\n\nsubmission rejected: {reason}\n"), + "AUTHS-E9001" => Some("# AUTHS-E9001\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::SubmissionRejected`\n\n## Message\n\nsubmission rejected: {reason}\n\n## Suggestion\n\nCheck the attestation format and payload size\n"), "AUTHS-E9002" => Some("# AUTHS-E9002\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::NetworkError`\n\n## Message\n\nnetwork error: {0}\n\n## Suggestion\n\nCheck your internet connection and the log's API URL\n"), "AUTHS-E9003" => Some("# AUTHS-E9003\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::RateLimited`\n\n## Message\n\nrate limited, retry after {retry_after_secs}s\n\n## Suggestion\n\nWait and retry; the log is rate-limiting requests\n"), - "AUTHS-E9004" => Some("# AUTHS-E9004\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::InvalidResponse`\n\n## Message\n\ninvalid response: {0}\n"), + "AUTHS-E9004" => Some("# AUTHS-E9004\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::InvalidResponse`\n\n## Message\n\ninvalid response: {0}\n\n## Suggestion\n\nThe log returned an unexpected response; check the log version\n"), "AUTHS-E9005" => Some("# AUTHS-E9005\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::EntryNotFound`\n\n## Message\n\nentry not found\n\n## Suggestion\n\nThe entry may not be sequenced yet; retry after a moment\n"), - "AUTHS-E9006" => Some("# AUTHS-E9006\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::ConsistencyViolation`\n\n## Message\n\nconsistency violation: {0}\n"), - "AUTHS-E9007" => Some("# AUTHS-E9007\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::Unavailable`\n\n## Message\n\nlog unavailable: {0}\n"), + "AUTHS-E9006" => Some("# AUTHS-E9006\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::ConsistencyViolation`\n\n## Message\n\nconsistency violation: {0}\n\n## Suggestion\n\nThe log returned data that does not match what was submitted\n"), + "AUTHS-E9007" => Some("# AUTHS-E9007\n\n**Crate:** `auths-core`\n\n**Type:** `LogError::Unavailable`\n\n## Message\n\nlog unavailable: {0}\n\n## Suggestion\n\nThe transparency log is unavailable; retry later or use --allow-unlogged\n"), _ => None, } @@ -855,6 +859,7 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E5908", "AUTHS-E5909", "AUTHS-E5910", + "AUTHS-E5911", "AUTHS-E6001", "AUTHS-E6002", "AUTHS-E6003", @@ -865,6 +870,7 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E6105", "AUTHS-E6106", "AUTHS-E6107", + "AUTHS-E6301", "AUTHS-E8001", "AUTHS-E8002", "AUTHS-E8003", @@ -914,6 +920,6 @@ mod tests { #[test] fn all_codes_count_matches_registry() { - assert_eq!(all_codes().len(), 371); + assert_eq!(all_codes().len(), 373); } } diff --git a/crates/auths-cli/src/errors/renderer.rs b/crates/auths-cli/src/errors/renderer.rs index ed33c4a0c..78cd51185 100644 --- a/crates/auths-cli/src/errors/renderer.rs +++ b/crates/auths-cli/src/errors/renderer.rs @@ -103,8 +103,10 @@ fn render_text(err: &Error) { if let Some(suggestion) = suggestion { eprintln!(" fix: {}", suggestion.blue()); } - if let Some(url) = docs_url(code) { - eprintln!(" docs: {url}"); + // The offline lookup is the real, working next step; the docs site's + // /errors/ path 404s, so it is not advertised until it is published. + if code.starts_with("AUTHS-E") { + eprintln!(" look up: auths error show {code}"); } return; } @@ -154,11 +156,20 @@ fn render_json(err: &Error) { &format!("{cli_err}"), Some(cli_err.suggestion()), cli_err.docs_url().map(|s| s.to_string()), + None, ) } else if let Some((code, message, suggestion)) = extract_error_info(err) { - build_json(Some(code), message, suggestion, docs_url(code)) + // Emit the offline lookup, not the 404 docs URL, as the machine-readable + // next step (mirrors the text renderer's `look up:` line). + build_json( + Some(code), + message, + suggestion, + docs_url(code), + lookup(code), + ) } else { - build_json(None, &format!("{err}"), None, None) + build_json(None, &format!("{err}"), None, None, None) }; eprintln!("{json}"); @@ -169,6 +180,7 @@ fn build_json( message: &str, suggestion: Option<&str>, docs: Option, + lookup: Option, ) -> String { let mut map = serde_json::Map::new(); if let Some(c) = code { @@ -178,6 +190,9 @@ fn build_json( if let Some(s) = suggestion { map.insert("suggestion".into(), serde_json::Value::String(s.into())); } + if let Some(l) = lookup { + map.insert("lookup".into(), serde_json::Value::String(l)); + } if let Some(d) = docs { map.insert("docs".into(), serde_json::Value::String(d)); } @@ -187,58 +202,70 @@ fn build_json( .unwrap_or_else(|_| format!("{{\"error\":{{\"message\":\"{message}\"}}}}")) } -fn docs_url(code: &str) -> Option { +/// The offline lookup command for an error code — the working next step. +fn lookup(code: &str) -> Option { if code.starts_with("AUTHS-E") { - Some(format!("{DOCS_BASE_URL}/errors/#{code}")) + Some(format!("auths error show {code}")) } else { None } } +/// Deep docs link for an error code. Returns `None` until the docs site publishes +/// `docs/errors/` — its `/errors/` path 404s today, so `auths error show` (the +/// renderer's look-up line) is the actionable path instead. +fn docs_url(code: &str) -> Option { + let _ = code; + None +} + #[cfg(test)] mod tests { use super::*; #[test] - fn docs_url_returns_some_for_known_codes() { - let url = docs_url("AUTHS-E3001"); - assert_eq!(url, Some(format!("{DOCS_BASE_URL}/errors/#AUTHS-E3001"))); + fn docs_url_returns_none_until_docs_site_publishes_errors() { + // The docs `/errors/` path 404s, so no docs URL is emitted for any code — + // `auths error show` is the working next step instead. + assert!(docs_url("AUTHS-E3001").is_none()); + assert!(docs_url("UNKNOWN").is_none()); } #[test] - fn docs_url_returns_none_for_unknown_codes() { - assert!(docs_url("UNKNOWN").is_none()); - assert!(docs_url("SOME_OTHER").is_none()); + fn lookup_points_at_offline_command_for_auths_codes() { + assert_eq!( + lookup("AUTHS-E5909"), + Some("auths error show AUTHS-E5909".to_string()) + ); + assert!(lookup("UNKNOWN").is_none()); } #[test] - fn build_json_with_all_fields() { + fn json_error_carries_offline_lookup_not_docs_url() { + // The machine-readable body advertises `auths error show ` and drops + // the dead docs URL — the same repointing the text renderer does. let json = build_json( - Some("AUTHS-E3001"), - "Key not found", - Some("Run `auths key list`"), - Some(format!("{DOCS_BASE_URL}/errors/#AUTHS-E3001")), + Some("AUTHS-E5909"), + "keychain unavailable", + Some("Run `auths doctor`"), + docs_url("AUTHS-E5909"), + lookup("AUTHS-E5909"), ); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["error"]["code"], "AUTHS-E3001"); - assert_eq!(parsed["error"]["message"], "Key not found"); - assert_eq!(parsed["error"]["suggestion"], "Run `auths key list`"); - assert!( - parsed["error"]["docs"] - .as_str() - .unwrap() - .contains("AUTHS-E3001") - ); + assert_eq!(parsed["error"]["lookup"], "auths error show AUTHS-E5909"); + assert!(parsed["error"].get("docs").is_none()); + assert!(!json.contains("docs.auths.dev/errors")); } #[test] fn build_json_without_optional_fields() { - let json = build_json(None, "Something went wrong", None, None); + let json = build_json(None, "Something went wrong", None, None, None); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["error"]["message"], "Something went wrong"); assert!(parsed["error"].get("code").is_none()); assert!(parsed["error"].get("suggestion").is_none()); assert!(parsed["error"].get("docs").is_none()); + assert!(parsed["error"].get("lookup").is_none()); } #[test] diff --git a/crates/auths-cli/src/factories/mod.rs b/crates/auths-cli/src/factories/mod.rs index 98eadeda9..eadc1c6a0 100644 --- a/crates/auths-cli/src/factories/mod.rs +++ b/crates/auths-cli/src/factories/mod.rs @@ -69,14 +69,11 @@ pub fn build_config(cli: &AuthsCli) -> Result { let is_interactive = std::io::stdout().is_terminal(); - // Honor the global --repo flag, falling back to AUTHS_REPO so a headless CI step - // (which exports AUTHS_REPO from `init --profile ci`) finds the same registry. - let repo_path = cli.repo.clone().or_else(|| { - std::env::var("AUTHS_REPO") - .ok() - .filter(|s| !s.is_empty()) - .map(std::path::PathBuf::from) - }); + // One storage root for every subcommand: --repo, then AUTHS_REPO (which a + // headless CI step exports from `init --profile ci`), then AUTHS_HOME as a + // deprecated alias — honored with a warning rather than silently ignored, so + // `verify`/`doctor` never read a different root than `init`/`sign` wrote. + let repo_path = resolve_repo_root(cli.repo.clone()); Ok(CliConfig { repo_path, @@ -87,6 +84,66 @@ pub fn build_config(cli: &AuthsCli) -> Result { }) } +/// Resolve the single storage-root override shared by every subcommand. +/// +/// Precedence: an explicit `--repo`, then `AUTHS_REPO`, then `AUTHS_HOME` as a +/// deprecated alias (warned, never silently dropped). Returns `None` when none is +/// set, leaving callers on the default `~/.auths`. +/// +/// Args: +/// * `repo_flag`: the value of the global `--repo` flag, if the user passed one. +/// +/// Usage: +/// ```ignore +/// let repo_path = resolve_repo_root(cli.repo.clone()); +/// ``` +fn resolve_repo_root(repo_flag: Option) -> Option { + #[allow(clippy::disallowed_methods)] // CLI boundary: storage-root env resolution + let auths_repo = std::env::var("AUTHS_REPO").ok().filter(|s| !s.is_empty()); + #[allow(clippy::disallowed_methods)] // CLI boundary: storage-root env resolution + let auths_home = std::env::var("AUTHS_HOME").ok().filter(|s| !s.is_empty()); + + let (root, via_home_alias) = pick_repo_root(repo_flag, auths_repo, auths_home); + if via_home_alias { + eprintln!( + "warning: AUTHS_HOME is deprecated; prefer AUTHS_REPO to override the storage root" + ); + } + root +} + +/// Apply the storage-root precedence: `--repo` > `AUTHS_REPO` > `AUTHS_HOME`. +/// +/// Returns the resolved root (if any) and whether it came from the deprecated +/// `AUTHS_HOME` alias, so the caller can warn without the env read living inside +/// the tested logic. +/// +/// Args: +/// * `repo_flag`: the `--repo` value, if given. +/// * `auths_repo`: a non-empty `AUTHS_REPO`, if set. +/// * `auths_home`: a non-empty `AUTHS_HOME`, if set. +/// +/// Usage: +/// ```ignore +/// let (root, via_home) = pick_repo_root(None, None, Some("/x".into())); +/// ``` +fn pick_repo_root( + repo_flag: Option, + auths_repo: Option, + auths_home: Option, +) -> (Option, bool) { + if let Some(flag) = repo_flag { + return (Some(flag), false); + } + if let Some(repo) = auths_repo { + return (Some(std::path::PathBuf::from(repo)), false); + } + match auths_home { + Some(home) => (Some(std::path::PathBuf::from(home)), true), + None => (None, false), + } +} + /// Loads audit sinks from `~/.auths/audit.toml` and initialises the global /// telemetry pipeline. /// @@ -130,3 +187,44 @@ pub fn build_agent_provider() -> Arc { Arc::new(auths_sdk::ports::agent::NoopAgentProvider) } } + +#[cfg(test)] +mod tests { + use super::pick_repo_root; + use std::path::PathBuf; + + #[test] + fn repo_flag_wins_over_both_env_vars() { + let (root, via_home) = pick_repo_root( + Some(PathBuf::from("/flag")), + Some("/repo".into()), + Some("/home".into()), + ); + assert_eq!(root, Some(PathBuf::from("/flag"))); + assert!(!via_home); + } + + #[test] + fn auths_repo_wins_over_auths_home() { + let (root, via_home) = pick_repo_root(None, Some("/repo".into()), Some("/home".into())); + assert_eq!(root, Some(PathBuf::from("/repo"))); + assert!(!via_home); + } + + #[test] + fn auths_home_populates_repo_path_with_warning() { + // AUTHS_HOME must feed the shared storage root (with a deprecation warning), + // not be silently ignored — otherwise verify/doctor read a different root + // than init/sign wrote. + let (root, via_home) = pick_repo_root(None, None, Some("/home".into())); + assert_eq!(root, Some(PathBuf::from("/home"))); + assert!(via_home, "AUTHS_HOME must trigger the deprecation warning"); + } + + #[test] + fn no_override_leaves_default_root() { + let (root, via_home) = pick_repo_root(None, None, None); + assert_eq!(root, None); + assert!(!via_home); + } +} diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 39c20d273..343de79e9 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -123,6 +123,7 @@ fn run() -> Result<()> { RootCommand::Emergency(cmd) => cmd.execute(&ctx), RootCommand::Agent(cmd) => cmd.execute(&ctx), RootCommand::Treasury(cmd) => cmd.execute(&ctx), + RootCommand::Anchor(cmd) => cmd.execute(&ctx), RootCommand::Witness(cmd) => cmd.execute(&ctx), RootCommand::Scim(cmd) => cmd.execute(&ctx), RootCommand::Commit(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-core/src/crypto/encryption.rs b/crates/auths-core/src/crypto/encryption.rs index 29d8caaeb..5697e3872 100644 --- a/crates/auths-core/src/crypto/encryption.rs +++ b/crates/auths-core/src/crypto/encryption.rs @@ -64,6 +64,13 @@ pub fn get_kdf_params() -> Result { params.map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e))) } +/// Human-readable statement of the passphrase policy [`validate_passphrase`] +/// enforces. Shown before an interactive prompt so the rule is known up front, +/// not learned by violating it. Single source of truth: the prompt hint and the +/// validator read the same policy from here. +pub const PASSPHRASE_POLICY_HINT: &str = + "12+ characters, at least 3 of: lowercase, uppercase, digit, symbol"; + /// Validates that a passphrase meets minimum strength requirements. /// /// Requires at least 12 characters and at least 3 of 4 character classes: @@ -345,6 +352,30 @@ mod tests { assert!(validate_passphrase(STRONG_PASS).is_ok()); } + #[test] + fn policy_hint_matches_validator() { + // The shown policy and the enforced policy must never drift: a passphrase + // that honors the hint (12+ chars, 3+ classes) passes, and ones that break + // either half fail — so the hint can never promise a rule the validator + // does not enforce. + assert!( + PASSPHRASE_POLICY_HINT.contains("12+"), + "hint must state the length floor" + ); + // Honors the hint: 13 chars across lowercase, uppercase, digit, symbol. + assert!(validate_passphrase("Abcdefg1234!x").is_ok()); + // Long enough but only two classes (lowercase + digit) — must fail. + assert!(matches!( + validate_passphrase("abcdefgh1234"), + Err(AgentError::WeakPassphrase(_)) + )); + // Enough classes but too short — must fail. + assert!(matches!( + validate_passphrase("Ab1!"), + Err(AgentError::WeakPassphrase(_)) + )); + } + #[test] fn test_argon2_encrypt_rejects_weak() { let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256); diff --git a/crates/auths-core/src/pairing/error.rs b/crates/auths-core/src/pairing/error.rs index 44b188797..36bb68464 100644 --- a/crates/auths-core/src/pairing/error.rs +++ b/crates/auths-core/src/pairing/error.rs @@ -56,15 +56,15 @@ impl AuthsErrorInfo for PairingError { Self::LanTimeout => Some("Check your network and try again"), Self::RelayError(_) => Some("Check your internet connection"), Self::Protocol(_) => Some("Ensure both devices are running compatible auths versions"), - Self::QrCodeFailed(_) => { - Some("QR code generation failed; try `auths device pair --mode relay` instead") - } + Self::QrCodeFailed(_) => Some( + "QR code generation failed; try relay-based pairing with `auths device pair --registry ` instead", + ), Self::LocalServerError(_) => { Some("The local pairing server failed to start; check that the port is available") } - Self::MdnsError(_) => { - Some("mDNS discovery failed; try `auths device pair --mode relay` instead") - } + Self::MdnsError(_) => Some( + "mDNS discovery failed; try relay-based pairing with `auths device pair --registry ` instead", + ), } } } diff --git a/crates/auths-evidence/Cargo.toml b/crates/auths-evidence/Cargo.toml index 387247ca0..471ffc849 100644 --- a/crates/auths-evidence/Cargo.toml +++ b/crates/auths-evidence/Cargo.toml @@ -37,6 +37,10 @@ tokio = { workspace = true } [dev-dependencies] tempfile = "3" +# Exposes auths-anchor's deterministic finalized-anchor fixtures to the +# attestation test battery (the anchor-forgery cases). Feature-unified with the +# normal dependency above, so it is enabled ONLY when building this crate's tests. +auths-anchor = { workspace = true, features = ["test-support"] } [lints] workspace = true diff --git a/crates/auths-evidence/schemas/audit-v1.json b/crates/auths-evidence/schemas/audit-v1.json index e230ce714..55e1e2a29 100644 --- a/crates/auths-evidence/schemas/audit-v1.json +++ b/crates/auths-evidence/schemas/audit-v1.json @@ -13,18 +13,26 @@ "properties": { "verdict": { "type": "string", - "enum": ["consistent", "tampered-proof", "cost-mismatch", "budget-mismatch", "dropped-call", "revoked"] + "enum": ["consistent", "tampered-proof", "cost-mismatch", "counterparty-mismatch", "budget-mismatch", "chain-break", "revoked"] } }, - "description": "The typed audit verdict (internally tagged, kebab-case), carrying the failing check's own fields." + "description": "The typed audit verdict (internally tagged, kebab-case), carrying the failing check's own fields. Note: the Consistent variant's internal tag is 'consistent' while its stable machine code (below) is 'self-consistent' — an offline audit proves self-consistency, not completeness." }, "code": { "type": "string", - "enum": ["consistent", "tampered-proof", "cost-mismatch", "budget-mismatch", "dropped-call", "revoked"] + "enum": ["self-consistent", "tampered-proof", "cost-mismatch", "counterparty-mismatch", "budget-mismatch", "chain-break", "revoked"] }, "consistent": { "type": "boolean" }, "records": { "type": "integer", "minimum": 0 }, "settled_cents": { "type": "integer", "minimum": 0 }, + "counterparty": { + "type": "string", + "description": "The verified counterparty the last metered call settled with (Auths-Settle-Ref, cross-checked against the rail response) — a proven field consumers act on, never receipt.charge_ref. Absent when nothing metered settled." + }, + "completeness": { + "type": "string", + "description": "Whether completeness was proven. An offline audit re-derives self-consistency but cannot prove the log is complete (a $0/refused tail truncation is invisible) — 'unproven-offline' for a consistent offline audit; absent otherwise." + }, "checkpoint": { "type": "object", "required": ["records", "settled_cents", "binding"], diff --git a/crates/auths-evidence/src/attestation.rs b/crates/auths-evidence/src/attestation.rs index f318735da..0520917d5 100644 --- a/crates/auths-evidence/src/attestation.rs +++ b/crates/auths-evidence/src/attestation.rs @@ -12,6 +12,7 @@ //! `args_hash`es. use auths_keri::{KeriPublicKey, Prefix}; +use auths_verifier::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use chrono::{DateTime, Utc}; @@ -141,62 +142,183 @@ pub fn unsigned_activity_anchor( }) } +/// The verified summary of an embedded quorum anchor — the report an RP branches +/// on, never the raw finalized anchor. Present on a verdict only when a witness +/// anchor verified whole (finalization re-checked, tuple restates the document's +/// aggregate, party key current). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchorSummary { + /// The assurance tier this anchor confers. Always `"witness"` today. + pub tier: &'static str, + /// The finalization threshold `t` of the co-signing witness set. + pub threshold: u32, + /// Declared members `N` of the witness set. + pub witnesses: usize, + /// Distinct cosignatures that met the threshold. + pub cosigners: usize, + /// The spend chain this anchor extends (hex seed id). + pub seed_id: String, + /// The self-addressing identifier of the co-signing witness set. + pub witness_set_said: String, + /// True when a supplied witness tip index proves a fresher anchor exists — + /// the document is genuinely anchored, but the witness has moved past it. + pub stale: bool, +} + +impl AnchorSummary { + /// Build the summary from a verified finalized anchor. `stale` defaults to + /// `false`; [`verify_activity_with_keys`] sets it from a supplied witness tip. + fn witness(finalized: &auths_anchor::FinalizedAnchor) -> Self { + Self { + tier: "witness", + threshold: finalized.witness_set.threshold, + witnesses: finalized.witness_set.members.len(), + cosigners: finalized.cosignatures.len(), + seed_id: finalized.anchor.seed_id.to_hex(), + witness_set_said: finalized.witness_set.said.clone(), + stale: false, + } + } +} + +/// The first-class verdict the `activity/v1` verifiers return: the freshness +/// bound, the verified anchor summary (or `None` when unanchored), and whether +/// the head is bound by a witness anchor. The report is the only API — a relying +/// party reads these fields and never re-derives them from evidence. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityVerdict { + /// How fresh this positive verdict is, relative to the policy window. + pub freshness: Freshness, + /// The verified witness-anchor summary, or `None` for an unanchored document. + pub anchor: Option, + /// True iff a verified witness anchor cosigns `head == doc.head`. A + /// self-asserted head is never bound (the verifier never sees the private + /// spend chain the head commits to). + pub head_bound: bool, +} + +/// Options for `activity/v1` verification. The defaults keep the anchor an +/// additive assurance tier; `require_witness` promotes it to a required gate. +#[derive(Debug, Clone, Default)] +pub struct VerifyActivityOpts { + /// Fail the whole document when there is no verified witness anchor — turns + /// the anchor from an additive assurance into a gate (a document that simply + /// omits its anchor no longer passes). + pub require_witness: bool, + /// An independently-known witness tip index for this seed, if the relying + /// party has one. A tip greater than the document's count proves the witness + /// has moved past this anchor (the document is stale). + pub witness_tip_index: Option, +} + +/// Map a finalization failure to a typed anchor-invalid error with a stable, +/// machine-readable code the relying party can gate on. +fn anchor_invalid(e: auths_anchor::AnchorError) -> EvidenceError { + use auths_anchor::AnchorError as A; + let code = match &e { + A::CosignatureInvalid { .. } => "cosignature-invalid", + A::CosignerOutsideSet { .. } => "cosigner-outside-set", + A::ThresholdNotMet { .. } => "threshold-not-met", + A::WitnessSetMismatch { .. } | A::SetSaidMismatch { .. } => "set-said-mismatch", + A::SetInvalid(_) => "witness-set-invalid", + A::PartyKeyNotCurrent | A::PartySignatureInvalid => "party-key-not-current", + A::CheckpointUnverifiable { .. } | A::InclusionMissing { .. } | A::InclusionInvalid(_) => { + "inclusion-invalid" + } + _ => "anchor-invalid", + }; + EvidenceError::AnchorInvalid { + code, + detail: e.to_string(), + } +} + /// Verify an attestation's embedded finalized anchor against the document it -/// rides in: the finalization re-checks offline, the tuple restates exactly -/// this document's aggregate, and the party key that signed the anchor is one -/// of the agent's current keys. A document without an anchor passes — the -/// anchor is an additive assurance tier, never a gate. +/// rides in and report its tier: the finalization re-checks offline, the tuple +/// restates exactly this document's aggregate, and the party key that signed the +/// anchor is one of the agent's current keys. +/// +/// `require_witness` turns the anchor from an additive assurance into a gate: an +/// unanchored document fails whole when a caller demands the witness tier, +/// instead of passing with `anchor == None`. +/// +/// Args: +/// * `doc`: the attestation carrying the optional anchor. +/// * `current_keys`: the agent's current CESR-parsed verkeys. +/// * `require_witness`: fail whole when no verified witness anchor is present. +/// +/// Usage: +/// ```ignore +/// let anchor = verify_embedded_anchor(&doc, &keys, false)?; +/// ``` fn verify_embedded_anchor( doc: &ActivityV1, current_keys: &[KeriPublicKey], -) -> Result<(), EvidenceError> { + require_witness: bool, +) -> Result, EvidenceError> { let Some(finalized) = &doc.anchor else { - return Ok(()); + return if require_witness { + Err(EvidenceError::AnchorInvalid { + code: "anchor-required", + detail: "witness tier required but this document carries no anchor".to_string(), + }) + } else { + Ok(None) + }; }; - auths_anchor::verify_finalized(finalized, None) - .map_err(|e| EvidenceError::Input(format!("embedded anchor: {e}")))?; + auths_anchor::verify_finalized(finalized, None).map_err(anchor_invalid)?; let anchor = &finalized.anchor; if anchor.seed_id != activity_seed_id(doc) { - return Err(EvidenceError::Input( - "embedded anchor is for a different spend chain".to_string(), - )); + return Err(EvidenceError::AnchorInvalid { + code: "chain-mismatch", + detail: "embedded anchor is for a different spend chain".to_string(), + }); } if anchor.head.to_hex() != doc.head || anchor.index != doc.count || anchor.cumulative != u128::from(doc.cumulative_cents) { - return Err(EvidenceError::Input( - "embedded anchor does not restate this document's aggregate".to_string(), - )); + return Err(EvidenceError::AnchorInvalid { + code: "aggregate-mismatch", + detail: "embedded anchor does not restate this document's aggregate".to_string(), + }); } let party_is_current = current_keys.iter().any(|key| { key.curve() == anchor.sig_party.curve && key.raw_bytes() == anchor.sig_party.public_key }); if !party_is_current { - return Err(EvidenceError::Input( - "embedded anchor's party key is not a current agent key".to_string(), - )); + return Err(EvidenceError::AnchorInvalid { + code: "party-key-not-current", + detail: "embedded anchor's party key is not a current agent key".to_string(), + }); } - Ok(()) + Ok(Some(AnchorSummary::witness(finalized))) } -/// Verify an attestation's signature against the agent's CURRENT signing keys -/// (resolved by the caller from the public KEL). Passes if ANY current key -/// verifies — the default posture is `kt=1`. +/// Verify an attestation's body signature against the agent's CURRENT signing +/// keys (resolved by the caller from the public KEL) and, when present, its +/// embedded quorum anchor. Passes if ANY current key verifies the body signature +/// — the default posture is `kt=1`. Returns the verified anchor summary, or +/// `None` for an unanchored document (or an error when `require_witness` demands +/// a witness tier the document lacks). /// /// Args: /// * `doc`: the attestation. /// * `current_keys`: the agent's current CESR-parsed verkeys. +/// * `require_witness`: fail whole when no verified witness anchor is present. /// /// Usage: /// ```ignore -/// verify_activity(&doc, &keys)?; +/// let anchor = verify_activity(&doc, &keys, false)?; /// ``` pub fn verify_activity( doc: &ActivityV1, current_keys: &[KeriPublicKey], -) -> Result<(), EvidenceError> { + require_witness: bool, +) -> Result, EvidenceError> { if doc.version != ACTIVITY_VERSION { return Err(EvidenceError::Input(format!( "unknown version {}", @@ -209,32 +331,120 @@ pub fn verify_activity( .map_err(|e| EvidenceError::Input(format!("signature b64: {e}")))?; for key in current_keys { if auths_crypto::typed_verify(key.curve(), key.raw_bytes(), &message, &signature).is_ok() { - return verify_embedded_anchor(doc, current_keys); + return verify_embedded_anchor(doc, current_keys, require_witness); } } Err(EvidenceError::Input( - "attestation signature verifies under no current agent key".to_string(), + "attestation signature does not verify under any current agent key \ + (the body was edited after signing, or the signing key was rotated out)" + .to_string(), )) } +/// Verify an `activity/v1` attestation against already-resolved agent keys and +/// return a first-class verdict: the body signature, the embedded quorum anchor +/// (when present, as a gate under `require_witness`), and a freshness bound. The +/// clock is INJECTED — this function reads no wall clock and no network. +/// +/// A future `as_of` is rejected outright (a stale timestamp can only be *more* +/// stale, never falsely fresh). Freshness is classified against the policy the +/// options imply: strict (offline-`Unknown` denied) when a witness tier is +/// required, offline-friendly otherwise. A witness anchor cosigns the `as_of`, +/// so a recent anchored document reads `Fresh`; a recent, self-asserted +/// timestamp is unconfirmable and reads `Unknown`; a supplied fresher witness +/// tip marks the anchor `stale`. +/// +/// Args: +/// * `doc`: the attestation. +/// * `current_keys`: the agent's current CESR-parsed verkeys. +/// * `now`: the verification instant (injected at the binding boundary). +/// * `opts`: gating options (`require_witness`, `witness_tip_index`). +/// +/// Usage: +/// ```ignore +/// let verdict = verify_activity_with_keys(&doc, &keys, now, &opts)?; +/// ``` +pub fn verify_activity_with_keys( + doc: &ActivityV1, + current_keys: &[KeriPublicKey], + now: DateTime, + opts: &VerifyActivityOpts, +) -> Result { + if doc.as_of.ts > now + chrono::Duration::seconds(60) { + return Err(EvidenceError::Input( + "attestation as_of is in the future".to_string(), + )); + } + let mut anchor = verify_activity(doc, current_keys, opts.require_witness)?; + + let policy = if opts.require_witness { + FreshnessPolicy::strict(std::time::Duration::from_secs(24 * 60 * 60)) + } else { + FreshnessPolicy::default() + }; + let evidence = match opts.witness_tip_index { + Some(tip) => FreshnessEvidence::FresherTip { + latest_seq: u128::from(tip), + slice_as_of: u128::from(doc.count), + }, + None => { + let age = (now - doc.as_of.ts).to_std().unwrap_or_default(); + if age > policy.max_age || anchor.is_some() { + // A witness anchor cosigns the `as_of`, and an old self-timestamp + // can only be MORE stale — both are legible as a witnessed source + // age (recent → Fresh, past the window → Stale). + FreshnessEvidence::SourceAge(age) + } else { + // A recent, self-asserted timestamp is unconfirmable → named Unknown. + FreshnessEvidence::Offline + } + } + }; + let freshness = policy.classify(evidence); + + if let Some(summary) = anchor.as_mut() + && let Some(tip) = opts.witness_tip_index + { + summary.stale = tip > doc.count; + } + + if opts.require_witness && !policy.trusts(freshness) { + return Err(EvidenceError::Input(format!( + "freshness not trusted for a witness-tier claim: {freshness:?}" + ))); + } + + let head_bound = anchor.is_some(); + Ok(ActivityVerdict { + freshness, + anchor, + head_bound, + }) +} + /// Verify an attestation against a public registry copy: resolve the agent's /// current key state from the KEL (identity resolution ONLY — no spend data is -/// ever fetched), require its delegator to be the claimed root, and verify the -/// signature. This is the market's whole verification; it never sees a per-call -/// row. +/// ever fetched), require its delegator to be the claimed root, verify the body +/// signature and (when present, or when `opts.require_witness` demands it) the +/// embedded quorum anchor, and classify freshness against the injected clock. +/// This is the market's whole verification; it never sees a per-call row. /// /// Args: /// * `doc`: the attestation. /// * `registry`: a fetched copy of the public registry. +/// * `now`: the verification instant (injected at the binding boundary). +/// * `opts`: gating options (`require_witness`, `witness_tip_index`). /// /// Usage: /// ```ignore -/// verify_activity_against_registry(&doc, ®istry_dir)?; +/// let verdict = verify_activity_against_registry(&doc, ®istry_dir, now, opts)?; /// ``` pub fn verify_activity_against_registry( doc: &ActivityV1, registry: &std::path::Path, -) -> Result<(), EvidenceError> { + now: DateTime, + opts: VerifyActivityOpts, +) -> Result { use auths_sdk::ports::RegistryBackend; use auths_sdk::storage::{GitRegistryBackend, RegistryConfig}; @@ -279,7 +489,7 @@ pub fn verify_activity_against_registry( .map_err(|e| EvidenceError::Input(format!("current key: {e}"))) }) .collect::>()?; - verify_activity(doc, &keys) + verify_activity_with_keys(doc, &keys, now, &opts) } /// The monotonicity rules a verifier applies between a stored checkpoint and a @@ -299,6 +509,10 @@ pub fn monotonicity_violation( if next.as_of.ts < prev_ts { return Some("as-of-regressed"); } + // A self-asserted head can be freshly minted each publish, so this rule is + // cosmetic at first-seen; it is load-bearing only once BOTH checkpoints carry + // a verified witness anchor (the anchor cosigns `head == doc.head`, which is + // what makes the head un-forgeable — see `ActivityVerdict::head_bound`). if next.cumulative_cents > prev_cents && next.head == prev_head { return Some("head-unmoved-under-growth"); } diff --git a/crates/auths-evidence/src/bundle.rs b/crates/auths-evidence/src/bundle.rs index 332dcf7c4..e0a1c7812 100644 --- a/crates/auths-evidence/src/bundle.rs +++ b/crates/auths-evidence/src/bundle.rs @@ -101,7 +101,7 @@ pub struct BuildOpts { pub online_freshness: Option, /// Verified escrow-record summary (dispute bundles). pub escrow: Option, - /// Minimized compliance cross-link (dispute bundles, S3). + /// Minimized compliance cross-link (dispute bundles). pub compliance: Option, /// Human-readable render over hashed fields. pub rendered: Option, @@ -315,11 +315,11 @@ pub struct OfflineVerdict { /// The (re-checked) verdicts — always restating the anchor, never bare. #[serde(default, skip_serializing_if = "Option::is_none")] pub verdicts: Option, - /// The subject echoed for caller binding (security S4). + /// The subject echoed for caller binding. pub subject: Subject, - /// The settlement tx echoed for caller binding (S4). + /// The settlement tx echoed for caller binding. pub tx: String, - /// The call index echoed for caller binding (S4). + /// The call index echoed for caller binding. #[serde(rename = "callIndex")] pub call_index: u64, /// The proven root (the pinned root the chain re-derivation reached). @@ -343,7 +343,7 @@ impl OfflineVerdict { } /// Fully-offline verification of a bundle: proof replay → head recompute → -/// anchor tier → verdict recompute → issuer signature → S4 echo. No network, no +/// anchor tier → verdict recompute → issuer signature → binding-field echo. No network, no /// wall clock — everything is judged as of the embedded anchor instant, so a /// bundle verifies identically forever. /// @@ -359,7 +359,7 @@ impl OfflineVerdict { /// Usage: /// ```ignore /// let verdict = verify_offline(&bundle).await; -/// assert!(verdict.ok && verdict.tx == my_disputed_tx); // the caller's S4 binding +/// assert!(verdict.ok && verdict.tx == my_disputed_tx); // the caller's binding check /// ``` pub async fn verify_offline(bundle: &EvidenceBundle) -> OfflineVerdict { // 1. Replay the embedded proof through the one audit walk, as of the anchor. @@ -385,7 +385,9 @@ pub async fn verify_offline(bundle: &EvidenceBundle) -> OfflineVerdict { .await; if matches!( verdict, - AuditVerdict::TamperedProof { .. } | AuditVerdict::CostMismatch { .. } + AuditVerdict::TamperedProof { .. } + | AuditVerdict::CostMismatch { .. } + | AuditVerdict::CounterpartyMismatch { .. } ) { return OfflineVerdict::fail(bundle, "tampered", format!("{verdict}")); } @@ -474,7 +476,7 @@ pub async fn verify_offline(bundle: &EvidenceBundle) -> OfflineVerdict { return OfflineVerdict::fail(bundle, "invalid-signature", reason); } - // 6. S4 — echo the binding fields; the CALLER must assert they match its own + // 6. Echo the binding fields; the CALLER must assert they match its own // payment ref before acting on the verdict. OfflineVerdict { ok: true, diff --git a/crates/auths-evidence/src/error.rs b/crates/auths-evidence/src/error.rs index 5fee26e4e..deebb75f1 100644 --- a/crates/auths-evidence/src/error.rs +++ b/crates/auths-evidence/src/error.rs @@ -47,4 +47,18 @@ pub enum EvidenceError { /// refused the first-seen fallback. #[error("the anchor head does not cover the requested call: {0}")] AnchorLagging(String), + + /// An embedded quorum anchor failed a specific verification leg. `code` is a + /// stable, machine-readable identifier of which leg failed, so a relying + /// party can gate on it (the report is the only API); `detail` is the + /// human-readable cause. + #[error("embedded anchor invalid ({code}): {detail}")] + AnchorInvalid { + /// Stable kebab-case identifier of the failed check (e.g. + /// `anchor-required`, `chain-mismatch`, `aggregate-mismatch`, + /// `cosignature-invalid`, `threshold-not-met`, `party-key-not-current`). + code: &'static str, + /// The human-readable cause. + detail: String, + }, } diff --git a/crates/auths-evidence/src/lib.rs b/crates/auths-evidence/src/lib.rs index 15adcb68e..9454f0063 100644 --- a/crates/auths-evidence/src/lib.rs +++ b/crates/auths-evidence/src/lib.rs @@ -34,9 +34,9 @@ pub use anchor::{ // The AWN protocol core, re-exported so evidence consumers reach freshness and // the finalized-anchor proof through one crate. pub use attestation::{ - ACTIVITY_VERSION, ActivityAsOf, ActivityV1, activity_seed_id, activity_signing_bytes, - monotonicity_violation, unsigned_activity_anchor, verify_activity, - verify_activity_against_registry, + ACTIVITY_VERSION, ActivityAsOf, ActivityV1, ActivityVerdict, AnchorSummary, VerifyActivityOpts, + activity_seed_id, activity_signing_bytes, monotonicity_violation, unsigned_activity_anchor, + verify_activity, verify_activity_against_registry, verify_activity_with_keys, }; pub use auths_anchor::{Freshness, freshness}; pub use bundle::{ diff --git a/crates/auths-evidence/src/types.rs b/crates/auths-evidence/src/types.rs index 49179fa6d..70b3d620a 100644 --- a/crates/auths-evidence/src/types.rs +++ b/crates/auths-evidence/src/types.rs @@ -221,8 +221,8 @@ pub struct BundleGrant { pub counterparty_policy: CounterpartyPolicy, } -/// The identified call the bundle is about. Arguments travel HASHED only -/// (security S3) — the plaintext never enters a bundle. +/// The identified call the bundle is about. Arguments travel HASHED only — +/// the plaintext never enters a bundle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BundleCall { /// The tool the call targeted. @@ -351,7 +351,7 @@ pub struct EvidenceBundle { /// Optional verified escrow-record summary (dispute bundles). #[serde(default, skip_serializing_if = "Option::is_none")] pub escrow: Option, - /// Optional minimized compliance cross-link (dispute bundles, security S3). + /// Optional minimized compliance cross-link (dispute bundles). #[serde(default, skip_serializing_if = "Option::is_none")] pub compliance: Option, /// Optional human-readable render, built over hashed fields only. @@ -382,6 +382,17 @@ pub struct AuditV1 { pub records: usize, /// The re-derived cross-rail settled total (cents). pub settled_cents: u64, + /// The VERIFIED counterparty the last metered call settled with (`Auths-Settle-Ref`, + /// cross-checked against the rail response) — a proven field consumers act on, never + /// `receipt.charge_ref`. `None` when nothing metered settled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub counterparty: Option, + /// Whether completeness was proven. An offline audit re-derives SELF-consistency but cannot + /// prove the log is complete (a $0/refused tail truncation is invisible) — so a consumer + /// branches on this field, not on the prose (`"unproven-offline"` for a consistent offline + /// audit; `None` otherwise). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completeness: Option, /// The resumable end state for a checkpointing caller. #[serde(default, skip_serializing_if = "Option::is_none")] pub checkpoint: Option, diff --git a/crates/auths-evidence/src/verify_spend.rs b/crates/auths-evidence/src/verify_spend.rs index 822b4f838..06f7f1556 100644 --- a/crates/auths-evidence/src/verify_spend.rs +++ b/crates/auths-evidence/src/verify_spend.rs @@ -152,9 +152,13 @@ pub async fn verify_spend( /// Build the typed `audit/v1` report from a walk's verdict + the full record set. pub fn report_of(verdict: &AuditVerdict, records: &[SpendLogRecord]) -> AuditV1 { - let (audited, settled) = match verdict { - AuditVerdict::Consistent(proof) => (proof.calls(), proof.settled_cents().get()), - _ => (0, 0), + let (audited, settled, counterparty) = match verdict { + AuditVerdict::Consistent(proof) => ( + proof.calls(), + proof.settled_cents().get(), + proof.counterparty().map(str::to_string), + ), + _ => (0, 0, None), }; let checkpoint = records.last().map(|last| AuditCheckpoint { records: records.len(), @@ -168,8 +172,71 @@ pub fn report_of(verdict: &AuditVerdict, records: &[SpendLogRecord]) -> AuditV1 consistent: verdict.is_consistent(), records: audited, settled_cents: settled, + counterparty, + // An offline audit never proves completeness — a consumer branches on this field. + completeness: verdict + .is_consistent() + .then(|| "unproven-offline".to_string()), checkpoint, treasury: None, freshness: None, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Mint a `Consistent` verdict through serde — the typed proof's constructor is deliberately + /// private to production code, so tests build it from its wire shape (as the judge tests do). + fn consistent(calls: usize, settled: u64, head: &str, counterparty: &str) -> AuditVerdict { + serde_json::from_value(serde_json::json!({ + "verdict": "consistent", + "calls": calls, + "settled_cents": settled, + "head": head, + "counterparty": counterparty, + })) + .unwrap() + } + + #[test] + fn report_of_a_consistent_verdict_round_trips_as_a_portable_object() { + // The `--json` verdict object serializes to one portable line carrying the honest + // `self-consistent` code. + let verdict = consistent(2, 300, "abc123head", "ch_3MmlLrLkdIwHu7ix"); + let report = report_of(&verdict, &[]); + let json = serde_json::to_string(&report).unwrap(); + assert!(json.contains("\"code\":\"self-consistent\""), "{json}"); + assert!(json.contains("\"version\":\"audit/v1\""), "{json}"); + assert!( + json.contains("\"counterparty\":\"ch_3MmlLrLkdIwHu7ix\""), + "{json}" + ); + assert!( + json.contains("\"completeness\":\"unproven-offline\""), + "{json}" + ); + // Round-trips. + let back: AuditV1 = serde_json::from_str(&json).unwrap(); + assert_eq!(back.code, "self-consistent"); + assert!(back.consistent); + } + + #[test] + fn report_of_a_counterparty_mismatch_is_inconsistent() { + let verdict = AuditVerdict::CounterpartyMismatch { + at: 0, + signed_counterparty: "ch_3Mml".to_string(), + response_counterparty: "ch_ATTACKERxxx".to_string(), + proof_ref: "deadbeef".to_string(), + }; + let report = report_of(&verdict, &[]); + let json = serde_json::to_string(&report).unwrap(); + assert!( + json.contains("\"code\":\"counterparty-mismatch\""), + "{json}" + ); + assert!(!report.consistent); + } +} diff --git a/crates/auths-evidence/tests/cases/attestation.rs b/crates/auths-evidence/tests/cases/attestation.rs new file mode 100644 index 000000000..02c23db1e --- /dev/null +++ b/crates/auths-evidence/tests/cases/attestation.rs @@ -0,0 +1,333 @@ +//! The `activity/v1` verifier battery: a witness anchor is a required gate when +//! demanded, every tampered or forged anchor fails the WHOLE document with a +//! named check, a genuinely-signed but false aggregate is never laundered into a +//! witnessed fact, and freshness is honest against an injected clock. +//! +//! These exercise the pure, keys-injected surface (`verify_activity` and +//! `verify_activity_with_keys`) so no git registry is needed; the registry +//! wrapper only adds KEL key resolution around this same core. + +use auths_anchor::test_support::{ + finalized_matching, finalized_sample, party_seed_bytes, party_verifying_key_bytes, + with_cosigners, +}; +use auths_crypto::{CurveType, TypedSeed}; +use auths_evidence::{ + ACTIVITY_VERSION, ActivityAsOf, ActivityV1, EvidenceError, Subject, VerifyActivityOpts, + activity_seed_id, activity_signing_bytes, verify_activity, verify_activity_with_keys, +}; +use auths_keri::KeriPublicKey; +use auths_verifier::freshness::Freshness; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::{DateTime, Duration, TimeZone, Utc}; + +/// A fixed verification instant, so every case is deterministic. +fn t0() -> DateTime { + Utc.timestamp_opt(1_760_000_000, 0).unwrap() +} + +/// A genuinely-signed, unanchored `activity/v1` document over the given tuple, +/// plus the agent's current keys. The document is signed by the same party key +/// the anchor fixtures use, so an attached fixture anchor's party-key check +/// resolves to a current key. The curve travels with the key material via the +/// typed seed and the CESR-taggable verkey — never assumed. +fn honest_doc( + head: [u8; 32], + count: u64, + cumulative_cents: u64, + as_of: DateTime, +) -> (ActivityV1, Vec) { + let (curve, seed_bytes) = (CurveType::Ed25519, party_seed_bytes()); + let mut doc = ActivityV1 { + version: ACTIVITY_VERSION.to_string(), + suite: "json-canon/ed25519".to_string(), + subject: Subject { + root: "did:keri:root".to_string(), + agent: "did:keri:agent".to_string(), + }, + head: auths_anchor::Head::from_bytes(head).to_hex(), + count, + cumulative_cents, + as_of: ActivityAsOf { + ts: as_of, + anchor: None, + }, + signature: String::new(), + anchor: None, + }; + let seed = TypedSeed::from_curve(curve, seed_bytes); + let message = activity_signing_bytes(&doc).unwrap(); + let signature = auths_crypto::typed_sign(&seed, &message).unwrap(); + doc.signature = BASE64.encode(signature); + let verkey = party_verifying_key_bytes(); + let keys = vec![KeriPublicKey::from_verkey_bytes(&verkey, curve).unwrap()]; + (doc, keys) +} + +/// Attach a witness anchor that legitimately restates the document's tuple. +fn attach_matching_anchor(doc: &mut ActivityV1) { + let seed_id = activity_seed_id(doc); + let head = auths_anchor::Head::from_hex(&doc.head).unwrap(); + let finalized = finalized_matching( + seed_id, + doc.count, + *head.as_bytes(), + u128::from(doc.cumulative_cents), + 3, + 2, + ); + doc.anchor = Some(finalized); +} + +fn require_witness() -> VerifyActivityOpts { + VerifyActivityOpts { + require_witness: true, + witness_tip_index: None, + } +} + +// ---- a witness anchor is a required gate when the tier is demanded ---- + +#[test] +fn anchor_removed_with_require_witness_fails_whole() { + let (doc, keys) = honest_doc([0x11; 32], 3, 300, t0()); + let err = verify_activity_with_keys(&doc, &keys, t0(), &require_witness()).unwrap_err(); + assert!( + matches!( + err, + EvidenceError::AnchorInvalid { + code: "anchor-required", + .. + } + ), + "{err:?}" + ); +} + +#[test] +fn anchor_removed_without_require_witness_passes_with_null_anchor() { + let (doc, keys) = honest_doc([0x11; 32], 3, 300, t0()); + let verdict = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap(); + assert!(verdict.anchor.is_none()); + assert!(!verdict.head_bound); +} + +// ---- every tampered/forged anchor fails the whole document with a named leg ---- + +#[test] +fn spliced_anchor_for_a_different_chain_fails_whole() { + let (mut doc, keys) = honest_doc([0x11; 32], 1, 100, t0()); + // A finalized anchor from a different spend chain (its own seed id). + doc.anchor = Some(finalized_sample(3, 2)); + let err = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap_err(); + assert!( + matches!( + err, + EvidenceError::AnchorInvalid { + code: "chain-mismatch", + .. + } + ), + "{err:?}" + ); +} + +#[test] +fn under_threshold_anchor_fails_whole() { + // A finalization failure (fewer distinct cosignatures than the threshold) + // fails the whole document and names the leg. + let (mut doc, keys) = honest_doc([0x11; 32], 4, 400, t0()); + let seed_id = activity_seed_id(&doc); + let finalized = with_cosigners(finalized_matching(seed_id, 4, [0x11; 32], 400, 3, 2), 1); + doc.anchor = Some(finalized); + let err = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap_err(); + assert!( + matches!( + err, + EvidenceError::AnchorInvalid { + code: "threshold-not-met", + .. + } + ), + "{err:?}" + ); +} + +#[test] +fn rewritten_head_under_a_real_anchor_fails_whole() { + let (mut doc, keys) = honest_doc([0x11; 32], 5, 300, t0()); + // The document is signed over one head; the anchor (same chain, same + // count/cumulative) restates a different head — the aggregate no longer + // matches, so moving the head under a real anchor is caught. + let seed_id = activity_seed_id(&doc); + doc.anchor = Some(finalized_matching(seed_id, 5, [0x22; 32], 300, 3, 2)); + let err = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap_err(); + assert!( + matches!( + err, + EvidenceError::AnchorInvalid { + code: "aggregate-mismatch", + .. + } + ), + "{err:?}" + ); +} + +// ---- freshness against an injected clock ---- + +#[test] +fn future_as_of_is_rejected() { + let future = t0() + Duration::hours(1); + let (doc, keys) = honest_doc([0x11; 32], 1, 100, future); + let err = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap_err(); + assert!( + matches!(err, EvidenceError::Input(ref m) if m.contains("in the future")), + "{err:?}" + ); +} + +#[test] +fn recent_standalone_reads_unknown() { + let (doc, keys) = honest_doc([0x11; 32], 1, 100, t0()); + let now = t0() + Duration::hours(1); + let verdict = + verify_activity_with_keys(&doc, &keys, now, &VerifyActivityOpts::default()).unwrap(); + assert_eq!(verdict.freshness, Freshness::Unknown); + assert!(verdict.anchor.is_none()); +} + +#[test] +fn stale_standalone_reads_stale() { + let (doc, keys) = honest_doc([0x11; 32], 1, 100, t0()); + let now = t0() + Duration::hours(48); + let verdict = + verify_activity_with_keys(&doc, &keys, now, &VerifyActivityOpts::default()).unwrap(); + assert_eq!(verdict.freshness, Freshness::Stale); +} + +#[test] +fn require_witness_denies_unconfirmable() { + // An unanchored, recent document under a witness-tier demand fails — the + // anchor is required and absent, so it never reaches a soft freshness pass. + let (doc, keys) = honest_doc([0x11; 32], 1, 100, t0()); + let now = t0() + Duration::hours(1); + assert!(verify_activity_with_keys(&doc, &keys, now, &require_witness()).is_err()); +} + +// ---- anchored / never-anchored / stale distinguishable on the verdict ---- + +#[test] +fn unanchored_verdict_anchor_is_null() { + let (doc, keys) = honest_doc([0x11; 32], 1, 100, t0()); + let now = t0() + Duration::hours(1); + let verdict = + verify_activity_with_keys(&doc, &keys, now, &VerifyActivityOpts::default()).unwrap(); + assert!(verdict.anchor.is_none()); + assert_eq!(verdict.freshness, Freshness::Unknown); + assert!(!verdict.head_bound); +} + +#[test] +fn anchored_live_verdict_is_witness_tier_and_not_stale() { + let (mut doc, keys) = honest_doc([0x11; 32], 4, 400, t0()); + attach_matching_anchor(&mut doc); + let now = t0() + Duration::hours(1); + let verdict = + verify_activity_with_keys(&doc, &keys, now, &VerifyActivityOpts::default()).unwrap(); + let anchor = verdict.anchor.expect("anchored"); + assert_eq!(anchor.tier, "witness"); + assert!(!anchor.stale); + assert_eq!(verdict.freshness, Freshness::Fresh); + assert!(verdict.head_bound); +} + +#[test] +fn anchored_but_witness_moved_on_is_stale() { + let (mut doc, keys) = honest_doc([0x11; 32], 4, 400, t0()); + attach_matching_anchor(&mut doc); + let now = t0() + Duration::hours(1); + let opts = VerifyActivityOpts { + require_witness: false, + witness_tip_index: Some(doc.count + 1), + }; + let verdict = verify_activity_with_keys(&doc, &keys, now, &opts).unwrap(); + let anchor = verdict.anchor.expect("anchored"); + assert!(anchor.stale); + assert_eq!(verdict.freshness, Freshness::Stale); +} + +// ---- an unanchored aggregate is never laundered into a witnessed fact ---- + +#[test] +fn genuinely_signed_false_aggregate_stays_unwitnessed() { + // A $500k aggregate over a trivial log, signed by a genuine key: the + // signature verifies, but with no anchor the tier proves it is unwitnessed — + // the magnitude is present but not presented as a proven fact. + let (doc, keys) = honest_doc([0x11; 32], 2, 50_000_000, t0()); + let verdict = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap(); + assert!( + verdict.anchor.is_none(), + "an unanchored aggregate is first-seen, never witnessed" + ); +} + +// ---- head binding ---- + +#[test] +fn fabricated_head_is_not_head_bound() { + // A fabricated 32-byte head verifies (it rides in the signed body) but is not + // bound — no witness anchor cosigns it. + let (doc, keys) = honest_doc([0xde; 32], 1, 100, t0()); + let verdict = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap(); + assert!(!verdict.head_bound); +} + +#[test] +fn anchored_head_is_head_bound() { + let (mut doc, keys) = honest_doc([0x11; 32], 4, 400, t0()); + attach_matching_anchor(&mut doc); + let verdict = + verify_activity_with_keys(&doc, &keys, t0(), &VerifyActivityOpts::default()).unwrap(); + assert!(verdict.head_bound); +} + +// ---- the signature binds the content: editing any number breaks it ---- + +#[test] +fn inflated_cumulative_with_old_signature_fails() { + let (mut doc, keys) = honest_doc([0x11; 32], 2, 300, t0()); + doc.cumulative_cents = 50_000_000; + assert!(matches!( + verify_activity(&doc, &keys, false), + Err(EvidenceError::Input(_)) + )); +} + +#[test] +fn lowered_cumulative_with_old_signature_fails() { + let (mut doc, keys) = honest_doc([0x11; 32], 2, 300, t0()); + doc.cumulative_cents = 1; + assert!(matches!( + verify_activity(&doc, &keys, false), + Err(EvidenceError::Input(_)) + )); +} + +#[test] +fn edited_count_with_old_signature_fails() { + let (mut doc, keys) = honest_doc([0x11; 32], 2, 300, t0()); + doc.count = 99; + assert!(matches!( + verify_activity(&doc, &keys, false), + Err(EvidenceError::Input(_)) + )); +} diff --git a/crates/auths-evidence/tests/cases/judge.rs b/crates/auths-evidence/tests/cases/judge.rs index 963f85437..45943ec58 100644 --- a/crates/auths-evidence/tests/cases/judge.rs +++ b/crates/auths-evidence/tests/cases/judge.rs @@ -1,4 +1,4 @@ -//! Threat-model rows that are pure judge arithmetic (plan RC-E5.2 rows 6, 16, +//! Threat-model rows that are pure judge arithmetic (rows 6, 16, //! 17, 20, 21 + the revocation/expiry legs): total functions over proven facts, //! exercised over synthetic facts — no crypto here by design, the walk already //! proved them. @@ -70,6 +70,7 @@ fn fact(index: usize, verdict: Verdict, before: u64, signed: Option) -> Rec binding: format!("binding-{index}"), settled_cents_before: Cents::new(before), signed_cents: signed.map(Cents::new), + counterparty: None, } } @@ -287,6 +288,7 @@ fn out_of_window_call_is_expired() { fn tampered_walk_grounds_no_call() { let mut fx = clean_fixture(); fx.audit = AuditVerdict::TamperedProof { + at: 0, proof_ref: "deadbeef".to_string(), }; assert_eq!( diff --git a/crates/auths-evidence/tests/cases/mod.rs b/crates/auths-evidence/tests/cases/mod.rs index edd01a94f..2cecff0fc 100644 --- a/crates/auths-evidence/tests/cases/mod.rs +++ b/crates/auths-evidence/tests/cases/mod.rs @@ -1,2 +1,3 @@ pub mod anchor; +pub mod attestation; pub mod judge; diff --git a/crates/auths-id/src/error.rs b/crates/auths-id/src/error.rs index 04e4fa089..6b554febe 100644 --- a/crates/auths-id/src/error.rs +++ b/crates/auths-id/src/error.rs @@ -130,7 +130,11 @@ impl AuthsErrorInfo for InitError { #[cfg(feature = "git-storage")] Self::Git(_) => Some("Check that the Git repository is accessible"), Self::Keri(_) => Some("KERI event processing failed; check identity state"), - Self::Key(_) => Some("Check keychain access and passphrase"), + Self::Key(_) => Some( + "Check keychain access and passphrase. Headless/CI (no Touch ID): set \ + AUTHS_KEYCHAIN_BACKEND=file AUTHS_KEYCHAIN_FILE= AUTHS_PASSPHRASE=, \ + or run `auths init --profile ci`.", + ), Self::InvalidData(_) => { Some("Identity data is malformed; try re-initializing with `auths init`") } diff --git a/crates/auths-mcp-core/Cargo.toml b/crates/auths-mcp-core/Cargo.toml index 58fb3f389..931896f35 100644 --- a/crates/auths-mcp-core/Cargo.toml +++ b/crates/auths-mcp-core/Cargo.toml @@ -39,6 +39,8 @@ chrono = { version = "0.4", features = ["serde"] } # The cross-rail budget engine's tests (reserve/settle/release, cross-rail summing, # rollback) drive the verifier-held SETTLED counter over a throwaway repo dir. tempfile = "3" +# The audit-walk tests drive the async re-derivation (`audit_spend_log`) to a verdict. +tokio = { workspace = true } [lints] workspace = true diff --git a/crates/auths-mcp-core/src/audit.rs b/crates/auths-mcp-core/src/audit.rs index 19e8b41ab..b752dcb9e 100644 --- a/crates/auths-mcp-core/src/audit.rs +++ b/crates/auths-mcp-core/src/audit.rs @@ -80,8 +80,11 @@ pub struct SpendLogRecord { /// Raw bytes of the agent's signed `tools/call` proof commit — retained so the audit /// re-verifies it offline rather than trusting the receipt's `proof_ref` SHA. pub call_commit: Vec, - /// The per-call receipt — the operator's CLAIM. An untrusted hint, cross-checked against the - /// signed material; never an input to the audited total. + /// UNVERIFIED operator display — a hint, cross-checked against the signed material, never an + /// input to the audited total. Serialized as `unverified_display` (not a bare `receipt`) so no + /// reader mistakes it for vouched data: rewriting `verdict`/`tool`/`reserved_cents` here — + /// every such mutation still audits clean, because the wire never said the block was unverified. + #[serde(rename = "unverified_display")] pub receipt: Receipt, /// The settlement state of this call: [`Settlement::Unmetered`] when it touched no rail, else /// [`Settlement::Metered`] carrying the rail and whatever settlement artifacts it reached. @@ -111,15 +114,31 @@ impl DurableSettled { pub struct ConsistentProof { calls: usize, settled_cents: Cents, + /// The final commit binding the log re-derived to — the anti-rollback anchor a caller + /// pins against a witness to detect a tail truncation offline. `None` for an empty log. + #[serde(default)] + head: Option, + /// The VERIFIED counterparty the last metered call settled with (the payee signed into + /// `Auths-Settle-Ref`, cross-checked against the rail response) — never `receipt.charge_ref`. + /// `None` when nothing metered settled. + #[serde(default)] + counterparty: Option, } impl ConsistentProof { /// Mint a proof — `pub(crate)` so ONLY this crate's audit, after every check has passed, can /// construct one. - pub(crate) fn new(calls: usize, settled_cents: Cents) -> Self { + pub(crate) fn new( + calls: usize, + settled_cents: Cents, + head: Option, + counterparty: Option, + ) -> Self { Self { calls, settled_cents, + head, + counterparty, } } @@ -133,6 +152,18 @@ impl ConsistentProof { pub fn settled_cents(&self) -> Cents { self.settled_cents } + + /// The final commit binding the log re-derived to — the head a caller pins against a witness + /// (offline audit cannot prove completeness; pinning this detects a later rollback). + pub fn head(&self) -> Option<&str> { + self.head.as_deref() + } + + /// The verified counterparty the last metered call settled with (`Auths-Settle-Ref`), or + /// `None` when nothing metered settled. + pub fn counterparty(&self) -> Option<&str> { + self.counterparty.as_deref() + } } /// The typed result of an offline spend audit. Every failure mode is a NAMED case the caller @@ -147,19 +178,37 @@ pub enum AuditVerdict { /// durable verifier-held counter. Carries the proof of that re-derivation. Consistent(ConsistentProof), /// A call or settlement proof failed `verify_commit_against_kel_scoped` (forged, altered, or - /// signed-after-revocation). `proof_ref` is the offending commit. + /// signed-after-revocation). `at` is the record's positional index (an auditor-fixed number a + /// tamperer cannot choose); `proof_ref` is the offending commit's operator-forgeable display id, + /// kept only as a secondary hint. TamperedProof { - /// The proof reference (commit SHA) that failed verification. + /// The record index that failed verification — the primary, un-forgeable locator. + at: usize, + /// The proof reference (commit SHA) that failed verification — a secondary hint. proof_ref: String, }, /// A settlement commit's SIGNED cost disagrees with the cost re-extracted from the /// recorded rail response — the operator signed one number but logged another response. CostMismatch { + /// The record index at fault — the primary, un-forgeable locator. + at: usize, /// The cost the agent SIGNED in the settlement commit. signed_cents: Cents, /// The cost re-extracted from the recorded rail response. recomputed_cents: Cents, - /// The settlement commit at fault. + /// The settlement commit at fault — a secondary hint. + proof_ref: String, + }, + /// The rail response's charge id disagrees with the counterparty the agent SIGNED in + /// `Auths-Settle-Ref` — the recorded payee was rewritten after signing. + CounterpartyMismatch { + /// The record index at fault — the primary, un-forgeable locator. + at: usize, + /// The counterparty the agent SIGNED in `Auths-Settle-Ref`. + signed_counterparty: String, + /// The counterparty the recorded rail response actually paid. + response_counterparty: String, + /// The settlement commit at fault — a secondary hint. proof_ref: String, }, /// The re-derived cross-rail total (summed from the SIGNED costs) disagrees with the @@ -170,10 +219,17 @@ pub enum AuditVerdict { /// The cumulative the operator's counter/receipt claimed. claimed_cents: Cents, }, - /// The signed proof chain has a gap at record index `at` — a call was dropped or reordered. - DroppedCall { + /// The signed proof chain broke at record index `at` — a record is missing, out of order, or + /// duplicated (distinguished by `kind`). `more` counts any further breaks past the first, so a + /// combined middle-delete + tail-truncation reports both, not only the first gap. + ChainBreak { /// The record index where continuity broke. at: usize, + /// What kind of break it is — missing, reordered, or duplicated. + kind: ChainBreakKind, + /// How many additional breaks the walk found past this one. + #[serde(default)] + more: usize, }, /// The agent's delegation was revoked as of record index `at`; calls at/after it are /// unauthorized. @@ -183,6 +239,22 @@ pub enum AuditVerdict { }, } +/// How a spend-log chain broke, decided off the set of commit bindings already seen: a +/// prev-link into nowhere is [`ChainBreakKind::Missing`], a prev-link back into the log is +/// [`ChainBreakKind::OutOfOrder`], and a re-seen binding is [`ChainBreakKind::Duplicate`]. +/// A duplicate is an EXTRA record and a reorder drops NOTHING — distinct attacks the old +/// single `dropped-call` code conflated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChainBreakKind { + /// The record's `Auths-Prev` points to a binding the log does not hold — a record was dropped. + Missing, + /// The record's `Auths-Prev` points BACK into the log — records were reordered. + OutOfOrder, + /// The record's own binding was already seen earlier — a record was duplicated. + Duplicate, +} + impl AuditVerdict { /// True ONLY for [`AuditVerdict::Consistent`] — the audit passed. pub fn is_consistent(&self) -> bool { @@ -192,11 +264,15 @@ impl AuditVerdict { /// A stable kebab-case code (for logs, the CLI, and exit-code mapping). pub fn code(&self) -> &'static str { match self { - AuditVerdict::Consistent(_) => "consistent", + // "self-consistent", never a bare "consistent": an offline audit proves the log + // agrees with itself, NOT that it is complete (a $0/refused tail truncation is + // invisible offline). Completeness needs a witnessed head pin — see Display. + AuditVerdict::Consistent(_) => "self-consistent", AuditVerdict::TamperedProof { .. } => "tampered-proof", AuditVerdict::CostMismatch { .. } => "cost-mismatch", + AuditVerdict::CounterpartyMismatch { .. } => "counterparty-mismatch", AuditVerdict::BudgetMismatch { .. } => "budget-mismatch", - AuditVerdict::DroppedCall { .. } => "dropped-call", + AuditVerdict::ChainBreak { .. } => "chain-break", AuditVerdict::Revoked { .. } => "revoked", } } @@ -207,24 +283,41 @@ impl fmt::Display for AuditVerdict { match self { AuditVerdict::Consistent(proof) => write!( f, - "consistent — {} call(s), ${}.{:02} re-derived from signed costs", + "self-consistent — {} call(s), ${}.{:02} re-derived from signed costs \ + (completeness unproven offline — pin head {} against a witness to detect rollback)", proof.calls(), proof.settled_cents().get() / 100, - proof.settled_cents().get() % 100 + proof.settled_cents().get() % 100, + proof.head().unwrap_or(""), ), - AuditVerdict::TamperedProof { proof_ref } => { - write!(f, "tampered-proof — {proof_ref} failed verification") + AuditVerdict::TamperedProof { at, proof_ref } => { + write!( + f, + "tampered-proof — record {at} failed verification (proof_ref hint {proof_ref})" + ) } AuditVerdict::CostMismatch { + at, signed_cents, recomputed_cents, proof_ref, } => write!( f, - "cost-mismatch — {proof_ref} signed {}c but the rail response is {}c", + "cost-mismatch — record {at} signed {}c but the rail response is {}c \ + (proof_ref hint {proof_ref})", signed_cents.get(), recomputed_cents.get() ), + AuditVerdict::CounterpartyMismatch { + at, + signed_counterparty, + response_counterparty, + proof_ref, + } => write!( + f, + "counterparty-mismatch — record {at} signed payee {signed_counterparty} \ + but the rail response paid {response_counterparty} (proof_ref hint {proof_ref})" + ), AuditVerdict::BudgetMismatch { recomputed_cents, claimed_cents, @@ -234,8 +327,17 @@ impl fmt::Display for AuditVerdict { recomputed_cents.get(), claimed_cents.get() ), - AuditVerdict::DroppedCall { at } => { - write!(f, "dropped-call — chain gap at record {at}") + AuditVerdict::ChainBreak { at, kind, more } => { + let extra = if *more > 0 { + format!(" (plus {more} more)") + } else { + String::new() + }; + write!( + f, + "chain-break — record {at}: {kind:?}{extra} \ + (re-fetch the canonical log and diff)" + ) } AuditVerdict::Revoked { at } => { write!(f, "revoked — delegation revoked as of record {at}") @@ -330,16 +432,105 @@ pub fn read_spend_log(path: &Path) -> std::io::Result> { let mut records = Vec::new(); for file in files { let raw = std::fs::read_to_string(&file)?; - for line in raw.lines().filter(|l| !l.trim().is_empty()) { - records.push( - serde_json::from_str::(line) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?, - ); + let non_empty: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); + for (line_idx, line) in non_empty.iter().enumerate() { + match serde_json::from_str::(line) { + Ok(record) => records.push(record), + // A bad line is a READ failure, never a `tampered-proof` — the audit must SPEAK + // (corrupt / not-JSONL / crash-truncated), not emit raw serde noise. + Err(e) => { + let is_last = line_idx + 1 == non_empty.len(); + let malformed = LogReadError::classify(records.len() + 1, is_last, &e); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + malformed.to_string(), + )); + } + } } } Ok(records) } +/// Why a spend log could not be READ — a first-class, non-tamper outcome that SPEAKS the cause so a +/// naive operator who `jq .`'d their own log (or hit a crash-truncated tail) gets a fix, never raw +/// serde noise mistaken for fraud. Kept firmly OUT of the `tampered-proof`/`cost-mismatch` family +/// (corruption is not conflated with tampering). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogReadError { + /// A record spans multiple lines — almost always a `jq .` pretty-print. JSONL is one record + /// per line. + NotOneLineJson { + /// The 1-based record position that could not be read as one line. + record: usize, + }, + /// The final record is a partial write — the file was cut off (a crash mid-append). + TruncatedRecord { + /// The 1-based record position that was truncated. + record: usize, + }, + /// A record is neither multiline nor a clean truncation — some other corruption. + CorruptRecord { + /// The 1-based record position that could not be parsed. + record: usize, + /// The underlying parse detail. + detail: String, + }, +} + +impl LogReadError { + /// Classify a failed line parse off the serde error and whether it was the LAST non-empty line: + /// an unexpected-end error on a NON-last line is a multiline (`jq .`) record; on the LAST line + /// it is a crash-truncated tail; anything else is other corruption. + /// + /// Args: + /// * `record`: the 1-based record position that failed. + /// * `is_last`: whether the failing line was the last non-empty line of its file. + /// * `err`: the serde parse error. + /// + /// Usage: + /// ```ignore + /// let malformed = LogReadError::classify(3, true, &serde_err); + /// ``` + pub fn classify(record: usize, is_last: bool, err: &serde_json::Error) -> LogReadError { + if err.is_eof() { + if is_last { + LogReadError::TruncatedRecord { record } + } else { + LogReadError::NotOneLineJson { record } + } + } else { + LogReadError::CorruptRecord { + record, + detail: err.to_string(), + } + } + } +} + +impl fmt::Display for LogReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LogReadError::NotOneLineJson { record } => write!( + f, + "malformed-log — record {record} is not one-line JSON \ + (did you `jq .` this? JSONL is one record per line)" + ), + LogReadError::TruncatedRecord { record } => write!( + f, + "malformed-log — the final record ({record}) is truncated; \ + the file may have been cut off by a crash" + ), + LogReadError::CorruptRecord { record, detail } => { + write!( + f, + "malformed-log — record {record} could not be parsed: {detail}" + ) + } + } + } +} + /// A filesystem-safe single component from a delegation id: strip the `did:keri:` scheme and map /// anything that is not `[A-Za-z0-9_-]` to `_` (defensive — a `did:keri:E…` tail is base64url and /// already safe; this only guards a malformed key from escaping the directory). @@ -478,6 +669,10 @@ pub struct RecordFact { /// The agent-signed (facilitator-attested when available) settled cost of this /// record — `None` for an unmetered or zero-cost call. pub signed_cents: Option, + /// The VERIFIED counterparty this record settled with — the payee signed into + /// `Auths-Settle-Ref` and cross-checked against the rail response. `None` for an unmetered + /// or zero-cost call. A consumer acts on THIS, never on the operator-controlled display. + pub counterparty: Option, } /// An audit verdict together with the per-record [`RecordFact`]s the walk established @@ -539,9 +734,25 @@ async fn audit_walk( // The binding each record's `Auths-Prev` must match — the prior record's commit hash, the // resumed prefix's final binding, or the genesis sentinel for a from-the-top audit. let mut expected_prev = resume.prior_binding.clone(); + // Every call binding seen so far — a re-seen OWN binding is a duplicate. + let mut seen_bindings: std::collections::HashSet = std::collections::HashSet::new(); + // Every binding the log holds (any position). A broken prev-link that STILL points into this + // set is a reorder (the record exists, just not as the immediate predecessor); one that points + // NOWHERE in the set is a genuinely missing/dropped record. + let all_bindings: std::collections::HashSet = records + .iter() + .map(|r| call_commit_binding(&r.call_commit)) + .collect(); + // Structural breaks are collected (not returned on the first) so a combined middle-delete + + // tail-truncation reports both; the first is the verdict, the rest are the `more` count. + let mut breaks: Vec<(usize, ChainBreakKind)> = Vec::new(); + // The last verified counterparty (`Auths-Settle-Ref`) — surfaced on the ConsistentProof so a + // consumer acts on the proven payee, never the operator-controlled display. + let mut last_counterparty: Option = None; for (i, rec) in records.iter().enumerate() { let settled_before = settled; let mut fact_signed_cents: Option = None; + let mut fact_counterparty: Option = None; // Re-verify the SIGNED proof bytes — the gate's own authenticity check, re-run offline. let commit_verdict = auths_verifier::verify_commit_against_kel_scoped( &rec.call_commit, @@ -559,6 +770,7 @@ async fn audit_walk( match &verdict { crate::gate::Verdict::ProofUnauthentic { .. } => { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; } @@ -569,14 +781,29 @@ async fn audit_walk( _ => {} } // Continuity: each record's SIGNED `Auths-Prev` links to the prior record's commit (the - // first to the genesis sentinel). A DROPPED or reordered record breaks the chain and is - // caught here — only an EDITED record was caught before (via its broken signature). The - // proof was just verified authentic, so this trailer is signed and trustworthy. + // first to the genesis sentinel). A DROPPED, reordered, or DUPLICATED record breaks the + // chain — distinguished by whether the prev-link points nowhere (missing), back into the + // log (reorder), or the record's own binding was already seen (duplicate). The proof was + // just verified authentic, so this trailer is signed and trustworthy. + let this_binding = call_commit_binding(&rec.call_commit); let claimed_prev = commit_trailer(&rec.call_commit, "Auths-Prev").unwrap_or(""); if claimed_prev != expected_prev { - return AuditVerdict::DroppedCall { at: i }; + let kind = if seen_bindings.contains(&this_binding) { + ChainBreakKind::Duplicate + } else if all_bindings.contains(claimed_prev) { + ChainBreakKind::OutOfOrder + } else { + ChainBreakKind::Missing + }; + breaks.push((i, kind)); + // Resync so the walk continues past the break, counting any further breaks rather than + // reporting only the first — still fail-closed (any break blocks `Consistent`). + seen_bindings.insert(this_binding.clone()); + expected_prev = this_binding; + continue; } - expected_prev = call_commit_binding(&rec.call_commit); + seen_bindings.insert(this_binding.clone()); + expected_prev = this_binding; // Sum the settled cost for a call that (a) carries an AUTHENTIC, IN-SCOPE proof — // `Allowed`/`AgentExpired`, both PROOF-DETERMINED, so the operator cannot relabel a settled // call as refused without breaking its signature (`OutsideAgentScope` never settled) — AND @@ -595,26 +822,29 @@ async fn audit_walk( { let rail = rail.as_str(); let resp = resp.as_slice(); - // The cost the rail's own recorded response reports. The response is operator-held and - // unsigned, so it is only a cross-check — the authoritative amount is the one the agent - // SIGNED in the settlement below. - let recomputed = match crate::rail::extract(rail, resp) { - Ok(c) => c.amount_cents, + // The cost + reference the rail's own recorded response reports. The response is + // operator-held and unsigned, so it is only a cross-check — the authoritative amount + // and payee are the ones the agent SIGNED in the settlement below. + let extracted = match crate::rail::extract(rail, resp) { + Ok(c) => c, // A settled call whose recorded response no longer extracts is a tampered response. Err(_) => { return AuditVerdict::CostMismatch { + at: i, signed_cents: Cents::ZERO, recomputed_cents: Cents::ZERO, proof_ref: rec.receipt.proof_ref.clone(), }; } }; + let recomputed = extracted.amount_cents; // A non-zero settled cost MUST come from a settlement the agent signed. Requiring it // closes the downgrade where an operator strips the settlement and falls back to a rail // response it authored. (A zero-cost forwarded call settles nothing and needs none.) if !recomputed.is_zero() { let Some(settle_commit) = settlement_commit.as_deref() else { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; }; @@ -634,6 +864,7 @@ async fn audit_walk( crate::gate::Verdict::Allowed | crate::gate::Verdict::AgentExpired ) { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; } @@ -644,10 +875,25 @@ async fn audit_walk( != Some(call_commit_binding(&rec.call_commit).as_str()) { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; } - // 3. The agent-signed cost, cross-checked against the rail's own response — a + // 3. The COUNTERPARTY: the payee the agent SIGNED in `Auths-Settle-Ref` must equal + // the charge id the rail response actually paid. A disagreement means the recorded + // payee was rewritten after signing — the committed-but-unaudited counterparty. + let signed_ref = commit_trailer(settle_commit, "Auths-Settle-Ref").unwrap_or(""); + if signed_ref != extracted.reference { + return AuditVerdict::CounterpartyMismatch { + at: i, + signed_counterparty: signed_ref.to_string(), + response_counterparty: extracted.reference.clone(), + proof_ref: rec.receipt.proof_ref.clone(), + }; + } + fact_counterparty = Some(signed_ref.to_string()); + last_counterparty = fact_counterparty.clone(); + // 4. The agent-signed cost, cross-checked against the rail's own response — a // disagreement means the operator swapped the response (or the signed amount). // The signed cents trailer is a decimal string — parse to u64 then wrap at this // commit-trailer boundary. @@ -656,11 +902,13 @@ async fn audit_walk( .map(Cents::new) else { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; }; if signed != recomputed { return AuditVerdict::CostMismatch { + at: i, signed_cents: signed, recomputed_cents: recomputed, proof_ref: rec.receipt.proof_ref.clone(), @@ -682,12 +930,14 @@ async fn audit_walk( Ok(a) => a, Err(_) => { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; } }; if attested.amount() != signed { return AuditVerdict::CostMismatch { + at: i, signed_cents: signed, recomputed_cents: attested.amount(), proof_ref: rec.receipt.proof_ref.clone(), @@ -699,7 +949,7 @@ async fn audit_walk( }; settled = settled.saturating_add(summand); fact_signed_cents = Some(summand); - // 4. The signed running total ties the cumulative to signed material, so the budget + // 5. The signed running total ties the cumulative to signed material, so the budget // leg does not rest on the operator's own (unsigned) receipt cumulative. The // trailer is a decimal string — parse to u64 then wrap at this boundary. let Some(signed_cumulative) = @@ -708,6 +958,7 @@ async fn audit_walk( .map(Cents::new) else { return AuditVerdict::TamperedProof { + at: i, proof_ref: rec.receipt.proof_ref.clone(), }; }; @@ -726,9 +977,19 @@ async fn audit_walk( binding: expected_prev.clone(), settled_cents_before: settled_before, signed_cents: fact_signed_cents, + counterparty: fact_counterparty, }); } } + // A structural break (missing / reordered / duplicated record) blocks a clean verdict — report + // the first with a count of any further breaks, BEFORE the budget/counter cross-checks below. + if let Some(&(at, kind)) = breaks.first() { + return AuditVerdict::ChainBreak { + at, + kind, + more: breaks.len().saturating_sub(1), + }; + } // The operator's claimed cross-rail total is the last record's cumulative — an UNTRUSTED hint we // compare against the cost we re-derived from the rail responses. An EMPTY resumed suffix has // no new claim to compare (the prefix's claim was checked when it was verified); the durable @@ -769,6 +1030,10 @@ async fn audit_walk( AuditVerdict::Consistent(ConsistentProof::new( resume.prior_records + records.len(), settled, + // The head the caller pins against a witness to detect a later rollback (offline audit + // cannot prove completeness). `None` for an empty log — nothing to be consistent with. + records.last().map(|r| call_commit_binding(&r.call_commit)), + last_counterparty, )) } @@ -838,17 +1103,25 @@ mod tests { #[test] fn audit_verdict_code_and_is_consistent() { - let ok = AuditVerdict::Consistent(ConsistentProof::new(3, Cents::new(450))); + let ok = AuditVerdict::Consistent(ConsistentProof::new( + 3, + Cents::new(450), + Some("deadbeefhead".to_string()), + None, + )); assert!(ok.is_consistent()); - assert_eq!(ok.code(), "consistent"); + // The offline audit proves self-consistency, never completeness — the code says so. + assert_eq!(ok.code(), "self-consistent"); let bad = AuditVerdict::TamperedProof { + at: 0, proof_ref: "deadbeef".into(), }; assert!(!bad.is_consistent()); assert_eq!(bad.code(), "tampered-proof"); assert_eq!( AuditVerdict::CostMismatch { + at: 1, signed_cents: Cents::new(10), recomputed_cents: Cents::new(5), proof_ref: "s".into() @@ -856,13 +1129,87 @@ mod tests { .code(), "cost-mismatch" ); - assert_eq!(AuditVerdict::DroppedCall { at: 2 }.code(), "dropped-call"); + assert_eq!( + AuditVerdict::ChainBreak { + at: 2, + kind: ChainBreakKind::Missing, + more: 0 + } + .code(), + "chain-break" + ); assert_eq!(AuditVerdict::Revoked { at: 4 }.code(), "revoked"); } + #[test] + fn consistent_display_carries_the_completeness_caveat_and_head() { + // The success line must never be a bare `consistent` — it names self-consistency, the + // completeness caveat, and the head to pin against a witness. + let proof = ConsistentProof::new(7, Cents::new(0), Some("abc123head".to_string()), None); + let shown = AuditVerdict::Consistent(proof).to_string(); + assert!(shown.starts_with("self-consistent"), "{shown}"); + assert!(shown.contains("completeness unproven"), "{shown}"); + assert!(shown.contains("abc123head"), "{shown}"); + // An empty log names `` as the head — an empty log proves nothing. + let empty = ConsistentProof::new(0, Cents::ZERO, None, None); + let shown_empty = AuditVerdict::Consistent(empty).to_string(); + assert!(shown_empty.contains("0 call(s)"), "{shown_empty}"); + assert!( + shown_empty.contains("completeness unproven"), + "{shown_empty}" + ); + assert!(shown_empty.contains(""), "{shown_empty}"); + } + + #[test] + fn counterparty_mismatch_code_and_display() { + let v = AuditVerdict::CounterpartyMismatch { + at: 3, + signed_counterparty: "ch_3Mml".into(), + response_counterparty: "ch_ATTACKERxxx".into(), + proof_ref: "deadbeef".into(), + }; + assert_eq!(v.code(), "counterparty-mismatch"); + let shown = v.to_string(); + assert!(shown.contains("record 3"), "{shown}"); + assert!(shown.contains("ch_3Mml"), "{shown}"); + assert!(shown.contains("ch_ATTACKERxxx"), "{shown}"); + } + + #[test] + fn tampered_proof_display_leads_with_the_positional_index() { + // A tamperer forges `proof_ref` to any string; the auditor-fixed positional `at` leads the + // Display so the record named is not attacker-chosen. + let v = AuditVerdict::TamperedProof { + at: 5, + proof_ref: "deadbeefATTACKER".into(), + }; + let shown = v.to_string(); + assert!(shown.starts_with("tampered-proof — record 5"), "{shown}"); + } + + #[test] + fn chain_break_kinds_are_distinct_on_the_wire() { + for kind in [ + ChainBreakKind::Missing, + ChainBreakKind::OutOfOrder, + ChainBreakKind::Duplicate, + ] { + let v = AuditVerdict::ChainBreak { + at: 0, + kind, + more: 0, + }; + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("\"verdict\":\"chain-break\""), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), v); + } + } + #[test] fn audit_verdict_serde_roundtrips_tagged_kebab() { let v = AuditVerdict::CostMismatch { + at: 2, signed_cents: Cents::new(60), recomputed_cents: Cents::new(50), proof_ref: "p".into(), @@ -892,11 +1239,50 @@ mod tests { !line.contains('\n'), "a record must serialize to a single JSONL line" ); + // The operator's per-call block is named `unverified_display` on the wire — never a bare + // `receipt` — so no reader mistakes it for vouched data. + assert!( + line.contains("\"unverified_display\""), + "the operator block must announce it is unverified: {line}" + ); + assert!( + !line.contains("\"receipt\""), + "the wire must not carry a bare `receipt` key: {line}" + ); // Round-trips stably (Receipt isn't PartialEq, so compare the canonical serialization). let back: SpendLogRecord = serde_json::from_str(&line).unwrap(); assert_eq!(serde_json::to_string(&back).unwrap(), line); } + #[tokio::test] + async fn empty_log_audits_self_consistent_with_records_zero() { + // An empty log is NOT a clean bill of health — it re-derives self-consistent with a + // records:0 signal and the completeness caveat, never a bare `consistent — 0 call(s)`. + let verdict = audit_spend_log( + &[], + &[], + &[], + &["did:keri:Eroot".to_string()], + 0, + None, + None, + ) + .await; + match &verdict { + AuditVerdict::Consistent(proof) => { + assert_eq!(proof.calls(), 0); + assert!(proof.head().is_none(), "an empty log pins no head"); + } + other => panic!("empty log should be self-consistent, got {other:?}"), + } + let shown = verdict.to_string(); + assert!(shown.contains("completeness unproven"), "{shown}"); + assert!( + !shown.starts_with("consistent —"), + "no bare consistent: {shown}" + ); + } + #[test] fn non_metered_record_omits_rail_fields() { let rec = SpendLogRecord { @@ -938,15 +1324,69 @@ mod tests { // a corrupted/edited line fails closed, never silently drops a record std::fs::write(&path, format!("{line}\nnot json\n")).unwrap(); assert!(read_spend_log(&path).is_err()); + + // A pretty-printed (`jq .`) log — a record split across many lines — SPEAKS as a + // not-one-line-JSON malformed-log, never raw serde noise or `tampered-proof`. + let record: SpendLogRecord = serde_json::from_str(&line).unwrap(); + let pretty = serde_json::to_string_pretty(&record).unwrap(); + std::fs::write(&path, format!("{pretty}\n")).unwrap(); + let err = read_spend_log(&path).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("malformed-log"), "{msg}"); + assert!( + msg.contains("not one-line JSON") || msg.contains("jq"), + "{msg}" + ); + assert!( + !msg.contains("tampered"), + "a read error must not read as tamper: {msg}" + ); + + // A crash-truncated final record — the last line cut off mid-object — SPEAKS as truncated. + let half = &line[..line.len() / 2]; + std::fs::write(&path, format!("{line}\n{half}")).unwrap(); + let err = read_spend_log(&path).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("malformed-log") && msg.contains("truncated"), + "{msg}" + ); + } + + #[test] + fn log_read_error_classification_distinguishes_the_causes() { + // The typed classification: an unexpected-end on a NON-last + // line is a multiline `jq .` record; on the LAST line it is a crash-truncated tail; a + // value error is other corruption. All three are read errors, never `tampered-proof`. + let eof = serde_json::from_str::("{").unwrap_err(); + assert!(matches!( + LogReadError::classify(1, false, &eof), + LogReadError::NotOneLineJson { record: 1 } + )); + assert!(matches!( + LogReadError::classify(2, true, &eof), + LogReadError::TruncatedRecord { record: 2 } + )); + let bad = serde_json::from_str::("not json").unwrap_err(); + assert!(matches!( + LogReadError::classify(3, true, &bad), + LogReadError::CorruptRecord { record: 3, .. } + )); } #[test] fn commit_trailer_matches_the_token_exactly() { let commit = - b"tree abc\n\ntools/settle\n\nAuths-Settle-Call:def\nAuths-Settle-Cents: 175\nAuths-Settle-Cumulative:500\n"; + b"tree abc\n\ntools/settle\n\nAuths-Settle-Call:def\nAuths-Settle-Cents: 175\nAuths-Settle-Ref:ch_3MmlLrLkdIwHu7ix\nAuths-Settle-Cumulative:500\n"; // Exact token match, with or without a space after the colon. assert_eq!(commit_trailer(commit, "Auths-Settle-Cents"), Some("175")); assert_eq!(commit_trailer(commit, "Auths-Settle-Call"), Some("def")); + // The SIGNED payee reads back exactly — the counterparty the audit cross-checks against + // the rail response's charge id. + assert_eq!( + commit_trailer(commit, "Auths-Settle-Ref"), + Some("ch_3MmlLrLkdIwHu7ix") + ); // `Auths-Settle-Cents` must NOT match the longer `Auths-Settle-Cumulative` line. assert_eq!( commit_trailer(commit, "Auths-Settle-Cumulative"), diff --git a/crates/auths-mcp-core/src/budget.rs b/crates/auths-mcp-core/src/budget.rs index 3403dfd6b..fe15e8ab4 100644 --- a/crates/auths-mcp-core/src/budget.rs +++ b/crates/auths-mcp-core/src/budget.rs @@ -738,6 +738,33 @@ mod tests { assert_eq!(g.reserved_cents(), Cents::ZERO); } + #[test] + fn exact_exhaust_allowed_one_over_refused() { + // The budget boundary rule is locked here: the rule is strictly-over + // (`would_be > cap`), so a call that reserves EXACTLY to the cap is allowed and the next + // call — even 1 cent — is refused. This locks `>` against ever drifting to `>=`. + let dir = tempfile::tempdir().unwrap(); + let mut b = budget(dir.path(), 1000); + // Reserve+settle exactly to the cap (cumulative == cap): allowed. + let h = match b.reserve(Ceiling::new(Cents::new(1000))).unwrap() { + ReserveOutcome::Reserved { hold, .. } => hold, + o => panic!("the exact-exhaust reserve must be admitted, got {o:?}"), + }; + b.settle(h, Actual::new(Cents::new(1000))).unwrap(); + assert_eq!(b.settled_cents().unwrap(), Cents::new(1000)); + // One cent over the exhausted cap: refused BEFORE the rail is touched. + match b.reserve(Ceiling::new(Cents::new(1))).unwrap() { + ReserveOutcome::Refused { + cap_cents, + would_be_cents, + } => { + assert_eq!(cap_cents, Cents::new(1000)); + assert_eq!(would_be_cents, Cents::new(1001)); + } + o => panic!("one cent over the cap must be refused, got {o:?}"), + } + } + #[test] fn reserve_settle_releases_slack() { // Call reserves a $2.00 ceiling but settles $1.50 — the $0.50 slack must be diff --git a/crates/auths-mcp-core/src/gate.rs b/crates/auths-mcp-core/src/gate.rs index b44c56375..f880bfe11 100644 --- a/crates/auths-mcp-core/src/gate.rs +++ b/crates/auths-mcp-core/src/gate.rs @@ -249,6 +249,28 @@ impl PerCallGate { }) } + /// Re-resolve the delegator KEL (which carries the revocation/expiry seals) from the registry, + /// so a mid-session revocation propagates to a long-lived gateway within the caller's recheck + /// SLA instead of only on process restart. The gate resolves both KELs once at + /// construction and judges against that session-fixed snapshot forever; this refreshes the + /// delegator half — the one that gains a revocation seal — before the next judge. + /// + /// Args: + /// * `registry`: the issuer registry the delegator KEL is re-read from (the same one + /// [`PerCallGate::resolve`] used). + /// + /// Usage: + /// ```ignore + /// gate.refresh_delegator(®istry)?; // then gate.judge(...) sees a fresh revocation + /// ``` + pub fn refresh_delegator(&mut self, registry: &dyn RegistryBackend) -> Result<(), GateError> { + let chain = KelResolverChain::local(registry); + self.delegator_kel = chain.resolve_kel(&self.delegator_did).map_err(|e| { + GateError::GrantUnresolved(format!("delegator KEL refresh {}: {e}", self.delegator_did)) + })?; + Ok(()) + } + /// Independently re-audit a persisted spend log with THIS gate's resolved KELs — the offline /// [`crate::audit::audit_spend_log`] driven by the same agent/delegator KELs + pinned root the /// gate judges against. Lets the hermetic gate re-audit its own log end-to-end: after a run, @@ -557,6 +579,29 @@ mod tests { )); } + #[test] + fn refresh_delegator_fails_closed_when_unresolvable() { + // The mid-session revocation-propagation hook: `refresh_delegator` + // re-reads the delegator KEL from the registry. When the registry cannot resolve it, the + // refresh returns `GrantUnresolved` — the proxy turns that into a fail-closed call, never a + // silently-stale snapshot. (The revoke→Revoked behavior is locked end-to-end in the e2e + // `test_revocation_propagates_within_sla`, which needs the real signing toolchain.) + use auths_sdk::storage::{GitRegistryBackend, RegistryConfig}; + let dir = tempfile::tempdir().unwrap(); + let registry = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir.path())); + let mut gate = PerCallGate { + agent_did: "did:keri:Eagent".to_string(), + delegator_did: "did:keri:Eroot".to_string(), + agent_kel: Vec::new(), + delegator_kel: Vec::new(), + }; + let err = gate + .refresh_delegator(®istry) + .expect_err("an unresolvable delegator must fail closed"); + assert!(matches!(err, GateError::GrantUnresolved(_)), "{err:?}"); + } + #[test] fn valid_but_stale_is_stale_not_unauthentic() { // A valid proof whose freshness fails policy is Stale, not ProofUnauthentic: the proof IS diff --git a/crates/auths-mcp-core/src/lib.rs b/crates/auths-mcp-core/src/lib.rs index 1266b6468..4bb67efe5 100644 --- a/crates/auths-mcp-core/src/lib.rs +++ b/crates/auths-mcp-core/src/lib.rs @@ -45,10 +45,10 @@ pub mod treasury; pub use attestation::{AttestationError, Attested, RailAttestation}; pub use audit::{ - AnnotatedAudit, AuditResume, AuditVerdict, ConsistentProof, RecordFact, SPEND_LOG_GENESIS, - Settlement, SpendLogRecord, audit_spend_log, audit_spend_log_annotated, - audit_spend_log_resumed, call_commit_binding, read_spend_log, resolve_spend_log, spend_log_dir, - spend_log_path, spend_log_period_path, + AnnotatedAudit, AuditResume, AuditVerdict, ChainBreakKind, ConsistentProof, LogReadError, + RecordFact, SPEND_LOG_GENESIS, Settlement, SpendLogRecord, audit_spend_log, + audit_spend_log_annotated, audit_spend_log_resumed, call_commit_binding, read_spend_log, + resolve_spend_log, spend_log_dir, spend_log_path, spend_log_period_path, }; pub use budget::{ BudgetError, CounterKey, CounterRef, CrossRailBudget, FlushPolicy, Hold, ReserveOutcome, @@ -91,8 +91,24 @@ impl Capability { /// an unknown tool fails closed as out-of-scope rather than being waved through. pub fn for_tool(tool: &str) -> Self { let cap = match tool { - "read_file" | "read" | "fs.read" => "fs.read", - "write_file" | "write" | "fs.write" => "fs.write", + // The read FAMILY (every `readOnlyHint:true` filesystem tool), not just the one + // deprecated `read_file` — a modern MCP client told to "read a file" calls + // `read_text_file`/`list_directory`/`read_multiple_files`. Every name here is an + // EXPLICIT real tool; an unlisted near-miss still falls through to `tool.` below, + // so the exact-token boundary the delegator's scope enforces is unchanged. + "read_file" + | "read_text_file" + | "read_media_file" + | "read_multiple_files" + | "list_directory" + | "list_directory_with_sizes" + | "directory_tree" + | "get_file_info" + | "search_files" + | "read" + | "fs.read" => "fs.read", + "write_file" | "edit_file" | "create_directory" | "move_file" | "write" + | "fs.write" => "fs.write", "create_comment" | "comment" | "github.comment" => "github.comment", "paid_call" | "paid.call" => "paid.call", other => return Capability(format!("tool.{other}")), @@ -105,3 +121,67 @@ impl Capability { &self.0 } } + +#[cfg(test)] +mod tests { + use super::Capability; + + #[test] + fn fs_read_admits_the_read_family() { + // A `fs.read` grant covers the whole read family, not just the deprecated `read_file`. + for tool in [ + "read_file", + "read_text_file", + "read_media_file", + "read_multiple_files", + "list_directory", + "list_directory_with_sizes", + "directory_tree", + "get_file_info", + "search_files", + ] { + assert_eq!( + Capability::for_tool(tool).as_str(), + "fs.read", + "`{tool}` must map to fs.read" + ); + } + // The write family maps to fs.write. + for tool in ["write_file", "edit_file", "create_directory", "move_file"] { + assert_eq!( + Capability::for_tool(tool).as_str(), + "fs.write", + "`{tool}` must map to fs.write" + ); + } + // An unknown tool still fails closed to a synthetic capability the delegator never granted. + assert_eq!(Capability::for_tool("rm_rf").as_str(), "tool.rm_rf"); + } + + #[test] + fn capability_for_tool_never_folds_near_misses() { + // The tool→capability map is an EXACT-token match — no + // case-fold, no substring, no unicode-fold, no traversal. Every near-miss of `read_file` + // falls through to a synthetic `tool.*` the delegator never granted, so `--scope fs.read` + // refuses it. This MUST survive the read-family widening (which added only real tool names). + for near_miss in [ + "Read_File", // case + "read_file_secret", // suffix + "xread_file", // prefix + "reаd_file", // Cyrillic 'а' (U+0430), not ASCII 'a' + "../read_file", // path traversal + ] { + let cap = Capability::for_tool(near_miss); + assert!( + cap.as_str().starts_with("tool."), + "`{near_miss}` must fail closed to a synthetic tool.* capability, got {}", + cap.as_str() + ); + assert_ne!( + cap.as_str(), + "fs.read", + "`{near_miss}` must NOT fold to fs.read" + ); + } + } +} diff --git a/crates/auths-mcp-gateway/src/chain.rs b/crates/auths-mcp-gateway/src/chain.rs index d4673f52c..a55d02c90 100644 --- a/crates/auths-mcp-gateway/src/chain.rs +++ b/crates/auths-mcp-gateway/src/chain.rs @@ -132,7 +132,13 @@ impl Chain { /// The sandbox env (HOME, AUTHS_HOME/REPO, keychain, passphrase, git identity) /// must already be exported by the caller (the probe harness sets these); /// chain construction inherits them. - pub fn build(lab: &Path, scope: &[String]) -> anyhow::Result { + /// + /// `ttl_secs` is the grant's time-to-live in seconds (from `wrap --ttl`); when `Some`, it is + /// anchored as the delegation's `--expires-in` expiry seal so the verifier's expiry check can + /// fire — without it the `ttl=` the banner prints enforces nothing. It is applied ONLY + /// on a fresh delegation: a resumed agent keeps the expiry fixed at first delegation (a restart + /// cannot widen it). + pub fn build(lab: &Path, scope: &[String], ttl_secs: Option) -> anyhow::Result { let auths_bin = locate_auths()?; // A fleet gateway names its own delegation label so N wraps can join ONE // shared root registry (one label per gateway); the default single-wrap @@ -227,16 +233,48 @@ impl Chain { for cap in scope { add.args(["--scope", &to_auths_capability(cap)]); } - let added = must(&mut add, "id agent add (scoped agent)")?; - let agent_did = first_did(&added.stdout).ok_or_else(|| { - anyhow::anyhow!("could not read the agent did:keri from agent add") - })?; - - // 3. Materialize the agent's delegate machine: copy the org registry and - // drop the org icp root subtree, leaving only the agent dip so the local - // signer resolves to the agent (tree surgery only — no key moved). - materialize_agent_machine(&org_repo, &agent_repo, &root_did)?; - agent_did + // Anchor the TTL as an expiry seal so the verifier's expiry check (AgentExpired on + // `now >= expires_at`) actually fires. Without this the banner's `ttl=` bounds nothing. + if let Some(secs) = ttl_secs { + add.args(["--expires-in", &secs.to_string()]); + } + let added = run(&mut add)?; + if added.status.success() { + let agent_did = first_did(&added.stdout).ok_or_else(|| { + anyhow::anyhow!("could not read the agent did:keri from agent add") + })?; + // 3. Materialize the agent's delegate machine: copy the org registry and + // drop the org icp root subtree, leaving only the agent dip so the local + // signer resolves to the agent (tree surgery only — no key moved). + materialize_agent_machine(&org_repo, &agent_repo, &root_did)?; + agent_did + } else { + // Idempotency: a second wrap on the same keychain hits an EXISTING scoped + // agent under this alias. The agent DID must stay stable across restarts anyway, so + // RESUME it — re-materialize the delegate machine from the org registry — rather than + // failing the wrap with a bare error. If the delegation cannot be resumed from this + // registry (e.g. a fresh live dir), emit a coded, actionable remedy. + let stderr = String::from_utf8_lossy(&added.stderr); + let stdout = String::from_utf8_lossy(&added.stdout); + let alias_taken = + stderr.contains("already exists") || stdout.contains("already exists"); + if alias_taken { + materialize_agent_machine(&org_repo, &agent_repo, &root_did)?; + resume_agent_did(&agent_repo).ok_or_else(|| { + anyhow::anyhow!( + "a scoped agent already exists under alias `{agent_alias}`, but its \ + delegation could not be resumed from this registry — re-run with a \ + fresh `--agent-label` (or `AUTHS_MCP_AGENT_LABEL`) or a fresh \ + `AUTHS_KEYCHAIN_FILE`. Underlying: {stderr}" + ) + })? + } else { + anyhow::bail!( + "id agent add (scoped agent) failed (exit {:?}):\n{stdout}\n{stderr}", + added.status.code(), + ); + } + } }; let inproc = crate::inproc_sign::InprocState::new(&agent_alias); @@ -271,7 +309,7 @@ impl Chain { /// /// Usage: /// ```ignore - /// let chain = Chain::build(&lab, &scope)?; // warms internally + /// let chain = Chain::build(&lab, &scope, ttl_secs)?; // warms internally /// ``` pub fn warm_signing_templates(&self) { // Decrypt the session signing key once now (the Argon2id keychain unlock), so the diff --git a/crates/auths-mcp-gateway/src/main.rs b/crates/auths-mcp-gateway/src/main.rs index af73e5d5f..49bc644b7 100644 --- a/crates/auths-mcp-gateway/src/main.rs +++ b/crates/auths-mcp-gateway/src/main.rs @@ -275,6 +275,10 @@ struct WrapArgs { /// across every rail and tool by pre-authorization (reserve before the rail is /// touched, settle the actual after), so the live wire cannot allow a cross-rail /// call the gate refuses (#281). + /// + /// Boundary: a call that reserves EXACTLY to the cap (cumulative == cap) is allowed; the + /// next call — even 1 cent — is refused (`usage-cap-exceeded`). The rule is strictly-over: + /// `settled + holds + ceiling > cap` refuses before the rail is touched. #[arg(long = "budget", value_name = "BUDGET")] budget: Option, @@ -335,6 +339,12 @@ struct WrapArgs { #[arg(long = "dispute-ref", value_name = "REF")] dispute_ref: Option, + /// Where the signed spend log + registry are written (default: an ephemeral per-run OS temp + /// dir, wiped on reboot). Pass a stable dir to keep receipts across runs; the resolved path is + /// announced on startup (`spend-log: `) so it is never discoverable only from stderr noise. + #[arg(long = "spend-log", value_name = "DIR")] + spend_log: Option, + /// The downstream MCP server command (everything after `--`). #[arg(last = true, value_name = "DOWNSTREAM", required = true)] downstream: Vec, @@ -366,6 +376,12 @@ struct VerifySpendArgs { #[arg(long = "root", value_name = "DID")] root: String, + /// Print the portable `audit/v1` verdict object as ONE JSON line instead of the human lines — + /// so integrating the audit anywhere reads a typed object, never scraped stdout. Exits 0 only + /// when the report is consistent. + #[arg(long = "json")] + json: bool, + /// A treasury checkpoint trail (`checkpoints.jsonl`) to cross-check: every /// signature must verify, the trail must be monotonic, and with /// `--expect-cumulative` the final total must equal the re-derived sum. @@ -812,6 +828,7 @@ async fn run_wrap(args: WrapArgs) -> ExitCode { test_mode: args.test_mode, show_mode: args.show_mode, dispute_ref: args.dispute_ref, + spend_log: args.spend_log, }; match proxy::serve(cfg).await { Ok(()) => ExitCode::SUCCESS, @@ -877,17 +894,31 @@ async fn run_verify_spend(args: VerifySpendArgs) -> ExitCode { return ExitCode::FAILURE; } }; - println!( - "verify-spend: {} — {}", - spend.report.code, spend.report.verdict - ); + // `--json`: emit the portable `audit/v1` verdict object as ONE line, so integrating the audit + // reads a typed object instead of scraping stdout. + if args.json { + println!( + "{}", + serde_json::to_string(&spend.report) + .unwrap_or_else(|e| format!(r#"{{"error":"serialize report: {e}"}}"#)) + ); + return if spend.report.consistent { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }; + } + // The Display already leads with the verdict code, so print it ONCE — not `{code} — {verdict}`, + // which doubled the word (`self-consistent — self-consistent — …`). + println!("verify-spend: {}", spend.report.verdict); if !spend.report.consistent { return ExitCode::FAILURE; } - // The resumable end state a checkpointing caller stores for its next run. + // The resumable end state a checkpointing caller stores for its next run — the head to pin + // against a witness (the anti-rollback anchor). if let Some(cp) = &spend.report.checkpoint { println!( - "checkpoint: records={} settled_cents={} binding={}", + "checkpoint (pin this head against a witness): records={} settled_cents={} binding={}", cp.records, cp.settled_cents, cp.binding ); } diff --git a/crates/auths-mcp-gateway/src/proxy.rs b/crates/auths-mcp-gateway/src/proxy.rs index d29a443ad..51737830b 100644 --- a/crates/auths-mcp-gateway/src/proxy.rs +++ b/crates/auths-mcp-gateway/src/proxy.rs @@ -148,6 +148,9 @@ pub struct WrapConfig { /// writes (`wrap --dispute-ref`) — the index entry pointing at the evidence /// surface that adjudicates a later dispute. pub dispute_ref: Option, + /// Where the signed spend log + registry are written (`wrap --spend-log`). `None` defaults to + /// an ephemeral per-run OS temp dir (wiped on reboot); a stable dir keeps receipts across runs. + pub spend_log: Option, } impl WrapConfig { @@ -162,18 +165,54 @@ impl WrapConfig { } /// The directory the live wire builds its signing chain + registry under (org root, the -/// delegated agent, the scope seal, and the spend log). Honors `AUTHS_MCP_LIVE_DIR` (then -/// `LAB_DIR`) so a demo/harness can point the chain + log at a known path it later audits -/// with `verify-spend`; otherwise a per-process temp dir. -fn live_chain_dir() -> PathBuf { +/// delegated agent, the scope seal, and the spend log). Resolution order: `wrap --spend-log` +/// (explicit, stable), then `AUTHS_MCP_LIVE_DIR`, then `LAB_DIR`, else an ephemeral per-process +/// temp dir. Returns `(dir, defaulted)` where `defaulted` is true ONLY for the temp-dir fallback, +/// so the caller can flag the ephemeral case in the startup banner. +fn live_chain_dir(spend_log: Option<&std::path::Path>) -> (PathBuf, bool) { + if let Some(dir) = spend_log { + return (dir.to_path_buf(), false); + } for var in ["AUTHS_MCP_LIVE_DIR", "LAB_DIR"] { if let Ok(p) = std::env::var(var) && !p.is_empty() { - return PathBuf::from(p); + return (PathBuf::from(p), false); } } - std::env::temp_dir().join(format!("auths-mcp-live-{}", std::process::id())) + ( + std::env::temp_dir().join(format!("auths-mcp-live-{}", std::process::id())), + true, + ) +} + +/// Parse a `--ttl` grant duration (`30m`, `1s`, `2h`, `7d`, or bare seconds) to seconds. A grant +/// TTL is anchored as the delegation's expiry seal, so a malformed TTL must fail the wrap at parse +/// time rather than serve an unenforced bound. +/// +/// Args: +/// * `ttl`: the raw `--ttl` string (a suffixed duration or bare seconds). +/// +/// Usage: +/// ```ignore +/// assert_eq!(parse_ttl_secs("30m").unwrap(), 1800); +/// ``` +pub(crate) fn parse_ttl_secs(ttl: &str) -> Result { + let t = ttl.trim(); + let (num, mult) = match t.chars().last() { + Some('s') => (&t[..t.len() - 1], 1), + Some('m') => (&t[..t.len() - 1], 60), + Some('h') => (&t[..t.len() - 1], 3600), + Some('d') => (&t[..t.len() - 1], 86_400), + _ => (t, 1), + }; + num.trim() + .parse::() + .map_err(|e| e.to_string()) + .and_then(|n| { + n.checked_mul(mult) + .ok_or_else(|| "ttl overflow".to_string()) + }) } /// The metered shape of one brokered `tools/call`, parsed from the agent's request at the wire @@ -268,8 +307,16 @@ struct GatewayProxy { chain: Arc, /// The per-call gate resolved over the chain's registry: it authenticates each signed /// call (proof + scope ⊆ grant + expiry + revocation) and reserves/settles against the - /// budget — the SAME [`auths_mcp_core::PerCallGate`] the hermetic gate drives. - gate: Arc, + /// budget — the SAME [`auths_mcp_core::PerCallGate`] the hermetic gate drives. Behind a + /// `Mutex` so the delegator KEL can be re-resolved mid-session (revocation propagation); + /// per-agent calls are already serialized by `prev_binding`, so the lock adds no contention. + gate: Arc>, + /// How often the delegator KEL is re-resolved so a mid-session revocation propagates within + /// this SLA (default 30s, `AUTHS_MCP_REVOCATION_RECHECK_SECS`) instead of only on restart. + revocation_recheck: std::time::Duration, + /// When the delegator KEL was last resolved — the re-resolution is due once this is older than + /// `revocation_recheck`. + last_resolved: Arc>, /// Monotonic per-call index; each call's signing work repo is keyed by it. next_call: Arc, /// The payment rail the wrapped downstream settles on, set by the operator. When `Some`, every @@ -458,6 +505,30 @@ impl ServerHandler for GatewayProxy { let call_start = std::time::Instant::now(); let tool = request.name.to_string(); + // Revocation propagation: re-resolve the delegator KEL (which carries the + // revocation/expiry seals) on a bounded interval so a mid-session `auths id agent revoke` + // is observed within the recheck SLA, not only on process restart. A refresh error fails + // the call CLOSED — never a silently-stale snapshot. + { + let mut last = self.last_resolved.lock().await; + if last.elapsed() >= self.revocation_recheck { + let registry = GitRegistryBackend::from_config_unchecked( + RegistryConfig::single_tenant(self.chain.org_repo()), + ); + self.gate + .lock() + .await + .refresh_delegator(®istry) + .map_err(|e| { + McpError::internal_error( + format!("re-resolve the delegator KEL for revocation propagation: {e}"), + None, + ) + })?; + *last = std::time::Instant::now(); + } + } + // Canonicalize the call the way the offline audit re-derives it, and SIGN it as the // agent — the per-call proof `verify-spend` re-verifies. The cost/rail are the call's // declared metering; extracting a metered rail's actual cost from its response and @@ -526,9 +597,10 @@ impl ServerHandler for GatewayProxy { } } CallCost::Free => { + // Lock ordering is always gate → budget (the refresh path locks the gate alone). + let gate = self.gate.lock().await; let mut budget = self.budget.lock().await; - self.gate - .judge(&Meter::Unmetered, &proof_bytes, now, &mut budget) + gate.judge(&Meter::Unmetered, &proof_bytes, now, &mut budget) .await .map_err(|e| McpError::internal_error(format!("per-call gate: {e}"), None))? } @@ -540,9 +612,9 @@ impl ServerHandler for GatewayProxy { rail: rail.clone(), ceiling: *ceiling, }; + let gate = self.gate.lock().await; let mut budget = self.budget.lock().await; - self.gate - .judge(&meter, &proof_bytes, now, &mut budget) + gate.judge(&meter, &proof_bytes, now, &mut budget) .await .map_err(|e| { McpError::internal_error(format!("per-call gate: {e}"), None) @@ -613,9 +685,10 @@ impl ServerHandler for GatewayProxy { // Settle the ACTUAL cost into the durable counter, releasing the reservation slack. if let Some(hold) = decision.hold { + // Lock ordering is always gate → budget. + let gate = self.gate.lock().await; let mut budget = self.budget.lock().await; - let (settle_verdict, new_cumulative) = self - .gate + let (settle_verdict, new_cumulative) = gate .settle(&mut budget, hold, Actual::new(actual_cents)) .map_err(|e| { McpError::internal_error( @@ -753,7 +826,16 @@ impl ServerHandler for GatewayProxy { verdict.code() ), }; - Err(McpError::invalid_request(refusal, None)) + // Co-deliver a structured verdict the refused caller can KEEP (a re-checkable + // artifact, not just a console line) — the same verdict the gate returned. + let proof_short = &proof_sha[..proof_sha.len().min(12)]; + let data = serde_json::json!({ + "code": verdict.code(), + "tool": tool, + "refused_before_downstream": true, + "proof_ref": proof_short, + }); + Err(McpError::invalid_request(refusal, Some(data))) } } } @@ -895,14 +977,35 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { // The chain is built BEFORE the budget so the durable counter can be keyed to the REAL // agent delegation under the chain's own registry — the same place the spend log and the // printed verify-spend command point — so the offline audit opens the counter the wire advanced. - let lab = live_chain_dir(); + let (lab, lab_defaulted) = live_chain_dir(cfg.spend_log.as_deref()); std::fs::create_dir_all(&lab) .map_err(|e| anyhow::anyhow!("create the live signing directory {lab:?}: {e}"))?; + // Announce the resolved spend-log directory up front, flagging the ephemeral default so + // "where did the receipts land" is answered without scraping the compound command. + eprintln!( + "auths-mcp-gateway: spend-log: {}{}", + lab.display(), + if lab_defaulted { + " (ephemeral per-run temp dir, wiped on reboot — pass --spend-log to keep it)" + } else { + "" + }, + ); let mut signing_scope = cfg.scope.clone(); if !signing_scope.iter().any(|c| c == "settle") { signing_scope.push("settle".to_string()); } - let chain = crate::chain::Chain::build(&lab, &signing_scope) + // Parse the grant TTL fail-closed and anchor it as the delegation's expiry seal: a + // malformed `--ttl` aborts the wrap rather than serving a bound nothing enforces. + let ttl_secs = cfg + .ttl + .as_deref() + .map(parse_ttl_secs) + .transpose() + .map_err(|e| { + anyhow::anyhow!("invalid --ttl `{}`: {e}", cfg.ttl.as_deref().unwrap_or("")) + })?; + let chain = crate::chain::Chain::build(&lab, &signing_scope, ttl_secs) .map_err(|e| anyhow::anyhow!("build the agent delegation chain for live signing: {e}"))?; let registry = GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(chain.org_repo())); @@ -961,12 +1064,28 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { Err(_) => auths_mcp_core::SPEND_LOG_GENESIS.to_string(), }; + // The revocation-propagation SLA: how often the delegator KEL is re-resolved so a mid-session + // revoke is observed. Bounded default (30s); a shorter value tightens the window. + let revocation_recheck = std::time::Duration::from_secs( + std::env::var("AUTHS_MCP_REVOCATION_RECHECK_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(30), + ); + eprintln!( + "auths-mcp-gateway: revocation propagation — the delegator KEL is re-resolved every {}s; \ + a mid-session `auths id agent revoke` stops the next call within that window", + revocation_recheck.as_secs(), + ); + let treasury = crate::treasury::TreasuryClient::from_env(&chain.root_did).map(Arc::new); let proxy = GatewayProxy { downstream, budget: Arc::new(Mutex::new(budget)), chain: Arc::new(chain), - gate: Arc::new(gate), + gate: Arc::new(Mutex::new(gate)), + revocation_recheck, + last_resolved: Arc::new(Mutex::new(std::time::Instant::now())), next_call: Arc::new(std::sync::atomic::AtomicUsize::new(0)), rail: cfg.rail, prev_binding: Arc::new(Mutex::new(prev_binding)), @@ -992,6 +1111,27 @@ pub async fn serve(cfg: WrapConfig) -> anyhow::Result<()> { mod tests { use super::*; + #[test] + fn parse_ttl_secs_handles_suffixes_and_bare_seconds() { + // The grant TTL is anchored as the delegation expiry seal. + assert_eq!(parse_ttl_secs("30m").unwrap(), 1800); + assert_eq!(parse_ttl_secs("1s").unwrap(), 1); + assert_eq!(parse_ttl_secs("2h").unwrap(), 7200); + assert_eq!(parse_ttl_secs("7d").unwrap(), 604_800); + // Bare seconds with no suffix. + assert_eq!(parse_ttl_secs("45").unwrap(), 45); + assert_eq!(parse_ttl_secs(" 90 ").unwrap(), 90); + } + + #[test] + fn parse_ttl_secs_fails_closed_on_garbage() { + // A malformed TTL must be an error, never a silently-unenforced bound. + assert!(parse_ttl_secs("garbage").is_err()); + assert!(parse_ttl_secs("30x").is_err()); + assert!(parse_ttl_secs("").is_err()); + assert!(parse_ttl_secs("m").is_err()); + } + #[test] fn parses_name_equals_value() { let v = CustodyVault::from_specs(&["DOWNSTREAM_API_KEY=sk-abc123".to_string()]).unwrap(); diff --git a/crates/auths-mcp-gateway/src/replay.rs b/crates/auths-mcp-gateway/src/replay.rs index 57f8b65c7..913d13302 100644 --- a/crates/auths-mcp-gateway/src/replay.rs +++ b/crates/auths-mcp-gateway/src/replay.rs @@ -123,7 +123,16 @@ pub async fn run(transcript_path: &Path) -> anyhow::Result { if !scope.iter().any(|c| c == "settle") { scope.push("settle".to_string()); } - let mut chain = Chain::build(&lab, &scope)?; + // Anchor the transcript's TTL as the delegation's expiry seal (a malformed one fails the + // replay rather than serving an unenforced bound); a transcript with no ttl is unbounded. + let ttl_secs = transcript + .grant + .ttl + .as_deref() + .map(crate::proxy::parse_ttl_secs) + .transpose() + .map_err(|e| anyhow::anyhow!("invalid transcript grant ttl: {e}"))?; + let mut chain = Chain::build(&lab, &scope, ttl_secs)?; println!( "▸ chain: identity={} device={}", chain.root_did, chain.agent_did @@ -226,7 +235,9 @@ pub async fn run(transcript_path: &Path) -> anyhow::Result { let verdict = gate .audit_spend_log(&records, Utc::now().timestamp(), Some(&counter), None) .await; - println!("▸ audit: {} — {verdict}", verdict.code()); + // The Display already leads with the verdict code — print it ONCE (no `{code} — …` + // doubling). + println!("▸ audit: {verdict}"); // Emit the exact args to re-run the audit as a STANDALONE process (`verify-spend`), // so an external party (and the smoke) can re-derive this verdict from disk alone. println!( diff --git a/crates/auths-mcp-server/src/error.rs b/crates/auths-mcp-server/src/error.rs index 70be5a220..4d6efe8e1 100644 --- a/crates/auths-mcp-server/src/error.rs +++ b/crates/auths-mcp-server/src/error.rs @@ -46,11 +46,45 @@ pub enum McpServerError { Internal(String), } -/// Error response body. +impl McpServerError { + /// The AUTHS-E code for this failure, reusing the taxonomy's existing codes so + /// the gateway's headline surface is lookupable via `auths error show` — the + /// same "insufficient capabilities" failure carries the same code everywhere. + pub fn auths_code(&self) -> &'static str { + match self { + Self::InsufficientCapabilities { .. } => "AUTHS-E5504", + Self::Unauthorized(_) | Self::TokenInvalid(_) => "AUTHS-E5501", + Self::JwksError(_) => "AUTHS-E5502", + Self::UnknownTool(_) => "AUTHS-E5505", + Self::ToolError(_) | Self::Internal(_) => "AUTHS-E5599", + } + } + + /// The actionable next step for this failure, mirroring the CLI contract. + pub fn suggestion(&self) -> Option<&'static str> { + match self { + Self::InsufficientCapabilities { .. } => Some( + "The agent's grant does not cover this tool. Widen its scope via `auths id agent add`/policy, or call a tool within scope.", + ), + Self::Unauthorized(_) | Self::TokenInvalid(_) => { + Some("Attach a valid agent passport (Authorization: Bearer ).") + } + _ => None, + } + } +} + +/// Error response body. Carries both the HTTP string code and the AUTHS-E code so +/// the wire body matches the CLI JSON contract (code + suggestion + offline lookup). #[derive(Debug, Serialize)] pub struct ErrorResponse { pub error: String, pub code: String, + pub auths_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggestion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lookup: Option, } impl IntoResponse for McpServerError { @@ -67,9 +101,13 @@ impl IntoResponse for McpServerError { McpServerError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"), }; + let auths_code = self.auths_code(); let body = ErrorResponse { error: self.to_string(), code: code.to_string(), + auths_code: auths_code.to_string(), + suggestion: self.suggestion().map(|s| s.to_string()), + lookup: Some(format!("auths error show {auths_code}")), }; let mut response = (status, Json(body)).into_response(); @@ -90,3 +128,54 @@ impl IntoResponse for McpServerError { /// Result type alias for MCP server operations. pub type McpServerResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insufficient_capabilities_carries_auths_e5504() { + // The gateway's headline denial must carry the same AUTHS-E code the SDK and + // verifier use for "insufficient capabilities", plus a suggestion and the + // offline lookup — so the wire body matches the CLI JSON contract. + let err = McpServerError::InsufficientCapabilities { + tool: "deploy".into(), + required: "deploy:prod".into(), + granted: vec!["read".into()], + }; + assert_eq!(err.auths_code(), "AUTHS-E5504"); + let body = ErrorResponse { + error: err.to_string(), + code: "INSUFFICIENT_CAPABILITIES".into(), + auths_code: err.auths_code().to_string(), + suggestion: err.suggestion().map(|s| s.to_string()), + lookup: Some(format!("auths error show {}", err.auths_code())), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["auths_code"], "AUTHS-E5504"); + assert!(json["suggestion"].is_string()); + assert!( + json["lookup"] + .as_str() + .unwrap() + .contains("auths error show") + ); + } + + #[test] + fn auth_failures_map_into_the_auth_range() { + assert_eq!( + McpServerError::Unauthorized("no token".into()).auths_code(), + "AUTHS-E5501" + ); + assert_eq!( + McpServerError::TokenInvalid("bad jwt".into()).auths_code(), + "AUTHS-E5501" + ); + assert!( + McpServerError::Unauthorized("x".into()) + .suggestion() + .is_some() + ); + } +} diff --git a/crates/auths-monitor/src/main.rs b/crates/auths-monitor/src/main.rs index 4ce6706dc..fdd099d84 100644 --- a/crates/auths-monitor/src/main.rs +++ b/crates/auths-monitor/src/main.rs @@ -128,6 +128,7 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|_| "86400".into()) .parse() .map_err(|e| anyhow::anyhow!("invalid AUTHS_WATCH_GAP_SECS: {e}"))?; + let alert_webhook = std::env::var("AUTHS_ALERT_WEBHOOK").ok(); if !watch_witnesses.is_empty() { tracing::info!( witnesses = watch_witnesses.len(), @@ -147,7 +148,10 @@ async fn main() -> anyhow::Result<()> { // The proof is the publishable artifact: emitted whole so any // channel can carry it to strangers for offline re-checking. match serde_json::to_string(&proof) { - Ok(wire) => tracing::error!(proof = %wire, "SPEND-ANCHOR DUPLICITY DETECTED"), + Ok(wire) => { + tracing::error!(proof = %wire, "SPEND-ANCHOR DUPLICITY DETECTED"); + push_alert(&client, &alert_webhook, "duplicity", &proof).await; + } Err(e) => tracing::error!(error = %e, "duplicity proof serialization"), } } @@ -167,6 +171,7 @@ async fn main() -> anyhow::Result<()> { gap_seconds = gap.gap_seconds, "withholding gap past tolerance" ); + push_alert(&client, &alert_webhook, "withholding", &gap).await; } } } @@ -197,3 +202,34 @@ async fn main() -> anyhow::Result<()> { tokio::time::sleep(tokio::time::Duration::from_secs(config.interval_secs)).await; } } + +/// POST a small alert to the operator's configured webhook, if any. +/// +/// The hosted watcher's promise — alerts the moment a fork or a silence appears +/// — needs a push, not just a log line; this is that push. Absent +/// `AUTHS_ALERT_WEBHOOK`, tracing stays the only channel (unchanged default), so +/// behavior without config is identical to before. +/// +/// Args: +/// * `client`: the shared HTTP client. +/// * `webhook`: the operator's alert URL, or `None` to skip the push. +/// * `kind`: the alert kind (`"duplicity"` or `"withholding"`). +/// * `event`: the alert payload (the proof, or the gap), embedded inline. +async fn push_alert( + client: &reqwest::Client, + webhook: &Option, + kind: &str, + event: &T, +) { + let Some(url) = webhook else { return }; + let body = serde_json::json!({ "kind": kind, "event": event }); + if let Err(e) = client + .post(url) + .json(&body) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + { + tracing::error!(error = %e, webhook = %url, "alert webhook POST failed"); + } +} diff --git a/crates/auths-monitor/src/spend_anchor.rs b/crates/auths-monitor/src/spend_anchor.rs index 6a87d1031..c407c3843 100644 --- a/crates/auths-monitor/src/spend_anchor.rs +++ b/crates/auths-monitor/src/spend_anchor.rs @@ -203,4 +203,25 @@ mod tests { assert!(withholding_gap("ab", old, now, 3600).is_some()); assert!(withholding_gap("ab", now, now, 3600).is_none()); } + + #[test] + fn gap_and_duplicity_produce_pushable_events() { + let now = chrono::TimeZone::timestamp_opt(&Utc, 1_700_010_000, 0).unwrap(); + let old = chrono::TimeZone::timestamp_opt(&Utc, 1_700_000_000, 0).unwrap(); + let gap = withholding_gap("abc123", old, now, 3600).expect("gap past threshold"); + let gap_body = serde_json::json!({ "kind": "withholding", "event": gap }); + assert_eq!(gap_body["kind"], "withholding"); + assert_eq!(gap_body["event"]["seed_id"], "abc123"); + assert!(gap_body["event"]["gap_seconds"].as_i64().unwrap() > 3600); + + let obs = vec![ + observed("w0", signed(5, [1u8; 32])), + observed("w1", signed(5, [2u8; 32])), + ]; + let proof = detect_spend_anchor_duplicity(&obs).expect("fork"); + let proof_body = serde_json::json!({ "kind": "duplicity", "event": proof }); + assert_eq!(proof_body["kind"], "duplicity"); + let embedded: DuplicityProof = serde_json::from_value(proof_body["event"].clone()).unwrap(); + embedded.verify().unwrap(); + } } diff --git a/crates/auths-receipts/src/api/error.rs b/crates/auths-receipts/src/api/error.rs index 8e8d55bd8..4e045c191 100644 --- a/crates/auths-receipts/src/api/error.rs +++ b/crates/auths-receipts/src/api/error.rs @@ -95,6 +95,9 @@ impl From for ApiError { E::SpendLog(m) | E::Registry(m) | E::Counter(m) | E::Treasury(m) => { ApiError::Unprocessable(m) } + E::AnchorInvalid { code, detail } => { + ApiError::Unprocessable(format!("embedded anchor invalid ({code}): {detail}")) + } E::Canonical(m) | E::Signing(m) => ApiError::Internal(m), } } diff --git a/crates/auths-sdk/src/domains/agents/error.rs b/crates/auths-sdk/src/domains/agents/error.rs index b50443a39..b9a6d42fc 100644 --- a/crates/auths-sdk/src/domains/agents/error.rs +++ b/crates/auths-sdk/src/domains/agents/error.rs @@ -99,9 +99,9 @@ impl AuthsErrorInfo for AgentError { Self::IdentityNotFound { .. } => { Some("Run `auths init` to create a root identity first") } - Self::AlreadyDelegated { .. } => { - Some("Choose a different --label; run `auths id agent list` to see existing agents") - } + Self::AlreadyDelegated { .. } => Some( + "An agent already exists under this alias. Reuse it, rotate it with `auths id agent rotate`, or pass a fresh --label; `auths id agent list` shows existing agents.", + ), Self::CryptoError(e) => e.suggestion(), Self::DelegationError(_) => Some( "The agent delegation could not be authored or anchored; check the root identity", diff --git a/crates/auths-sdk/src/domains/auth/error.rs b/crates/auths-sdk/src/domains/auth/error.rs index 5c181d145..5ca3ba4b2 100644 --- a/crates/auths-sdk/src/domains/auth/error.rs +++ b/crates/auths-sdk/src/domains/auth/error.rs @@ -103,9 +103,9 @@ impl AuthsErrorInfo for TrustError { Self::InvalidTrustStore(_) => Some( "Check the format of your trust store (roots.json or ~/.auths/known_identities.json)", ), - Self::TofuRequiresInteraction => { - Some("Run interactively (on a TTY) or use `auths verify --trust explicit`") - } + Self::TofuRequiresInteraction => Some( + "Run interactively (on a TTY), or pre-pin the identity with `auths trust pin` so no interactive decision is needed", + ), } } } diff --git a/crates/auths-sdk/src/domains/ci/types.rs b/crates/auths-sdk/src/domains/ci/types.rs index c1de8dcb2..a4514c84a 100644 --- a/crates/auths-sdk/src/domains/ci/types.rs +++ b/crates/auths-sdk/src/domains/ci/types.rs @@ -23,7 +23,12 @@ pub fn generate_ci_passphrase() -> String { #[allow(clippy::expect_used)] // INVARIANT: OS CSPRNG (ring SystemRandom) failure is unrecoverable, like a poisoned mutex. rng.fill(&mut bytes).expect("system CSPRNG unavailable"); - bytes.iter().map(|b| format!("{b:02x}")).collect() + // Hex covers lowercase + digit (two classes); the strength policy requires three + // of four. Append an uppercase letter and a symbol so the generated value always + // clears `validate_passphrase`, without touching the entropy carried by the hex. + let mut out: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + out.push_str("-A9"); + out } /// CI platform environment. @@ -103,8 +108,13 @@ mod tests { } #[test] - fn ci_passphrase_is_strong() { + fn ci_passphrase_passes_policy() { + // The generator must satisfy the exact validator the CLI enforces before + // inception — otherwise `init --profile ci` rejects its own passphrase. let p = generate_ci_passphrase(); - assert!(p.len() >= 32, "passphrase too short ({} chars)", p.len()); + assert!( + auths_core::crypto::encryption::validate_passphrase(&p).is_ok(), + "generated CI passphrase must clear the strength policy: {p:?}" + ); } } diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index d2d24ef7c..25768003a 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -61,6 +61,12 @@ pub enum SigningError { /// The encrypted key material could not be decrypted. #[error("key decryption failed: {0}")] KeyDecryptionFailed(String), + /// No key is stored under the requested alias (the keychain itself is healthy). + #[error("no signing key under alias '{alias}'")] + KeyNotFound { + /// The alias that had no stored key. + alias: String, + }, } impl auths_core::error::AuthsErrorInfo for SigningError { @@ -76,6 +82,7 @@ impl auths_core::error::AuthsErrorInfo for SigningError { Self::PassphraseExhausted { .. } => "AUTHS-E5908", Self::KeychainUnavailable(_) => "AUTHS-E5909", Self::KeyDecryptionFailed(_) => "AUTHS-E5910", + Self::KeyNotFound { .. } => "AUTHS-E5911", } } @@ -97,6 +104,9 @@ impl auths_core::error::AuthsErrorInfo for SigningError { ), Self::KeychainUnavailable(_) => Some("Run `auths doctor` to diagnose keychain issues"), Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), + Self::KeyNotFound { .. } => { + Some("Run `auths key list` to see available aliases, or `auths init` to create one") + } } } } diff --git a/crates/auths-sdk/src/keychain.rs b/crates/auths-sdk/src/keychain.rs index 41af35fdf..3d25ebed1 100644 --- a/crates/auths-sdk/src/keychain.rs +++ b/crates/auths-sdk/src/keychain.rs @@ -1,6 +1,6 @@ //! Re-exports of keychain, encrypted file storage, and passphrase cache types from `auths-core`. -pub use auths_core::crypto::encryption::validate_passphrase; +pub use auths_core::crypto::encryption::{PASSPHRASE_POLICY_HINT, validate_passphrase}; pub use auths_core::storage::encrypted_file::EncryptedFileStorage; pub use auths_core::storage::keychain; pub use auths_core::storage::keychain::{ diff --git a/crates/auths-sdk/src/ports/mod.rs b/crates/auths-sdk/src/ports/mod.rs index c57855b3c..cdb550df7 100644 --- a/crates/auths-sdk/src/ports/mod.rs +++ b/crates/auths-sdk/src/ports/mod.rs @@ -27,7 +27,7 @@ pub use auths_core::ports::transparency_log::{ // Re-exports from auths-id ports pub use auths_id::identity::helpers::ManagedIdentity; -pub use auths_id::ports::registry::RegistryBackend; +pub use auths_id::ports::registry::{RegistryBackend, RegistryError}; pub use auths_id::storage::attestation::AttestationSource; pub use auths_id::storage::git_refs::AttestationMetadata; pub use auths_id::storage::identity::IdentityStorage; diff --git a/crates/auths-sdk/src/workflows/signing.rs b/crates/auths-sdk/src/workflows/signing.rs index 05328a409..33532a2c6 100644 --- a/crates/auths-sdk/src/workflows/signing.rs +++ b/crates/auths-sdk/src/workflows/signing.rs @@ -222,6 +222,29 @@ fn try_agent_sign( }) } +/// Map a keychain `load_key` failure onto the right signing error. +/// +/// A missing alias is not a broken keychain: it gets its own error so the fix +/// points at `auths key list`, not the keychain subsystem. Every other failure +/// means the keychain itself could not be accessed. +/// +/// Args: +/// * `err`: the error `KeyStorage::load_key` returned. +/// * `alias`: the alias the caller asked for. +/// +/// Usage: +/// ```ignore +/// let e = classify_load_key_error(AgentError::KeyNotFound, "main"); +/// ``` +fn classify_load_key_error(err: AgentError, alias: &str) -> SigningError { + match err { + AgentError::KeyNotFound => SigningError::KeyNotFound { + alias: alias.to_string(), + }, + other => SigningError::KeychainUnavailable(other.to_string()), + } +} + fn load_key_with_passphrase_retry( ctx: &CommitSigningContext, params: &CommitSigningParams, @@ -230,7 +253,7 @@ fn load_key_with_passphrase_retry( let (_identity_did, _role, encrypted_data) = ctx .key_storage .load_key(&alias) - .map_err(|e| SigningError::KeychainUnavailable(e.to_string()))?; + .map_err(|e| classify_load_key_error(e, ¶ms.key_alias))?; let prompt = format!("Enter passphrase for '{}':", params.key_alias); @@ -268,3 +291,31 @@ fn direct_sign( signing::sign_with_seed(seed, ¶ms.data, ¶ms.namespace, curve) } + +#[cfg(test)] +mod tests { + use super::*; + use auths_core::error::AuthsErrorInfo; + + #[test] + fn missing_alias_is_key_not_found_not_keychain_unavailable() { + // A nonexistent alias must surface as KeyNotFound (AUTHS-E5911) so the fix + // names `auths key list` — not the healthy keychain (AUTHS-E5909). + let err = classify_load_key_error(AgentError::KeyNotFound, "nonexistent"); + assert!(matches!(err, SigningError::KeyNotFound { .. })); + assert_eq!(err.error_code(), "AUTHS-E5911"); + assert!( + err.suggestion() + .unwrap_or_default() + .contains("auths key list"), + "E5911 must point at `auths key list`" + ); + } + + #[test] + fn other_load_failures_stay_keychain_unavailable() { + let err = classify_load_key_error(AgentError::StorageLocked, "main"); + assert!(matches!(err, SigningError::KeychainUnavailable(_))); + assert_eq!(err.error_code(), "AUTHS-E5909"); + } +} diff --git a/crates/auths-verifier/src/commit_error.rs b/crates/auths-verifier/src/commit_error.rs index d91b9057b..8df747206 100644 --- a/crates/auths-verifier/src/commit_error.rs +++ b/crates/auths-verifier/src/commit_error.rs @@ -20,8 +20,8 @@ pub enum CommitVerificationError { #[error("commit is unsigned")] UnsignedCommit, - /// The commit uses a GPG signature, which is not supported. - #[error("GPG signatures not supported, use SSH signing")] + /// The commit uses a GPG signature, which Auths does not verify. + #[error("GPG signatures are not verified by Auths — use did:keri trailers via `auths init`")] GpgNotSupported, /// The SSHSIG envelope could not be parsed. @@ -52,8 +52,8 @@ pub enum CommitVerificationError { #[error("signature verification failed")] SignatureInvalid, - /// The signer's public key is not in the allowed keys list. - #[error("signer key not in allowed keys")] + /// The signer's identity is not trusted (no matching pinned root). + #[error("signer identity is not trusted (no matching pinned root)")] UnknownSigner, /// The raw commit object could not be parsed. @@ -78,12 +78,21 @@ impl AuthsErrorInfo for CommitVerificationError { fn suggestion(&self) -> Option<&'static str> { match self { - Self::UnsignedCommit => Some("Sign commits with: git commit -S"), - Self::GpgNotSupported => Some("Configure SSH signing: git config gpg.format ssh"), + Self::UnsignedCommit => Some( + "This commit has no Auths-Id/Auths-Device trailer. Run `auths init` so the \ + prepare-commit-msg hook signs future commits, or backfill with `auths sign `.", + ), + Self::GpgNotSupported => Some( + "Auths verifies its own did:keri commit trailers, not GPG or SSH signatures. \ + Run `auths init` to enable Auths signing.", + ), Self::UnsupportedKeyType { .. } => { Some("Use an Ed25519 or ECDSA P-256 SSH key for signing") } - Self::UnknownSigner => Some("Add the signer's key to the allowed signers list"), + Self::UnknownSigner => Some( + "The signer's identity is not trusted here. Pin it with `auths trust pin --did `, \ + or add it to .auths/roots.", + ), Self::SshSigParseFailed(_) => Some( "The SSH signature could not be parsed; verify the commit was signed correctly", ), @@ -102,3 +111,35 @@ impl AuthsErrorInfo for CommitVerificationError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retired_advice_is_gone() { + // The pre-KEL SSH/GPG advice would send a user to produce a commit `auths + // verify` still rejects. Every commit-verification suggestion must speak the + // KEL-native flow (`auths `) and name none of the retired mechanisms. + let retired = ["git commit -S", "gpg.format", "allowed signers"]; + for err in [ + CommitVerificationError::UnsignedCommit, + CommitVerificationError::GpgNotSupported, + CommitVerificationError::UnknownSigner, + ] { + let suggestion = err.suggestion().expect("headline verdicts carry advice"); + for phrase in retired { + assert!( + !suggestion.contains(phrase), + "{} still advises `{phrase}`: {suggestion}", + err.error_code() + ); + } + assert!( + suggestion.contains("auths "), + "{} must speak the KEL-native flow: {suggestion}", + err.error_code() + ); + } + } +} diff --git a/crates/auths-verifier/tests/cases/freshness_honesty.rs b/crates/auths-verifier/tests/cases/freshness_honesty.rs index d971aca44..a1f6520b2 100644 --- a/crates/auths-verifier/tests/cases/freshness_honesty.rs +++ b/crates/auths-verifier/tests/cases/freshness_honesty.rs @@ -152,6 +152,29 @@ fn credential_json_valid_verdict_carries_as_of_and_freshness_on_the_wire() { ); } +#[test] +fn default_window_stale_beyond_24h() { + // The standalone (source-age) path the `activity/v1` verifier uses: the 24h + // default window is the boundary. At exactly 24h a source is still Fresh; one + // second past it is Stale. Locks the window so a doc served a day later cannot + // read as fresh. + use auths_verifier::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy}; + use std::time::Duration; + let policy = FreshnessPolicy::default(); + assert_eq!( + policy.classify(FreshnessEvidence::SourceAge(Duration::from_secs(24 * 3600))), + Freshness::Fresh, + "a source at exactly the window boundary is still fresh" + ); + assert_eq!( + policy.classify(FreshnessEvidence::SourceAge(Duration::from_secs( + 24 * 3600 + 1 + ))), + Freshness::Stale, + "one second past the window is stale" + ); +} + #[test] fn non_freshness_allowlist_is_explicit() { // The allowlist is the only sanctioned way a positive verdict omits freshness; keep it diff --git a/crates/auths-witness-node/src/anchor_role.rs b/crates/auths-witness-node/src/anchor_role.rs index 2dd7be27e..765cc8792 100644 --- a/crates/auths-witness-node/src/anchor_role.rs +++ b/crates/auths-witness-node/src/anchor_role.rs @@ -9,15 +9,35 @@ //! store is the serialization point: a concurrent fork is caught as a lost CAS //! and re-run against the winner, which yields a duplicity proof if the heads //! differ. The node never forks the rule; it composes it. +//! +//! The HTTP surface (the `/v1/anchor` submit/read routes and the `/health` +//! probe) is built here too, so the node binary composes one router builder and +//! the integration tests drive the exact same surface over `oneshot`. + +// The wall clock is read at this HTTP boundary before it is injected into the +// pure acceptance rule — the same allowance the cosign role and the node binary +// take at their edges. +#![allow(clippy::disallowed_methods)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; use auths_anchor::{ Acceptance, Anchor, AnchorError, AnchorStore, CasOutcome, ControllerKeys, DuplicityProof, - LoggedInclusion, StoreError, WitnessCosignature, accept_anchor, + LoggedInclusion, SeedId, StoreError, WitnessCosignature, accept_anchor, }; -use auths_transparency::{LogWriter, TileStore, hash_leaf}; +use auths_transparency::{FsTileStore, LogWriter, TileStore, hash_leaf}; +use axum::extract::{Path as AxumPath, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; use chrono::{DateTime, Utc}; +use serde::Deserialize; -use crate::signer::Signer; +use crate::registry::{PartyResolveError, controller_keys_for_party}; +use crate::signer::{FileSigner, Signer}; +use crate::sqlite_store::SqliteAnchorStore; /// What the service decided for one submission. #[derive(Debug, Clone)] @@ -32,6 +52,9 @@ pub enum SubmitOutcome { /// what makes the cosignature finalization-grade. inclusion: Box, }, + /// The submission exactly matches the last co-signed anchor at this index — + /// an idempotent replay, accepted as a no-op with nothing re-stored. + AlreadyAnchored(Box), /// The anchor equivocated against the co-signed prior — refused, with the /// publishable proof. Duplicity(Box), @@ -100,6 +123,7 @@ impl AnchorService { ) -> Result { let prior = self.store.latest(&req.seed_id)?; match accept_anchor(req, keys, prior.as_ref(), now)? { + Acceptance::AlreadyAnchored(anchor) => Ok(SubmitOutcome::AlreadyAnchored(anchor)), Acceptance::Duplicity(proof) => Ok(SubmitOutcome::Duplicity(proof)), Acceptance::CoSign(anchor) => { let expected = prior.as_ref().map(|p| p.index); @@ -117,6 +141,9 @@ impl AnchorService { } CasOutcome::Lost(winner) => { match accept_anchor(req, keys, Some(&winner), now)? { + Acceptance::AlreadyAnchored(anchor) => { + Ok(SubmitOutcome::AlreadyAnchored(anchor)) + } Acceptance::Duplicity(proof) => Ok(SubmitOutcome::Duplicity(proof)), Acceptance::CoSign(_) => Err(ServiceError::Contended), } @@ -173,6 +200,213 @@ impl AnchorService { } } +/// The node's live anchor service, wired to the durable SQLite store and the +/// witness's own append-only log — the concrete type the HTTP surface drives. +pub type NodeAnchorService = AnchorService; + +/// Shared state behind the anchor role's HTTP surface: the acceptance service, +/// the registry the node resolves submitter keys against, and the durable +/// directory where caught equivocation proofs are recorded for later re-serving. +pub struct AppState { + service: NodeAnchorService, + registry: PathBuf, + duplicity_dir: PathBuf, + witness_name: String, + roles: Vec, +} + +impl AppState { + /// Assemble the anchor role's shared state. + /// + /// Args: + /// * `service`: the wired anchor-acceptance service. + /// * `registry`: local copy of the parties' public registry, for key resolution. + /// * `duplicity_dir`: durable directory where caught equivocation proofs are recorded. + /// * `witness_name`: this node's public name, echoed at `/health`. + /// * `roles`: the roles this node serves, echoed at `/health`. + /// + /// Usage: + /// ```ignore + /// let state = Arc::new(AppState::new(service, registry, data_dir.join("duplicity"), name, roles)); + /// let app = anchor_router(state, true); + /// ``` + pub fn new( + service: NodeAnchorService, + registry: PathBuf, + duplicity_dir: PathBuf, + witness_name: String, + roles: Vec, + ) -> Self { + Self { + service, + registry, + duplicity_dir, + witness_name, + roles, + } + } +} + +/// One anchor submission: the anchor plus the party naming the witness resolves +/// keys for. The party fields identify WHO is anchoring; the anchor itself +/// carries no per-record data. +#[derive(Deserialize)] +struct SubmitBody { + anchor: Anchor, + party: PartyRef, +} + +#[derive(Deserialize)] +struct PartyRef { + root: String, + agent: String, +} + +fn error_body(status: StatusCode, detail: String) -> Response { + (status, Json(serde_json::json!({ "error": detail }))).into_response() +} + +/// Shape-validate a `{seed}` path segment before decoding, so an RP that passes +/// a `did:` or a head string gets one consistent, product-voiced 400 naming the +/// expected form instead of the internal hex-decoder phrasing. The `Err` is the +/// message; the caller shapes the 400. +fn parse_seed_hex(seed_hex: &str) -> Result { + if seed_hex.len() != 64 || !seed_hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!( + "seed must be a 64-character hex seed_id; got '{seed_hex}'" + )); + } + SeedId::from_hex(seed_hex).map_err(|e| e.to_string()) +} + +/// Durably record a caught equivocation so it outlives the 409 response. A +/// witness that catches a cheat must be able to hand the proof to anyone who +/// asks later, not only the submitter whose fork tripped it. +fn record_duplicity(dir: &Path, proof: &DuplicityProof) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let path = dir.join(format!("{}.json", proof.seed_id.to_hex())); + let bytes = serde_json::to_vec_pretty(proof).map_err(std::io::Error::other)?; + std::fs::write(path, bytes) +} + +/// Read a previously recorded duplicity proof for a seed, if one was caught. +fn read_duplicity(dir: &Path, seed: &SeedId) -> Option { + let path = dir.join(format!("{}.json", seed.to_hex())); + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +async fn submit_anchor( + State(state): State>, + Json(body): Json, +) -> Response { + let keys = match controller_keys_for_party(&state.registry, &body.party.root, &body.party.agent) + { + Ok(keys) => keys, + Err(e @ PartyResolveError::RegistryUnavailable) => { + return error_body(StatusCode::SERVICE_UNAVAILABLE, e.to_string()); + } + Err(e) => return error_body(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()), + }; + let now = chrono::Utc::now(); + match state.service.submit(&body.anchor, &keys, now).await { + Ok(SubmitOutcome::CoSigned { + cosignature, + inclusion, + .. + }) => Json(serde_json::json!({ + "cosignature": *cosignature, + "inclusion": *inclusion, + })) + .into_response(), + Ok(SubmitOutcome::AlreadyAnchored(anchor)) => ( + StatusCode::OK, + Json(serde_json::json!({ "already_anchored": true, "index": anchor.index })), + ) + .into_response(), + Ok(SubmitOutcome::Duplicity(proof)) => { + let _ = record_duplicity(&state.duplicity_dir, &proof); + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "duplicity": *proof })), + ) + .into_response() + } + Err(ServiceError::Anchor(e)) => error_body(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()), + Err(ServiceError::Contended) => { + error_body(StatusCode::CONFLICT, "contended — retry".to_string()) + } + Err(e) => error_body(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + } +} + +async fn latest_anchor( + State(state): State>, + AxumPath(seed_hex): AxumPath, +) -> Response { + let seed = match parse_seed_hex(&seed_hex) { + Ok(seed) => seed, + Err(msg) => return error_body(StatusCode::BAD_REQUEST, msg), + }; + match state.service.latest(&seed) { + Ok(Some(anchor)) => Json(serde_json::json!({ "anchor": anchor })).into_response(), + Ok(None) => error_body(StatusCode::NOT_FOUND, "no anchor for this seed".to_string()), + Err(e) => error_body(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + } +} + +async fn duplicity_for_seed( + State(state): State>, + AxumPath(seed_hex): AxumPath, +) -> Response { + let seed = match parse_seed_hex(&seed_hex) { + Ok(seed) => seed, + Err(msg) => return error_body(StatusCode::BAD_REQUEST, msg), + }; + match read_duplicity(&state.duplicity_dir, &seed) { + Some(proof) => Json(serde_json::json!({ "duplicity": proof })).into_response(), + None => error_body( + StatusCode::NOT_FOUND, + "no recorded duplicity for this seed".to_string(), + ), + } +} + +async fn health(State(state): State>) -> Response { + Json(serde_json::json!({ + "up": true, + "roles": state.roles, + "witness_name": state.witness_name, + })) + .into_response() +} + +/// Build the anchor role's HTTP surface over shared state. +/// +/// The node binary and the integration tests both compose this one builder, so +/// tests drive the exact routes the node serves via `tower::ServiceExt::oneshot` +/// rather than re-deriving them. `serve_health` registers the shared `/health` +/// route; the node omits it here when another role already owns that path. +/// +/// Args: +/// * `state`: the anchor role's shared state. +/// * `serve_health`: register the `/health` probe on this router. +/// +/// Usage: +/// ```ignore +/// let app = anchor_router(Arc::new(state), true); +/// ``` +pub fn anchor_router(state: Arc, serve_health: bool) -> Router { + let mut router = Router::new() + .route("/v1/anchor", post(submit_anchor)) + .route("/v1/anchor/{seed}", get(latest_anchor)) + .route("/v1/duplicity/{seed}", get(duplicity_for_seed)); + if serve_health { + router = router.route("/health", get(health)); + } + router.with_state(state) +} + #[cfg(test)] pub(crate) mod tests_support { //! Deterministic fixtures shared by the node's unit tests. diff --git a/crates/auths-witness-node/src/bin/witness_node.rs b/crates/auths-witness-node/src/bin/witness_node.rs index b240ee5a8..54540be09 100644 --- a/crates/auths-witness-node/src/bin/witness_node.rs +++ b/crates/auths-witness-node/src/bin/witness_node.rs @@ -15,18 +15,13 @@ use std::path::PathBuf; use std::sync::Arc; -use auths_anchor::{Anchor, SeedId}; -use auths_witness_node::anchor_role::{AnchorService, ServiceError, SubmitOutcome}; -use auths_witness_node::registry::controller_keys_for_party; +use auths_witness_node::anchor_role::{AnchorService, AppState, anchor_router}; +use auths_witness_node::registry::registry_ready; use auths_witness_node::signer::{FileSigner, Signer as _}; use auths_witness_node::sqlite_store::SqliteAnchorStore; -use axum::extract::{DefaultBodyLimit, Path as AxumPath, State}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; +use axum::Router; +use axum::extract::DefaultBodyLimit; use clap::{Parser, Subcommand}; -use serde::Deserialize; use tower::limit::ConcurrencyLimitLayer; use tower_http::timeout::TimeoutLayer; @@ -119,90 +114,6 @@ struct ServeArgs { seed: String, } -struct AppState { - service: AnchorService, - registry: PathBuf, - witness_name: String, - roles: Vec, -} - -/// One anchor submission: the anchor plus the party naming the witness resolves -/// keys for. The party fields identify WHO is anchoring (a witness necessarily -/// knows its submitters); the anchor itself still carries no per-record data. -#[derive(Deserialize)] -struct SubmitBody { - anchor: Anchor, - party: PartyRef, -} - -#[derive(Deserialize)] -struct PartyRef { - root: String, - agent: String, -} - -fn error_body(status: StatusCode, detail: String) -> Response { - (status, Json(serde_json::json!({ "error": detail }))).into_response() -} - -async fn submit_anchor( - State(state): State>, - Json(body): Json, -) -> Response { - let keys = match controller_keys_for_party(&state.registry, &body.party.root, &body.party.agent) - { - Ok(keys) => keys, - Err(e) => return error_body(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()), - }; - #[allow(clippy::disallowed_methods)] // service binary boundary: wall clock injected here - let now = chrono::Utc::now(); - match state.service.submit(&body.anchor, &keys, now).await { - Ok(SubmitOutcome::CoSigned { - cosignature, - inclusion, - .. - }) => Json(serde_json::json!({ - "cosignature": *cosignature, - "inclusion": *inclusion, - })) - .into_response(), - Ok(SubmitOutcome::Duplicity(proof)) => ( - StatusCode::CONFLICT, - Json(serde_json::json!({ "duplicity": *proof })), - ) - .into_response(), - Err(ServiceError::Anchor(e)) => error_body(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()), - Err(ServiceError::Contended) => { - error_body(StatusCode::CONFLICT, "contended — retry".to_string()) - } - Err(e) => error_body(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), - } -} - -async fn latest_anchor( - State(state): State>, - AxumPath(seed_hex): AxumPath, -) -> Response { - let seed = match SeedId::from_hex(&seed_hex) { - Ok(seed) => seed, - Err(e) => return error_body(StatusCode::BAD_REQUEST, e.to_string()), - }; - match state.service.latest(&seed) { - Ok(Some(anchor)) => Json(serde_json::json!({ "anchor": anchor })).into_response(), - Ok(None) => error_body(StatusCode::NOT_FOUND, "no anchor for this seed".to_string()), - Err(e) => error_body(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), - } -} - -async fn health(State(state): State>) -> Response { - Json(serde_json::json!({ - "up": true, - "roles": state.roles, - "witness_name": state.witness_name, - })) - .into_response() -} - fn parse_seed(hex_seed: &str) -> Result<[u8; 32], String> { let raw = hex::decode(hex_seed.trim()).map_err(|e| format!("seed is not hex: {e}"))?; raw.try_into() @@ -252,11 +163,8 @@ fn build_cosign_router(args: &ServeArgs) -> Result { } fn build_state(args: &ServeArgs) -> Result, String> { - if !args.registry.exists() { - return Err(format!( - "registry path {} does not exist — the anchor role fails closed without key resolution", - args.registry.display() - )); + if let Err(e) = registry_ready(&args.registry) { + return Err(format!("{e} (registry path: {})", args.registry.display())); } std::fs::create_dir_all(&args.data_dir) .map_err(|e| format!("data dir {}: {e}", args.data_dir.display()))?; @@ -280,12 +188,13 @@ fn build_state(args: &ServeArgs) -> Result, String> { "witness-node: anchor role up as `{}` (member key {member_did})", args.witness_name, ); - Ok(Arc::new(AppState { - service: AnchorService::new(signer, store, log), - registry: args.registry.clone(), - witness_name: args.witness_name.clone(), - roles: args.roles.clone(), - })) + Ok(Arc::new(AppState::new( + AnchorService::new(signer, store, log), + args.registry.clone(), + args.data_dir.join("duplicity"), + args.witness_name.clone(), + args.roles.clone(), + ))) } #[tokio::main] @@ -320,15 +229,9 @@ async fn main() -> std::process::ExitCode { return std::process::ExitCode::FAILURE; } }; - let mut anchor_routes = Router::new() - .route("/v1/anchor", post(submit_anchor)) - .route("/v1/anchor/{seed}", get(latest_anchor)); // The KEL role's shared router already serves /health; register - // the node's own only when that role is off (one route, one owner). - if !has("kel") { - anchor_routes = anchor_routes.route("/health", get(health)); - } - app = app.merge(anchor_routes.with_state(state)); + // the anchor role's own only when that role is off (one owner). + app = app.merge(anchor_router(state, !has("kel"))); } if has("kel") { diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs index bddb614c6..ac969b54e 100644 --- a/crates/auths-witness-node/src/lib.rs +++ b/crates/auths-witness-node/src/lib.rs @@ -48,7 +48,9 @@ pub mod sqlite_store; pub mod standup; pub mod vocabulary; -pub use anchor_role::{AnchorService, ServiceError, SubmitOutcome}; +pub use anchor_role::{ + AnchorService, AppState, NodeAnchorService, ServiceError, SubmitOutcome, anchor_router, +}; pub use anchor_store::InMemoryAnchorStore; pub use build::{BuildAttestation, NodeBuildVerdict}; pub use engine::{DockerEngine, SocketHealthCheck, SocketHttpFetch}; diff --git a/crates/auths-witness-node/src/registry.rs b/crates/auths-witness-node/src/registry.rs index f44b181ae..89faf2fdd 100644 --- a/crates/auths-witness-node/src/registry.rs +++ b/crates/auths-witness-node/src/registry.rs @@ -11,7 +11,7 @@ use std::path::Path; use auths_anchor::ControllerKeys; use auths_keri::Prefix; -use auths_sdk::ports::RegistryBackend; +use auths_sdk::ports::{RegistryBackend, RegistryError}; use auths_sdk::storage::{GitRegistryBackend, RegistryConfig}; /// A failure resolving the submitting party's current keys. @@ -20,8 +20,23 @@ pub enum PartyResolveError { /// The identifier did not parse as a KERI prefix. #[error("party identifier: {0}")] BadIdentifier(String), - /// The registry lookup failed (missing identity, unreadable registry). - #[error("registry: {0}")] + /// The witness has no synced registry at all — no submitter can be resolved. + /// This is the operator's action: sync the registry before anchoring. + #[error( + "this witness has no synced registry — the operator must sync the parties' \ + public registry before anchoring is possible" + )] + RegistryUnavailable, + /// The registry is present but does not contain this submitter yet — the + /// operator must sync it before this party can anchor. + #[error( + "your identity is not in this witness's registry yet — the operator must sync \ + it before you can anchor" + )] + IdentityNotInRegistry, + /// A registry lookup failed in a way that is neither a clean absence nor a + /// missing repo (kept as a residual so nothing is silently reclassified). + #[error("registry lookup failed: {0}")] Registry(String), /// The agent is not delegated, or is delegated by someone other than the /// claimed root. @@ -32,6 +47,52 @@ pub enum PartyResolveError { Keys(String), } +/// Classify a registry lookup failure into the one action that fixes it. +/// +/// A missing identity in a real registry is the *stranger's* concern (sync your +/// entry); a repo that will not open, or a storage/IO fault, is the *operator's* +/// (sync your registry). Classification is structural — on the `RegistryError` +/// variant — so no libgit2 string ever leaks to a submitter. +fn classify_registry_error(e: RegistryError) -> PartyResolveError { + match &e { + RegistryError::NotFound { entity_type, .. } if entity_type == "identity" => { + PartyResolveError::IdentityNotInRegistry + } + RegistryError::NotFound { .. } | RegistryError::Storage(_) | RegistryError::Io(_) => { + PartyResolveError::RegistryUnavailable + } + _ => PartyResolveError::Registry(e.to_string()), + } +} + +/// Confirm the anchor role has a registry it can actually resolve against, +/// before the node binds. A mounted-but-empty volume *exists* yet resolves +/// nothing — exactly how a node boots healthy and then refuses every submission +/// — so `exists()` is not enough: probe a syntactically valid sentinel and treat +/// anything but a clean "no such identity" as not ready. +/// +/// Args: +/// * `registry`: path to the local copy of the parties' public registry. +/// +/// Usage: +/// ```ignore +/// registry_ready(&args.registry)?; // refuse to bind if the registry is unsynced +/// ``` +pub fn registry_ready(registry: &Path) -> Result<(), PartyResolveError> { + if !registry.exists() { + return Err(PartyResolveError::RegistryUnavailable); + } + let backend = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(registry)); + let sentinel = Prefix::new("EReadinessProbe0000000000000000000000000000".to_string()) + .map_err(|e| PartyResolveError::BadIdentifier(e.to_string()))?; + match backend.get_key_state(&sentinel) { + Ok(_) => Ok(()), + Err(RegistryError::NotFound { entity_type, .. }) if entity_type == "identity" => Ok(()), + Err(_) => Err(PartyResolveError::RegistryUnavailable), + } +} + /// Resolve the agent's current keys from a local registry copy, requiring its /// delegator to be the claimed root. /// @@ -56,7 +117,7 @@ pub fn controller_keys_for_party( .map_err(|e| PartyResolveError::BadIdentifier(e.to_string()))?; let state = backend .get_key_state(&prefix) - .map_err(|e| PartyResolveError::Registry(e.to_string()))?; + .map_err(classify_registry_error)?; let root_tail = root.strip_prefix("did:keri:").unwrap_or(root); match &state.delegator { diff --git a/crates/auths-witness-node/tests/cases/anchor.rs b/crates/auths-witness-node/tests/cases/anchor.rs new file mode 100644 index 000000000..1192d7a59 --- /dev/null +++ b/crates/auths-witness-node/tests/cases/anchor.rs @@ -0,0 +1,215 @@ +//! The anchor role's HTTP surface, driven through the real router. +//! +//! These exercise the readiness gate and the honest-vs-operator error split at +//! the transport boundary: an unsynced registry is a distinct "operator, sync +//! your registry" signal (503), an unknown submitter is the stranger's concern +//! (422) with no libgit2 string leaked, and a malformed `{seed}` reads as one +//! named 400 rather than the internal hex-decoder phrasing. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use auths_anchor::{Anchor, CurveType, Head, PartySignature, SeedId, WitnessSetRef}; +use auths_sdk::storage::{GitRegistryBackend, RegistryConfig}; +use auths_transparency::{FsTileStore, LogOrigin, LogSigningKey, LogWriter}; +use auths_witness_node::anchor_role::{AppState, anchor_router}; +use auths_witness_node::registry::{PartyResolveError, registry_ready}; +use auths_witness_node::{AnchorService, FileSigner, SqliteAnchorStore}; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use chrono::Utc; +use ed25519_dalek::{Signer, SigningKey}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const SIXTY_FOUR_HEX_ZEROS: &str = + "0000000000000000000000000000000000000000000000000000000000000000"; + +fn signed_anchor(index: u64, head: [u8; 32]) -> Anchor { + let sk = SigningKey::from_bytes(&[9u8; 32]); + let mut anchor = Anchor { + seed_id: SeedId::derive("did:keri:root", "did:keri:agent", "ESeal"), + index, + head: Head::from_bytes(head), + cumulative: index as u128 * 100, + timestamp: chrono::TimeZone::timestamp_opt(&Utc, 1_700_000_000 + index as i64, 0).unwrap(), + witness_set: WitnessSetRef { + said: "EWitSet".into(), + threshold: 1, + }, + sig_party: PartySignature { + curve: CurveType::Ed25519, + public_key: sk.verifying_key().as_bytes().to_vec(), + signature: Vec::new(), + }, + }; + let message = anchor.party_signing_bytes().unwrap(); + anchor.sig_party.signature = sk.sign(&message).to_bytes().to_vec(); + anchor +} + +/// Build the anchor role's state against a given registry path, with a fresh +/// durable store and log under `data_dir`. +fn state_for(registry: PathBuf, data_dir: &Path) -> Arc { + let seed = [1u8; 32]; + let store = SqliteAnchorStore::open(&data_dir.join("anchors.db")).unwrap(); + let log = LogWriter::new( + FsTileStore::new(data_dir.join("log")), + LogSigningKey::from_seed(seed).unwrap(), + LogOrigin::new("awn/test-w1").unwrap(), + ); + let signer = FileSigner::from_seed("test-w1", seed); + Arc::new(AppState::new( + AnchorService::new(signer, store, log), + registry, + data_dir.join("duplicity"), + "test-w1".to_string(), + vec!["anchor".to_string()], + )) +} + +/// A registry directory that resolves as synced (initialized git repo, no +/// identities in it yet). +fn synced_registry(dir: &Path) -> PathBuf { + let backend = GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(dir)); + backend.init_if_needed().unwrap(); + dir.to_path_buf() +} + +async fn send(app: Router, method: &str, uri: &str, body: Option) -> (StatusCode, String) { + let builder = Request::builder().method(method).uri(uri); + let request = match body { + Some(json) => builder + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +fn submit_body() -> String { + serde_json::json!({ + "anchor": signed_anchor(1, [1u8; 32]), + "party": { "root": "did:keri:ERoot", "agent": "did:keri:EAgentAbsent" }, + }) + .to_string() +} + +#[test] +fn no_registry_is_not_ready() { + let empty = tempfile::tempdir().unwrap(); + assert!(matches!( + registry_ready(empty.path()), + Err(PartyResolveError::RegistryUnavailable) + )); + + let synced = tempfile::tempdir().unwrap(); + synced_registry(synced.path()); + assert!( + registry_ready(synced.path()).is_ok(), + "an initialized registry must read as ready" + ); +} + +#[tokio::test] +async fn unsynced_registry_returns_503_not_422() { + let data = tempfile::tempdir().unwrap(); + let empty_registry = tempfile::tempdir().unwrap(); + let state = state_for(empty_registry.path().to_path_buf(), data.path()); + + let (status, body) = send( + anchor_router(state, false), + "POST", + "/v1/anchor", + Some(submit_body()), + ) + .await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!( + body.contains("no synced registry"), + "operator-facing 503 body was: {body}" + ); +} + +#[tokio::test] +async fn unknown_identity_is_422_not_503_and_hides_registry_prefix() { + let data = tempfile::tempdir().unwrap(); + let registry = tempfile::tempdir().unwrap(); + let state = state_for(synced_registry(registry.path()), data.path()); + + let (status, body) = send( + anchor_router(state, false), + "POST", + "/v1/anchor", + Some(submit_body()), + ) + .await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert!( + body.contains("not in this witness's registry"), + "stranger-facing 422 body was: {body}" + ); + assert!( + !body.contains("registry:") && !body.contains("/registry"), + "the libgit2 string / prefix leaked into: {body}" + ); +} + +#[tokio::test] +async fn non_hex_seed_is_named_400() { + let data = tempfile::tempdir().unwrap(); + let registry = tempfile::tempdir().unwrap(); + let state = state_for(synced_registry(registry.path()), data.path()); + + let (status, body) = send( + anchor_router(state, false), + "GET", + "/v1/anchor/nonexistent-seed-abc123", + None, + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!( + body.contains("seed must be a 64-character hex seed_id"), + "named 400 body was: {body}" + ); + assert!( + !body.contains("Odd number of digits"), + "the hex-decoder phrasing leaked into: {body}" + ); +} + +#[tokio::test] +async fn valid_hex_unknown_seed_is_404() { + let data = tempfile::tempdir().unwrap(); + let registry = tempfile::tempdir().unwrap(); + let state = state_for(synced_registry(registry.path()), data.path()); + + let (status, body) = send( + anchor_router(state, false), + "GET", + &format!("/v1/anchor/{SIXTY_FOUR_HEX_ZEROS}"), + None, + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert!( + body.contains("no anchor for this seed"), + "unknown-seed 404 body was: {body}" + ); +} diff --git a/crates/auths-witness-node/tests/cases/config.rs b/crates/auths-witness-node/tests/cases/config.rs index 694a64949..e8a3ebe30 100644 --- a/crates/auths-witness-node/tests/cases/config.rs +++ b/crates/auths-witness-node/tests/cases/config.rs @@ -1,10 +1,39 @@ -//! WitnessState construction and disk-persistence behaviour. +//! WitnessState construction and disk-persistence behaviour, plus the deploy +//! manifest's registry-mount default. use auths_witness_node::cosign_role::WitnessState; use axum::http::StatusCode; use super::support::{make_checkpoint, post_checkpoint, test_config, tofu_request}; +/// The deploy Compose file must default the registry mount, not hard-fail on an +/// unset var: `${WITNESS_REGISTRY:?…}` is evaluated during local YAML +/// interpolation, so it exits before Docker is even contacted — every first-run +/// operator's `docker compose up` fails on paste. A `:-` default plus the node's +/// readiness gate turns an empty default into a loud refusal, not that failure. +#[test] +fn compose_default_registry_interpolates_without_env() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("workspace root"); + let compose = + std::fs::read_to_string(workspace_root.join("deploy/witness/docker-compose.yml")).unwrap(); + let registry_line = compose + .lines() + .find(|line| line.contains(":/registry:ro")) + .expect("registry volume line present"); + assert!( + registry_line.contains("${WITNESS_REGISTRY:-"), + "registry mount must interpolate a default, got: {registry_line}" + ); + assert!( + !registry_line.contains(":?"), + "registry mount must not hard-fail on an unset var, got: {registry_line}" + ); +} + #[test] fn rejects_invalid_hex_signing_key() { let (mut config, _dir) = test_config(); diff --git a/crates/auths-witness-node/tests/cases/mod.rs b/crates/auths-witness-node/tests/cases/mod.rs index c7bf96e70..4ab0cee2e 100644 --- a/crates/auths-witness-node/tests/cases/mod.rs +++ b/crates/auths-witness-node/tests/cases/mod.rs @@ -1,3 +1,4 @@ +mod anchor; mod config; mod cosign; pub mod support; diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 91a8fd1ff..9873e9937 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -2,7 +2,7 @@ name = "xtask" version = "0.1.0" license = "MIT OR Apache-2.0" -edition = "2021" +edition = "2024" publish = false repository.workspace = true homepage.workspace = true @@ -26,5 +26,8 @@ tree-sitter = "0.24" tree-sitter-rust = "0.23" walkdir = "2" +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/xtask/src/check_admission_policy.rs b/crates/xtask/src/check_admission_policy.rs index 0cb34c41c..e4cf49122 100644 --- a/crates/xtask/src/check_admission_policy.rs +++ b/crates/xtask/src/check_admission_policy.rs @@ -36,7 +36,10 @@ impl std::fmt::Display for AdmissionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SchemaInvalid => { - write!(f, "admission policy does not validate against admission-policy.schema.json") + write!( + f, + "admission policy does not validate against admission-policy.schema.json" + ) } Self::Parse(e) => write!(f, "admission policy is unparseable: {e}"), Self::InvalidOperator { name, reason } => { diff --git a/crates/xtask/src/check_anchor_discipline.rs b/crates/xtask/src/check_anchor_discipline.rs index b07c65449..33ff91d09 100644 --- a/crates/xtask/src/check_anchor_discipline.rs +++ b/crates/xtask/src/check_anchor_discipline.rs @@ -207,22 +207,18 @@ fn check_swallowed_results( .child_by_field_name("pattern") .is_some_and(|p| &source[p.byte_range()] == "_"); - if has_wildcard { - if let Some(value) = node.child_by_field_name("value") { - if let Some(method_name) = - subtree_contains_banned_call(value, source, BANNED_SWALLOW_METHODS) - { - let start = node.start_position(); - violations.push(Violation { - file: file.to_path_buf(), - line: start.row + 1, - col: start.column + 1, - name: format!( - "let _ = {method_name}(...) — must handle Result, not discard" - ), - }); - } - } + if has_wildcard + && let Some(value) = node.child_by_field_name("value") + && let Some(method_name) = + subtree_contains_banned_call(value, source, BANNED_SWALLOW_METHODS) + { + let start = node.start_position(); + violations.push(Violation { + file: file.to_path_buf(), + line: start.row + 1, + col: start.column + 1, + name: format!("let _ = {method_name}(...) — must handle Result, not discard"), + }); } } diff --git a/crates/xtask/src/check_binding_boundary.rs b/crates/xtask/src/check_binding_boundary.rs index 864c1e606..ae87325ee 100644 --- a/crates/xtask/src/check_binding_boundary.rs +++ b/crates/xtask/src/check_binding_boundary.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; -use anyhow::{bail, Context}; +use anyhow::{Context, bail}; const BINDINGS_SRC: &str = "packages/auths-node/src"; const DEEP_PREFIXES: &[&str] = &["use auths_id::", "use auths_core::", "use auths_storage::"]; diff --git a/crates/xtask/src/check_command_drift.rs b/crates/xtask/src/check_command_drift.rs index e7c6e21b6..eeddac4a2 100644 --- a/crates/xtask/src/check_command_drift.rs +++ b/crates/xtask/src/check_command_drift.rs @@ -104,6 +104,9 @@ struct Violation { /// check_command_drift::run(workspace_root())?; /// ``` pub fn run(workspace_root: &Path) -> anyhow::Result<()> { + check_xtask_alias_ships(workspace_root)?; + check_release_stages_witness_node(workspace_root)?; + println!("Building auths-cli..."); let status = Command::new("cargo") .args(["build", "--package", "auths-cli"]) @@ -143,6 +146,57 @@ pub fn run(workspace_root: &Path) -> anyhow::Result<()> { } } +/// Every doc prints `cargo xtask …`; that shorthand only resolves from a clone +/// when `.cargo/config.toml` ships the `[alias] xtask` entry. A bare `.cargo/` +/// ignore once excluded that file entirely, so a fresh clone got `error: no such +/// command: xtask`. Fail closed if the alias is missing. +fn check_xtask_alias_ships(workspace_root: &Path) -> anyhow::Result<()> { + let path = workspace_root.join(".cargo/config.toml"); + let text = std::fs::read_to_string(&path).map_err(|e| { + anyhow::anyhow!( + "{}: {e} — the `cargo xtask` alias must be committed so clones resolve it", + path.display() + ) + })?; + let defines_xtask = text + .lines() + .skip_while(|l| l.trim() != "[alias]") + .skip(1) + .take_while(|l| !l.trim_start().starts_with('[')) + .any(|l| l.trim_start().starts_with("xtask")); + if !defines_xtask { + anyhow::bail!( + "{} has no `[alias] xtask` entry — `cargo xtask …` in the docs will not resolve \ + from a clone", + path.display() + ); + } + Ok(()) +} + +/// The docs present `witness-node` as an installable binary; the release must +/// stage it or `brew install` ships an archive without it. Assert the release +/// workflow copies `witness-node` out of the build directory. +fn check_release_stages_witness_node(workspace_root: &Path) -> anyhow::Result<()> { + let path = workspace_root.join(".github/workflows/release.yml"); + let text = + std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; + let stages_it = text.lines().any(|line| { + let line = line.trim(); + (line.starts_with("cp ") || line.contains("Copy-Item")) + && line.contains("release/") + && line.contains("witness-node") + }); + if !stages_it { + anyhow::bail!( + "{} does not stage `witness-node` into the release archive — the docs present it \ + as an installable binary", + path.display() + ); + } + Ok(()) +} + /// Discover the full two-level command tree (plus per-node long-flag sets) /// from the built binary's help output. fn build_command_tree(binary: &Path) -> anyhow::Result { diff --git a/crates/xtask/src/check_constant_time.rs b/crates/xtask/src/check_constant_time.rs index cc9766c5b..024bcb570 100644 --- a/crates/xtask/src/check_constant_time.rs +++ b/crates/xtask/src/check_constant_time.rs @@ -203,33 +203,33 @@ fn check_secret_partialeq( let name = node .child_by_field_name("name") .map(|n| source[n.byte_range()].to_string()); - if let Some(name) = name { - if secret_types.contains(&name) { - // tree-sitter-rust places attributes as preceding siblings of - // struct_item/enum_item, not as children. Walk backwards - // through siblings to find derive attributes. - let mut prev = node.prev_sibling(); - while let Some(sib) = prev { - let sk = sib.kind(); - if sk == "attribute_item" || sk == "attribute" { - let text = &source[sib.byte_range()]; - if text.contains("derive") - && (text.contains("PartialEq") || text.contains("Eq")) - { - let start = sib.start_position(); - let ty: &'static str = Box::leak(name.clone().into_boxed_str()); - violations.push(Violation { - file: file.to_path_buf(), - line: start.row + 1, - col: start.column + 1, - kind: ViolationKind::SecretPartialEq(ty), - }); - } - } else { - break; // stop once we hit a non-attribute sibling + if let Some(name) = name + && secret_types.contains(&name) + { + // tree-sitter-rust places attributes as preceding siblings of + // struct_item/enum_item, not as children. Walk backwards + // through siblings to find derive attributes. + let mut prev = node.prev_sibling(); + while let Some(sib) = prev { + let sk = sib.kind(); + if sk == "attribute_item" || sk == "attribute" { + let text = &source[sib.byte_range()]; + if text.contains("derive") + && (text.contains("PartialEq") || text.contains("Eq")) + { + let start = sib.start_position(); + let ty: &'static str = Box::leak(name.clone().into_boxed_str()); + violations.push(Violation { + file: file.to_path_buf(), + line: start.row + 1, + col: start.column + 1, + kind: ViolationKind::SecretPartialEq(ty), + }); } - prev = sib.prev_sibling(); + } else { + break; // stop once we hit a non-attribute sibling } + prev = sib.prev_sibling(); } } } diff --git a/crates/xtask/src/check_error_codes.rs b/crates/xtask/src/check_error_codes.rs new file mode 100644 index 000000000..b76dcb9ea --- /dev/null +++ b/crates/xtask/src/check_error_codes.rs @@ -0,0 +1,270 @@ +//! Error-code lint: locks the error corpus's house style so drift fails the build. +//! +//! Three invariants, checked against `docs/errors/registry.lock` (generated by +//! `gen-error-docs`) and the per-code docs: +//! +//! 1. Every `[AUTHS-E####]` string literal emitted in `crates/**/src` names a +//! registered code — a code that resolves to "unknown" via `auths error show` +//! is a broken promise. +//! 2. Every registered code's generated doc carries a `## Suggestion`, except an +//! explicit allowlist of codes whose message intentionally carries its own +//! guidance. Coverage only climbs. +//! 3. The user-facing render surface never reaches for `panic!`/`todo!` or a +//! `Box` — a failure to render an error must not itself panic. + +use std::collections::BTreeSet; +use std::path::Path; + +use walkdir::WalkDir; + +/// Codes whose generated doc intentionally has no `## Suggestion` — the variant's +/// own message carries the guidance, so a generic fix line would only dilute it. +/// Keep this list tight: adding a code here is a deliberate, reviewable choice. +const SUGGESTIONLESS_ALLOWLIST: &[&str] = &[ + // auths-sdk SetupError::InvalidSetupConfig — the message states the exact param. + "AUTHS-E5007", +]; + +/// Ceiling on non-allowlisted codes that still lack a `## Suggestion` — the legacy +/// backlog that predates suggestion coverage. This is a one-way ratchet: coverage +/// may only climb (this number may only fall). It fails the build the moment a code +/// loses its suggestion or a new suggestion-less code lands, which is exactly the +/// silent-regression class the corpus is meant to lock out. Lower it as the backlog +/// is worked down; never raise it. +const MAX_UNDOCUMENTED_CODES: usize = 60; + +/// Run the error-code lint against the workspace. +/// +/// Args: +/// * `root`: the repository root. +/// +/// Usage: +/// ```ignore +/// check_error_codes::run(workspace_root)?; +/// ``` +pub fn run(root: &Path) -> anyhow::Result<()> { + let registered = parse_registry_lock(root)?; + if registered.is_empty() { + anyhow::bail!( + "docs/errors/registry.lock has no codes — run `cargo xtask gen-error-docs` first" + ); + } + + let mut failures: Vec = Vec::new(); + + for (file, code) in scan_emitted_code_literals(root)? { + if !registered.contains(&code) { + failures.push(format!( + "{file}: emits {code} but it is not in docs/errors/registry.lock \ + (register it via an AuthsErrorInfo variant, or drop the tag)" + )); + } + } + + let mut undocumented: Vec = Vec::new(); + for code in ®istered { + if SUGGESTIONLESS_ALLOWLIST.contains(&code.as_str()) { + continue; + } + if !doc_has_suggestion(root, code)? { + undocumented.push(code.clone()); + } + } + if undocumented.len() > MAX_UNDOCUMENTED_CODES { + failures.push(format!( + "{} codes lack a `## Suggestion` but the ratchet allows at most {} — \ + coverage regressed. Add a suggestion to the offending AuthsErrorInfo arm \ + (do not raise the ceiling). Undocumented:\n{}", + undocumented.len(), + MAX_UNDOCUMENTED_CODES, + indent(&undocumented) + )); + } + + failures.extend(scan_render_path_for_panics(root)?); + + if failures.is_empty() { + println!( + " ok {} error codes registered; all emitted tags lookupable, \ + suggestion coverage within ratchet ({} undocumented, ceiling {})", + registered.len(), + undocumented.len(), + MAX_UNDOCUMENTED_CODES + ); + Ok(()) + } else { + anyhow::bail!("error-code lint failed:\n{}", indent(&failures)); + } +} + +fn indent(items: &[String]) -> String { + items + .iter() + .map(|f| format!(" - {f}")) + .collect::>() + .join("\n") +} + +/// Parse the registered codes from `docs/errors/registry.lock`. +fn parse_registry_lock(root: &Path) -> anyhow::Result> { + let path = root.join("docs/errors/registry.lock"); + let text = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?; + let mut codes = BTreeSet::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((code, _binding)) = line.split_once('=') { + codes.insert(code.trim().to_string()); + } + } + Ok(codes) +} + +/// Find every `[AUTHS-E####]` literal emitted in `crates/**/src`, paired with the +/// file it lives in. xtask is excluded — its own fixtures deliberately emit codes. +fn scan_emitted_code_literals(root: &Path) -> anyhow::Result> { + let crates_dir = root.join("crates"); + let mut hits = Vec::new(); + for entry in WalkDir::new(&crates_dir).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let path_str = path.to_string_lossy(); + if path_str.contains("/xtask/") || !path_str.contains("/src/") { + continue; + } + let content = std::fs::read_to_string(path).unwrap_or_default(); + for code in extract_bracket_codes(&content) { + hits.push((rel(root, path), code)); + } + } + Ok(hits) +} + +/// Extract every `[AUTHS-E####]` code from `content` (the bracketed emission form). +fn extract_bracket_codes(content: &str) -> Vec { + let mut out = Vec::new(); + let bytes = content.as_bytes(); + let needle = b"[AUTHS-E"; + let mut i = 0; + while let Some(pos) = find_from(bytes, needle, i) { + let digits_start = pos + needle.len(); + let mut j = digits_start; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + // Exactly `[AUTHS-E]` with at least one digit. + if j > digits_start && j < bytes.len() && bytes[j] == b']' { + out.push(format!("AUTHS-E{}", &content[digits_start..j])); + } + i = pos + needle.len(); + } + out +} + +fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option { + if from >= haystack.len() { + return None; + } + haystack[from..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|p| p + from) +} + +/// Whether the generated doc for `code` carries a `## Suggestion` section. +fn doc_has_suggestion(root: &Path, code: &str) -> anyhow::Result { + let path = root.join("docs/errors").join(format!("{code}.md")); + match std::fs::read_to_string(&path) { + Ok(text) => Ok(text.contains("## Suggestion")), + // A missing doc is a staleness issue gen-error-docs owns; report as "no + // suggestion" so it surfaces here too rather than passing silently. + Err(_) => Ok(false), + } +} + +/// Scan the error render surface for constructs that could panic while rendering +/// a user-facing error. Keeps the guard narrow (the renderer file) to avoid +/// false positives elsewhere. +fn scan_render_path_for_panics(root: &Path) -> anyhow::Result> { + let renderer = root.join("crates/auths-cli/src/errors/renderer.rs"); + let mut failures = Vec::new(); + let Ok(content) = std::fs::read_to_string(&renderer) else { + return Ok(failures); + }; + let banned = [ + "panic!(", + "todo!(", + "unimplemented!(", + "Box String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_bracketed_codes_only() { + let src = r#" + bail!("bad -O\n [AUTHS-E0031]"); + let code = "AUTHS-E5909"; // bare, not bracketed + eprintln!("[{code}]"); // dynamic, not a literal + // reference to [AUTHS-E4203] in a comment + "#; + let codes = extract_bracket_codes(src); + assert!(codes.contains(&"AUTHS-E0031".to_string())); + assert!(codes.contains(&"AUTHS-E4203".to_string())); + assert!( + !codes.contains(&"AUTHS-E5909".to_string()), + "a bare (non-bracketed) code is not an emission" + ); + } + + #[test] + fn unregistered_literal_is_detected() { + // A bracket literal whose code is absent from the registry must be caught — + // this is exactly the orphan-tag class the lint exists to lock out. + let registered: BTreeSet = ["AUTHS-E4203".to_string()].into_iter().collect(); + let emitted = extract_bracket_codes("guard: [AUTHS-E9999]"); + let unregistered: Vec<_> = emitted + .iter() + .filter(|c| !registered.contains(*c)) + .collect(); + assert_eq!(unregistered, vec![&"AUTHS-E9999".to_string()]); + } + + #[test] + fn ignores_partial_or_malformed_tags() { + assert!(extract_bracket_codes("[AUTHS-E]").is_empty()); + assert!(extract_bracket_codes("[AUTHS-Eabcd]").is_empty()); + assert!(extract_bracket_codes("[AUTHS-E1234").is_empty()); + } +} diff --git a/crates/xtask/src/check_error_docs_published.rs b/crates/xtask/src/check_error_docs_published.rs new file mode 100644 index 000000000..779ccca8d --- /dev/null +++ b/crates/xtask/src/check_error_docs_published.rs @@ -0,0 +1,208 @@ +//! Error-docs coverage lint: every registered `AUTHS-E` code must have a +//! generated page on disk, and every generated page must name a registered code. +//! +//! This is a page-existence/drift guard, distinct from `check_error_codes` +//! (which locks registration and suggestion coverage). It reuses the same +//! registry-enumeration path — the `code = crate::Type::Variant` bindings in +//! `docs/errors/registry.lock` — and pairs each code with its published +//! `docs/errors/AUTHS-E.md` page: +//! +//! 1. A registered code with no page on disk fails the build — the generated +//! docs went stale against the registry. +//! 2. A page on disk with no registered code fails the build — an orphan left +//! behind after a code was reassigned or removed. +//! +//! Both directions are fixed the same way: re-run `cargo xtask gen-error-docs`. + +use std::collections::BTreeSet; +use std::path::Path; + +/// Verify the generated error-docs pages are in sync with the registered codes. +/// +/// Enumerates the registered codes from `docs/errors/registry.lock`, then +/// asserts a `docs/errors/AUTHS-E.md` page exists for each and that no +/// `AUTHS-E*.md` page exists without a registered code. Prints one +/// `path — reason` line per violation and fails on any. +/// +/// Args: +/// * `workspace_root`: the repository root. +/// +/// Usage: +/// ```ignore +/// check_error_docs_published::run(workspace_root)?; +/// ``` +pub fn run(workspace_root: &Path) -> anyhow::Result<()> { + let registered = parse_registered_codes(workspace_root)?; + if registered.is_empty() { + anyhow::bail!( + "docs/errors/registry.lock has no codes — run `cargo xtask gen-error-docs` first" + ); + } + + let published = scan_published_codes(workspace_root)?; + let violations = find_drift(®istered, &published); + + if violations.is_empty() { + println!( + " ok {} registered error codes each have a generated docs/errors page; no orphans", + registered.len() + ); + Ok(()) + } else { + for v in &violations { + eprintln!("{v}"); + } + anyhow::bail!( + "{} error-docs coverage violation(s): a registered code has no generated page, \ + or a page names no registered code — run `cargo xtask gen-error-docs`", + violations.len() + ) + } +} + +/// Parse the registered codes from `docs/errors/registry.lock`, ignoring the +/// comment header and blank lines. Each entry is a `CODE = binding` line; only +/// the code is needed here. +fn parse_registered_codes(root: &Path) -> anyhow::Result> { + let path = root.join("docs/errors/registry.lock"); + let text = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?; + let mut codes = BTreeSet::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((code, _binding)) = line.split_once('=') { + codes.insert(code.trim().to_string()); + } + } + Ok(codes) +} + +/// Collect the codes that have a published page: every `AUTHS-E*.md` file under +/// `docs/errors/`, mapped back to its bare code. Non-code pages (`index.md`) and +/// non-markdown files are ignored. +fn scan_published_codes(root: &Path) -> anyhow::Result> { + let dir = root.join("docs/errors"); + let mut codes = BTreeSet::new(); + for entry in std::fs::read_dir(&dir) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", dir.display()))? + { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if let Some(code) = name.strip_suffix(".md") + && code.starts_with("AUTHS-E") + { + codes.insert(code.to_string()); + } + } + Ok(codes) +} + +/// Compare the registered and published code sets, returning one +/// `docs/errors/ — reason` message per drift. Both sets are ordered, so +/// the messages come out sorted and stable. +fn find_drift(registered: &BTreeSet, published: &BTreeSet) -> Vec { + let mut violations = Vec::new(); + for code in registered.difference(published) { + violations.push(format!( + "docs/errors/{code}.md — registered code {code} has no generated docs page" + )); + } + for code in published.difference(registered) { + violations.push(format!( + "docs/errors/{code}.md — orphan page: {code} is not in docs/errors/registry.lock" + )); + } + violations +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn code_set(codes: &[&str]) -> BTreeSet { + codes.iter().map(|c| c.to_string()).collect() + } + + fn write_fixture(root: &Path, codes: &[&str], pages: &[&str]) { + let errors = root.join("docs/errors"); + std::fs::create_dir_all(&errors).unwrap(); + let mut lock = String::from("# AUTHS error-code registry lock.\n"); + for code in codes { + lock.push_str(&format!("{code} = auths-core::Example::Variant\n")); + } + std::fs::write(errors.join("registry.lock"), lock).unwrap(); + for page in pages { + std::fs::write(errors.join(format!("{page}.md")), "# page\n").unwrap(); + } + } + + #[test] + fn complete_set_passes() { + let registered = code_set(&["AUTHS-E1001", "AUTHS-E1002"]); + let published = code_set(&["AUTHS-E1001", "AUTHS-E1002"]); + assert!(find_drift(®istered, &published).is_empty()); + } + + #[test] + fn registered_code_without_a_page_is_flagged() { + let registered = code_set(&["AUTHS-E1001", "AUTHS-E1002"]); + let published = code_set(&["AUTHS-E1001"]); + let violations = find_drift(®istered, &published); + assert_eq!(violations.len(), 1); + assert!( + violations[0].contains("AUTHS-E1002"), + "the code missing a page must be named: {}", + violations[0] + ); + } + + #[test] + fn orphan_page_without_a_registered_code_is_flagged() { + let registered = code_set(&["AUTHS-E1001"]); + let published = code_set(&["AUTHS-E1001", "AUTHS-E9999"]); + let violations = find_drift(®istered, &published); + assert_eq!(violations.len(), 1); + assert!( + violations[0].contains("AUTHS-E9999"), + "the orphan page's code must be named: {}", + violations[0] + ); + } + + #[test] + fn run_passes_when_every_code_has_a_page() { + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + &["AUTHS-E1001", "AUTHS-E1002"], + &["AUTHS-E1001", "AUTHS-E1002"], + ); + assert!(run(tmp.path()).is_ok()); + } + + #[test] + fn run_fails_when_a_registered_code_has_no_page() { + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + &["AUTHS-E1001", "AUTHS-E1002"], + &["AUTHS-E1001"], + ); + assert!(run(tmp.path()).is_err()); + } + + #[test] + fn non_code_pages_are_ignored() { + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path(), &["AUTHS-E1001"], &["AUTHS-E1001"]); + std::fs::write(tmp.path().join("docs/errors/index.md"), "# index\n").unwrap(); + assert!( + run(tmp.path()).is_ok(), + "index.md is not a per-code page and must not read as an orphan" + ); + } +} diff --git a/crates/xtask/src/check_paste_integrity.rs b/crates/xtask/src/check_paste_integrity.rs new file mode 100644 index 000000000..945af0689 --- /dev/null +++ b/crates/xtask/src/check_paste_integrity.rs @@ -0,0 +1,535 @@ +//! Paste-integrity lint: fails when a copyable shell block would break the +//! moment a reader pastes it verbatim. +//! +//! It reads two kinds of copyable command text out of a checked-out tree: +//! fenced shell blocks (bash / sh / console / shell / zsh) in Markdown, and the +//! command strings a site actually wires to a copy button — `copy="…"` props +//! and the text shown inside `` / `` +//! in TypeScript/TSX. Each block is checked for three paste hazards: +//! +//! * an angle-bracket `` a reader has to hand-edit, +//! * a bare `$VAR` with no matching assignment in the same block, and +//! * a leading `$ ` shell-prompt glyph copied into the payload. +//! +//! A block may opt out only by carrying `paste-integrity: illustrative` on the +//! line immediately above it (for deliberately non-runnable teasers). + +use std::collections::HashSet; +use std::path::Path; + +use regex_lite::Regex; +use walkdir::{DirEntry, WalkDir}; + +/// Fenced-code languages whose bodies are copyable shell commands. +const SHELL_LANGS: &[&str] = &["bash", "sh", "console", "shell", "zsh"]; + +/// Variables the shell always provides, so a reference without a matching +/// assignment in the block is still safe to paste. +const ALWAYS_SET: &[&str] = &[ + "HOME", + "PATH", + "PWD", + "USER", + "SHELL", + "TMPDIR", + "LANG", + "TERM", + "CI", + "GITHUB_WORKSPACE", + "GITHUB_ENV", + "GITHUB_OUTPUT", + "GITHUB_PATH", + "RUNNER_OS", + "RUNNER_TEMP", +]; + +/// The marker that exempts the block immediately below it. +const OPT_OUT_MARKER: &str = "paste-integrity: illustrative"; + +/// One copyable shell block pulled out of a source file, with enough context +/// to report a precise `path:line` for anything wrong inside it. +struct ShellBlock { + file: String, + start_line: usize, + body: String, + is_copyable: bool, + opted_out: bool, +} + +/// A single paste hazard found in a block. +struct Violation { + file: String, + line: usize, + reason: String, +} + +impl ShellBlock { + /// Build a copyable block anchored at `start_line` — the shape a copy + /// button ships and a reader pastes. + /// + /// Args: + /// * `file`: source path used in the reported location. + /// * `start_line`: 1-based line the block body begins on. + /// * `body`: the command text, one or more lines. + /// + /// Usage: + /// ```ignore + /// let block = ShellBlock::copy("verify.tsx", 1, "npx -y @auths-dev/mcp verify"); + /// ``` + #[cfg(test)] + fn copy(file: &str, start_line: usize, body: &str) -> Self { + Self { + file: file.to_string(), + start_line, + body: body.to_string(), + is_copyable: true, + opted_out: false, + } + } + + /// Point a violation at the `offset`-th body line of this block. + fn at(&self, offset: usize, reason: impl Into) -> Violation { + Violation { + file: self.file.clone(), + line: self.start_line + offset, + reason: reason.into(), + } + } +} + +/// Scan a checked-out tree for copyable shell blocks that break on a literal +/// paste, printing every hazard and failing if any are found. +/// +/// Args: +/// * `root`: the tree to scan (an `auths`, site, or docs checkout). +/// +/// Usage: +/// ```ignore +/// check_paste_integrity::run(std::path::Path::new("."))?; +/// ``` +pub fn run(root: &Path) -> anyhow::Result<()> { + let mut violations = Vec::new(); + let mut files_scanned = 0u32; + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_skipped_dir(e)) + .filter_map(|e| e.ok()) + { + let path = entry.path(); + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(_) => continue, + }; + let rel = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + let blocks = match ext { + "md" | "mdx" | "mdoc" => markdown_blocks(&rel, &source), + "ts" | "tsx" => site_blocks(&rel, &source), + _ => continue, + }; + files_scanned += 1; + for block in &blocks { + violations.extend(lint_block(block)); + } + } + + if violations.is_empty() { + println!( + "paste-integrity check: {files_scanned} files scanned, every copyable shell block survives a literal paste" + ); + Ok(()) + } else { + for v in &violations { + eprintln!("{}:{} — {}", v.file, v.line, v.reason); + } + anyhow::bail!( + "{} paste-integrity violation(s): a copyable shell block would break on a literal paste", + violations.len() + ) + } +} + +/// Prune build output, dependencies, and hidden directories from the walk +/// without ever pruning the scan root itself. +fn is_skipped_dir(entry: &DirEntry) -> bool { + if !entry.file_type().is_dir() || entry.depth() == 0 { + return false; + } + let name = entry.file_name().to_string_lossy(); + name == "target" || name == "node_modules" || name.starts_with('.') +} + +/// Apply the three paste-hazard rules to one copyable block. +fn lint_block(block: &ShellBlock) -> Vec { + if block.opted_out || !block.is_copyable { + return Vec::new(); + } + let angle = Regex::new(r"<[A-Za-z][^>]*>").unwrap(); + let var = Regex::new(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?").unwrap(); + let assigned = collect_assignments(&block.body); + + let mut out = Vec::new(); + for (offset, raw) in block.body.lines().enumerate() { + let line = strip_comment(raw); + if line.trim().is_empty() { + continue; + } + if angle.is_match(line) { + out.push(block.at(offset, "angle-bracket placeholder in a copyable command")); + } + if line.trim_start().starts_with("$ ") { + out.push(block.at( + offset, + "leading `$ ` shell-prompt glyph in a copyable block", + )); + } + for name in unset_vars(line, &var, &assigned) { + out.push(block.at( + offset, + format!("`${name}` used with no assignment in the same block"), + )); + } + } + out +} + +/// Names assigned anywhere in the block, via `export NAME=` or a bare `NAME=` +/// command prefix. Such names are in scope for a later `$NAME`. +fn collect_assignments(body: &str) -> HashSet { + let re = Regex::new(r"(?:^|\s)(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=").unwrap(); + re.captures_iter(body) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +/// Return the names of `$VAR` / `${VAR}` references on this line that would be +/// empty on paste — skipping shell positionals/specials (which never match the +/// name pattern), single-quoted literals (the shell does not expand them), +/// escaped `\$`, assignments made earlier in the block, and the handful of +/// variables the shell always sets. +fn unset_vars(line: &str, var: &Regex, assigned: &HashSet) -> Vec { + let quoted = single_quoted_spans(line); + let bytes = line.as_bytes(); + let mut out = Vec::new(); + for caps in var.captures_iter(line) { + let whole = caps.get(0).unwrap(); + let start = whole.start(); + if start >= 1 && bytes[start - 1] == b'\\' { + continue; + } + if quoted.iter().any(|(s, e)| start >= *s && start < *e) { + continue; + } + let name = caps.get(1).unwrap().as_str(); + if name == "_" || assigned.contains(name) || ALWAYS_SET.contains(&name) { + continue; + } + out.push(name.to_string()); + } + out +} + +/// Byte ranges on the line that fall inside single quotes, where the shell +/// performs no variable expansion. +fn single_quoted_spans(line: &str) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + let mut open: Option = None; + for (i, c) in line.char_indices() { + if c == '\'' { + match open { + Some(start) => { + spans.push((start, i)); + open = None; + } + None => open = Some(i + 1), + } + } + } + spans +} + +/// Drop a trailing shell comment so a placeholder or variable named only in +/// prose after `#` is not treated as a command. A `#` opens a comment only at +/// the start of a word and outside quotes, so URL fragments and quoted `#` +/// survive. +fn strip_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut in_single = false; + let mut in_double = false; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'#' if !in_single && !in_double => { + let prev = if i == 0 { None } else { Some(bytes[i - 1]) }; + if prev.is_none() || prev == Some(b' ') || prev == Some(b'\t') { + return &line[..i]; + } + } + _ => {} + } + } + line +} + +/// Pull every fenced shell block out of a Markdown source, tracking the line +/// each body starts on and honouring an immediately-preceding opt-out marker. +fn markdown_blocks(file: &str, source: &str) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + let mut blocks = Vec::new(); + let mut i = 0; + while i < lines.len() { + let Some(lang) = fence_language(lines[i].trim_start()) else { + i += 1; + continue; + }; + let open = i; + let mut j = open + 1; + while j < lines.len() && !lines[j].trim_start().starts_with("```") { + j += 1; + } + if SHELL_LANGS.contains(&lang.as_str()) { + blocks.push(ShellBlock { + file: file.to_string(), + start_line: open + 2, + body: lines[open + 1..j].join("\n"), + is_copyable: true, + opted_out: preceding_marker(&lines, open), + }); + } + i = j + 1; + } + blocks +} + +/// The lower-cased language tag on an opening code fence, if the line opens one. +fn fence_language(trimmed: &str) -> Option { + let rest = trimmed.strip_prefix("```")?; + let lang: String = rest + .trim_start() + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + if lang.is_empty() { + None + } else { + Some(lang.to_ascii_lowercase()) + } +} + +/// True when the nearest non-blank line above `index` carries the opt-out marker. +fn preceding_marker(lines: &[&str], index: usize) -> bool { + let mut k = index; + while k > 0 { + k -= 1; + let line = lines[k].trim(); + if line.is_empty() { + continue; + } + return line.contains(OPT_OUT_MARKER); + } + false +} + +/// Pull copyable command strings out of TypeScript/TSX: the `copy="…"` prop a +/// copy button ships, and the text shown inside `` / +/// ``. +fn site_blocks(file: &str, source: &str) -> Vec { + let mut blocks = Vec::new(); + + let copy = Regex::new(r#"\bcopy=(?:"([^"]*)"|'([^']*)')"#).unwrap(); + for caps in copy.captures_iter(source) { + let m = caps.get(1).or_else(|| caps.get(2)).unwrap(); + blocks.push(string_block(file, source, m.start(), m.as_str())); + } + + for tag in ["Prompt", "BashLines"] { + let re = Regex::new(&format!(r"(?s)<{tag}[^>]*>(.*?)")).unwrap(); + for caps in re.captures_iter(source) { + let inner = caps.get(1).unwrap(); + let text = decode_entities(inner.as_str()); + blocks.push(string_block(file, source, inner.start(), &text)); + } + } + + blocks +} + +/// Wrap an extracted command string as a copyable block anchored at the source +/// line it starts on. +fn string_block(file: &str, source: &str, offset: usize, body: &str) -> ShellBlock { + ShellBlock { + file: file.to_string(), + start_line: line_at(source, offset), + body: body.to_string(), + is_copyable: true, + opted_out: false, + } +} + +/// The 1-based line number of a byte offset in `source`. +fn line_at(source: &str, offset: usize) -> usize { + source[..offset].bytes().filter(|&b| b == b'\n').count() + 1 +} + +/// Turn the HTML entities a TSX author writes for shell metacharacters back +/// into the characters a reader would actually paste. +fn decode_entities(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("$", "$") + .replace("’", "'") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn angle_bracket_placeholder_is_flagged() { + let block = ShellBlock::copy( + "verify.tsx", + 1, + "npx -y @auths-dev/mcp verify-spend --log spend.jsonl --agent --root ", + ); + assert!( + lint_block(&block) + .iter() + .any(|v| v.reason.contains("placeholder")), + "an / placeholder must fail the paste check" + ); + } + + #[test] + fn interpolated_command_passes() { + let block = ShellBlock::copy( + "verify.tsx", + 1, + "npx -y @auths-dev/mcp verify-spend --log spend.jsonl --registry ./registry \ + --agent did:keri:EHiKP_2dx1U88s4Upir4BxQ1Qc21203WaW1JfSJvn0i2 \ + --root did:keri:EF6K8G4ZgfIjt788itIogc8eDXP948mAo1aQgXwQZJa2", + ); + assert!( + lint_block(&block).is_empty(), + "the interpolated form pastes cleanly and must pass" + ); + } + + #[test] + fn buyer_integration_pane_snippet_passes() { + let block = ShellBlock::copy( + "integration-pane.tsx", + 16, + "npx -y @auths-dev/mcp wrap --scope paid.call --budget '$1' --ttl 30m \ + --rail x402 --test-mode -- npx -y mcp-remote https://api.example.com/mcp", + ); + assert!( + lint_block(&block).is_empty(), + "the known-good interpolated pane must never regress" + ); + } + + #[test] + fn exported_variable_reference_passes() { + let block = ShellBlock::copy( + "anchor.md", + 14, + "export AGENT_DID=did:keri:EXAMPLE\nauths-mcp export-attestation --agent \"$AGENT_DID\"", + ); + assert!( + lint_block(&block).is_empty(), + "a $VAR with a matching assignment in the block is fine" + ); + } + + #[test] + fn unassigned_variable_is_flagged() { + let block = ShellBlock::copy("anchor.md", 5, "auths-mcp anchor --seed $SEED_ID"); + assert!( + lint_block(&block) + .iter() + .any(|v| v.reason.contains("SEED_ID")), + "a bare $SEED_ID with no assignment must fail" + ); + } + + #[test] + fn prompt_glyph_is_flagged() { + let block = ShellBlock::copy("landing.tsx", 56, "$ npx -y @auths-dev/mcp verify-spend"); + assert!( + lint_block(&block) + .iter() + .any(|v| v.reason.contains("prompt")), + "a leading `$ ` prompt glyph must fail" + ); + } + + #[test] + fn command_substitution_and_positionals_pass() { + let block = ShellBlock::copy( + "network.tsx", + 141, + "WITNESS_SEED=$(openssl rand -hex 32) docker compose up -d", + ); + assert!( + lint_block(&block).is_empty(), + "command substitution and inline assignment are safe" + ); + } + + #[test] + fn markdown_fenced_block_extraction_reports_precise_lines() { + let source = "intro\n\n```bash\nauths-mcp anchor --root \n```\n"; + let blocks = markdown_blocks("guide.md", source); + assert_eq!(blocks.len(), 1); + let found = lint_block(&blocks[0]); + assert_eq!(found.len(), 1); + assert_eq!(found[0].line, 4, "the placeholder is on source line 4"); + } + + #[test] + fn illustrative_marker_opts_a_block_out() { + let source = "\n```bash\nauths-mcp verify --agent \n```\n"; + let blocks = markdown_blocks("guide.md", source); + assert_eq!(blocks.len(), 1); + assert!( + lint_block(&blocks[0]).is_empty(), + "an explicitly illustrative block is exempt" + ); + } + + #[test] + fn tsx_copy_prop_is_extracted_and_linted() { + let source = "\">\n"; + let blocks = site_blocks("verify.tsx", source); + assert_eq!(blocks.len(), 1); + assert!( + lint_block(&blocks[0]) + .iter() + .any(|v| v.reason.contains("placeholder")) + ); + } + + #[test] + fn prompt_children_entities_are_decoded_and_linted() { + let source = + "--registry ./registry --agent <agent>\n"; + let blocks = site_blocks("verify.tsx", source); + assert_eq!(blocks.len(), 1); + assert!( + lint_block(&blocks[0]) + .iter() + .any(|v| v.reason.contains("placeholder")), + "an entity-encoded <agent> is still a placeholder on paste" + ); + } +} diff --git a/crates/xtask/src/gen_docs.rs b/crates/xtask/src/gen_docs.rs index b5a8b34f1..bf8bc27ab 100644 --- a/crates/xtask/src/gen_docs.rs +++ b/crates/xtask/src/gen_docs.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::path::Path; use std::process::Command; diff --git a/crates/xtask/src/gen_error_docs.rs b/crates/xtask/src/gen_error_docs.rs index 93c07ade9..036166ec5 100644 --- a/crates/xtask/src/gen_error_docs.rs +++ b/crates/xtask/src/gen_error_docs.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use walkdir::WalkDir; @@ -316,11 +316,7 @@ fn extract_variant_name(line: &str) -> Option { .chars() .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); - if name.is_empty() { - None - } else { - Some(name) - } + if name.is_empty() { None } else { Some(name) } } fn count_char(s: &str, ch: char) -> usize { @@ -354,25 +350,26 @@ fn parse_error_info_impls(lines: &[&str]) -> Vec { while i < lines.len() { let trimmed = lines[i].trim(); - if trimmed.contains("AuthsErrorInfo for ") && trimmed.contains("impl") { - if let Some(type_name) = extract_impl_type_name(trimmed) { - let impl_end = find_block_end(lines, i); - let impl_lines = &lines[i..impl_end]; - - let codes = parse_error_code_method(impl_lines); - let suggestions = parse_suggestion_method(impl_lines); - - if !codes.is_empty() { - results.push(ImplInfo { - type_name, - codes, - suggestions, - }); - } - - i = impl_end; - continue; + if trimmed.contains("AuthsErrorInfo for ") + && trimmed.contains("impl") + && let Some(type_name) = extract_impl_type_name(trimmed) + { + let impl_end = find_block_end(lines, i); + let impl_lines = &lines[i..impl_end]; + + let codes = parse_error_code_method(impl_lines); + let suggestions = parse_suggestion_method(impl_lines); + + if !codes.is_empty() { + results.push(ImplInfo { + type_name, + codes, + suggestions, + }); } + + i = impl_end; + continue; } i += 1; } @@ -386,11 +383,7 @@ fn extract_impl_type_name(line: &str) -> Option { .chars() .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); - if name.is_empty() { - None - } else { - Some(name) - } + if name.is_empty() { None } else { Some(name) } } fn find_block_end(lines: &[&str], start: usize) -> usize { @@ -429,10 +422,11 @@ fn parse_error_code_method(impl_lines: &[&str]) -> Vec { brace_depth += count_char(trimmed, '{') as i32; brace_depth -= count_char(trimmed, '}') as i32; - if trimmed.contains("Self::") && trimmed.contains("\"AUTHS-E") { - if let Some(mapping) = parse_code_arm(trimmed) { - results.push(mapping); - } + if trimmed.contains("Self::") + && trimmed.contains("\"AUTHS-E") + && let Some(mapping) = parse_code_arm(trimmed) + { + results.push(mapping); } if brace_depth <= 0 && !results.is_empty() @@ -469,6 +463,10 @@ fn parse_suggestion_method(impl_lines: &[&str]) -> Vec { let mut results = Vec::new(); let mut in_method = false; let mut brace_depth = 0i32; + // Accumulate each match arm into one logical line so rustfmt-wrapped arms + // (`=> Some(\n "…",\n)`) and block arms (`=> { Some("…") }`) parse as a unit, + // not just the single-line `=> Some("…"),` shape. + let mut arm = String::new(); for line in impl_lines { let trimmed = line.trim(); @@ -476,24 +474,45 @@ fn parse_suggestion_method(impl_lines: &[&str]) -> Vec { if trimmed.contains("fn suggestion") { in_method = true; brace_depth = 0; + arm.clear(); } - if in_method { - brace_depth += count_char(trimmed, '{') as i32; - brace_depth -= count_char(trimmed, '}') as i32; + if !in_method { + continue; + } - if trimmed.contains("Self::") && trimmed.contains("Some(\"") { - if let Some(mapping) = parse_suggestion_arm(trimmed) { - results.push(mapping); - } + brace_depth += count_char(trimmed, '{') as i32; + brace_depth -= count_char(trimmed, '}') as i32; + + // A new arm begins: drop any half-collected non-string arm (`None`, or a + // delegating `e.suggestion()`) and start fresh. + if trimmed.contains("Self::") { + arm.clear(); + } + + if !arm.is_empty() || trimmed.contains("Self::") { + // Rust `\`-newline continuation: strip the trailing backslash and join + // the next line directly (its indentation is already trimmed off), + // reconstructing the single logical string literal. + if arm.ends_with('\\') { + arm.pop(); } + arm.push_str(trimmed); - if brace_depth <= 0 && !results.is_empty() - || trimmed.starts_with("fn ") && in_method && !trimmed.contains("fn suggestion") + // Parse only a fully-closed arm — never one paused mid string-continuation. + if !arm.ends_with('\\') + && let Some(mapping) = parse_suggestion_arm(&arm) { - break; + results.push(mapping); + arm.clear(); } } + + if (brace_depth <= 0 && !results.is_empty()) + || (trimmed.starts_with("fn ") && !trimmed.contains("fn suggestion")) + { + break; + } } results } @@ -506,11 +525,16 @@ fn parse_suggestion_arm(line: &str) -> Option { .take_while(|c| c.is_alphanumeric() || *c == '_') .collect(); - let some_idx = line.find("Some(\"")?; - let text_start = some_idx + 6; - let text_rest = &line[text_start..]; - let text_end = text_rest.rfind("\")")?; - let text = text_rest[..text_end].to_string(); + // The payload is `Some("")`; rustfmt may separate `Some(` from the opening + // quote (a newline that arm-joining collapsed into the buffer), so locate the + // quote after `Some(` rather than requiring them adjacent. The text runs to the + // literal's closing quote — the last `"` in this single-arm buffer. + let some_idx = line.find("Some(")?; + let after_some = &line[some_idx + 5..]; + let quote_start = after_some.find('"')?; + let text_region = &after_some[quote_start + 1..]; + let text_end = text_region.rfind('"')?; + let text = text_region[..text_end].to_string(); if variant.is_empty() { return None; @@ -779,7 +803,9 @@ fn update_mkdocs_nav( println!(" updated mkdocs.yml (inserted error codes nav)"); } } else { - bail!("Cannot find insertion point in mkdocs.yml — add markers manually:\n {marker_start}\n {marker_end}"); + bail!( + "Cannot find insertion point in mkdocs.yml — add markers manually:\n {marker_start}\n {marker_end}" + ); } } @@ -916,3 +942,98 @@ fn generate_registry(entries: &[ErrorEntry]) -> String { out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_suggestion_arm_joins_wrapped() { + // A rustfmt-wrapped arm (open paren, string on the next line, close on a + // third) must recover the full suggestion, not be dropped. + let lines = [ + "impl AuthsErrorInfo for SetupError {", + " fn suggestion(&self) -> Option<&'static str> {", + " match self {", + " Self::WeakPassphrase { .. } => Some(", + " \"Use at least 12 characters with 3 of 4 character classes (lowercase, uppercase, digit, symbol)\",", + " ),", + " }", + " }", + "}", + ]; + let got = parse_suggestion_method(&lines); + assert_eq!(got.len(), 1); + assert_eq!(got[0].variant, "WeakPassphrase"); + assert!( + got[0].text.contains("Use at least 12 characters"), + "text was: {}", + got[0].text + ); + assert!( + got[0].text.ends_with("symbol)"), + "text was: {}", + got[0].text + ); + } + + #[test] + fn parse_suggestion_arm_handles_block_and_single_line() { + let lines = [ + " fn suggestion(&self) -> Option<&'static str> {", + " match self {", + " Self::Io(_) => Some(\"Check file permissions and disk space\"),", + " Self::Deserialization(_) => {", + " Some(\"The freeze state file may be corrupted; try deleting it\")", + " }", + " Self::CryptoError(e) => e.suggestion(),", + " Self::Nothing => None,", + " }", + " }", + ]; + let got = parse_suggestion_method(&lines); + let by_variant: std::collections::BTreeMap<_, _> = got + .iter() + .map(|m| (m.variant.as_str(), m.text.as_str())) + .collect(); + assert_eq!( + by_variant.get("Io"), + Some(&"Check file permissions and disk space") + ); + assert_eq!( + by_variant.get("Deserialization"), + Some(&"The freeze state file may be corrupted; try deleting it") + ); + // Delegating (`e.suggestion()`) and `None` arms carry no literal text. + assert!(!by_variant.contains_key("CryptoError")); + assert!(!by_variant.contains_key("Nothing")); + } + + #[test] + fn parse_suggestion_arm_reconstructs_backslash_continuation() { + // Rust `\`-newline continuation must be collapsed so the doc text matches the + // real runtime string (backslash + indentation removed). + let lines = [ + " fn suggestion(&self) -> Option<&'static str> {", + " match self {", + " Self::Key(_) => Some(", + " \"Set AUTHS_KEYCHAIN_BACKEND=file for headless \\", + " runs, or run `auths init --profile ci`.\",", + " ),", + " }", + " }", + ]; + let got = parse_suggestion_method(&lines); + assert_eq!(got.len(), 1); + assert!( + got[0].text.contains("headless runs"), + "continuation not collapsed: {}", + got[0].text + ); + assert!( + !got[0].text.contains('\\'), + "backslash leaked: {}", + got[0].text + ); + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index fb35d310d..dad9d225a 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -4,7 +4,10 @@ clippy::print_stdout, clippy::print_stderr, clippy::unwrap_used, - clippy::expect_used + clippy::expect_used, + // The AST-walking lint checkers nest tree-sitter node guards deliberately; + // long let-chains read worse than the nested `if`s here. + clippy::collapsible_if )] mod check_admission_policy; mod check_anchor_discipline; @@ -13,6 +16,9 @@ mod check_clippy_sync; mod check_command_drift; mod check_constant_time; mod check_curve_agnostic; +mod check_error_codes; +mod check_error_docs_published; +mod check_paste_integrity; mod check_rfc6979; mod check_verify_path_completeness; mod gen_docs; @@ -83,6 +89,24 @@ enum Command { /// Command-drift lint: fail if README.md or auths-cli string literals /// reference an `auths` command or long flag that doesn't exist. CheckCommandDrift, + /// Paste-integrity lint: fail if any copyable shell block (fenced bash/sh/ + /// console/shell/zsh in *.md/*.mdx/*.mdoc, or `copy="…"`/``/ + /// `` in *.ts/*.tsx) carries a ``, a bare unset + /// `$VAR`, or a leading `$ ` prompt glyph. Scans `--path` (default `.`), + /// so it can be pointed at any checkout. + CheckPasteIntegrity { + /// Directory tree to scan. + #[arg(long, default_value = ".")] + path: std::path::PathBuf, + }, + /// Error-code lint: every emitted `[AUTHS-E####]` literal is registered and + /// every registered code's doc carries a `## Suggestion` (minus an allowlist); + /// the render surface never reaches for panic!/Box. + CheckErrorCodes, + /// Error-docs coverage lint: every registered `AUTHS-E` code has a generated + /// `docs/errors/AUTHS-E.md` page, and no page is left orphaned without a + /// registered code. A page-existence/drift guard over `gen-error-docs` output. + CheckErrorDocsPublished, /// RT-002 verify-path completeness: ban structural-only KEL replay /// (`validate_kel*`/`replay_kel`) in the verifier + CLI-verify surfaces; /// require `validate_signed_kel` or an explicit `rt-002-allow:` justification. @@ -125,6 +149,9 @@ fn main() -> anyhow::Result<()> { Command::CheckRfc6979 => check_rfc6979::run(workspace_root()), Command::CheckAdmissionPolicy => check_admission_policy::run(workspace_root()), Command::CheckCommandDrift => check_command_drift::run(workspace_root()), + Command::CheckPasteIntegrity { path } => check_paste_integrity::run(&path), + Command::CheckErrorCodes => check_error_codes::run(workspace_root()), + Command::CheckErrorDocsPublished => check_error_docs_published::run(workspace_root()), Command::CheckVerifyPathCompleteness => { check_verify_path_completeness::run(workspace_root()) } diff --git a/crates/xtask/src/witness_conformance.rs b/crates/xtask/src/witness_conformance.rs index 108af9b5a..97fb9bb60 100644 --- a/crates/xtask/src/witness_conformance.rs +++ b/crates/xtask/src/witness_conformance.rs @@ -14,10 +14,10 @@ use std::path::Path; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use auths_anchor::{ - accept_anchor, freshness, Acceptance, Anchor, ControllerKeys, CurrentKey, CurveType, Head, - PartySignature, SeedId, WitnessSetRef, + Acceptance, Anchor, ControllerKeys, CurrentKey, CurveType, Head, PartySignature, SeedId, + WitnessSetRef, accept_anchor, freshness, }; use chrono::{DateTime, TimeZone, Utc}; use ed25519_dalek::{Signer, SigningKey}; @@ -85,6 +85,7 @@ pub fn drive(url: &str) -> Result<()> { if health.status != 200 { bail!("health: expected 200, got {}", health.status); } + let identity = identity_from_health_body(&health.body); let garbage = http_post_json(&format!("{base}/v1/anchor"), "{\"not\": \"an anchor\"}")?; if !(400..500).contains(&garbage.status) { @@ -115,7 +116,9 @@ pub fn drive(url: &str) -> Result<()> { bail!("unknown seed: expected 404, got {}", absent.status); } - println!("witness-conformance: live endpoint {base} passed 4/4 transport checks"); + println!( + "witness-conformance: live endpoint {base} passed 4/4 transport checks — certified {identity}" + ); println!( "witness-conformance: acceptance vectors need the node's registry to hold the \ conformance identity — emit them with --emit and provision the fixture" @@ -123,8 +126,25 @@ pub fn drive(url: &str) -> Result<()> { Ok(()) } +/// Read the node's own identity from its `/health` body so a pass line names the +/// node it actually certified — if something else already owns the target port, +/// the printed identity is not the operator's own, which is the only signal a +/// success-shaped mis-certification leaves. +fn identity_from_health_body(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|v| { + v.get("witness_did") + .and_then(|x| x.as_str()) + .or_else(|| v.get("witness_name").and_then(|x| x.as_str())) + .map(str::to_string) + }) + .unwrap_or_else(|| "unknown (node published no identity at /health)".to_string()) +} + struct HttpAnswer { status: u16, + body: String, } /// Dependency-free HTTP/1.0 request over a plain socket (conformance targets @@ -154,7 +174,11 @@ fn http_request(url: &str, method: &str, body: Option<&str>) -> Result Result { @@ -180,17 +204,32 @@ pub fn run(emit_dir: Option<&Path>) -> Result<()> { match accept_anchor(&next, &keys(), Some(&prior), now())? { Acceptance::CoSign(_) => passed += 1, Acceptance::Duplicity(_) => bail!("monotone accept: expected co-sign, got duplicity"), + Acceptance::AlreadyAnchored(_) => { + bail!("monotone accept: expected co-sign, got idempotent replay") + } } - // Duplicity: same index, different head is refused with a verifiable proof. + // Duplicity: same index, different head is refused with a verifiable proof — + // and a single flipped head byte must make that proof unverifiable, so a + // forged proof can never pass the same offline check. match accept_anchor(&fork, &keys(), Some(&prior), now())? { Acceptance::Duplicity(proof) => { proof .verify() .context("duplicity: emitted proof must verify offline")?; + let mut tampered = (*proof).clone(); + let mut head = *tampered.anchor_b.head.as_bytes(); + head[0] ^= 0xff; + tampered.anchor_b.head = Head::from_bytes(head); + if tampered.verify().is_ok() { + bail!("duplicity: a tampered proof must not verify"); + } passed += 1; } Acceptance::CoSign(_) => bail!("duplicity: expected refusal, got co-sign"), + Acceptance::AlreadyAnchored(_) => { + bail!("duplicity: expected refusal, got idempotent replay") + } } // Non-monotone index is rejected outright. @@ -213,6 +252,8 @@ pub fn run(emit_dir: Option<&Path>) -> Result<()> { passed += 1; if let Some(dir) = emit_dir { + std::fs::create_dir_all(dir) + .with_context(|| format!("creating emit directory {}", dir.display()))?; let path = dir.join("conformance-vectors.json"); std::fs::write( &path, @@ -239,3 +280,33 @@ fn vector_document(prior: &Anchor, next: &Anchor, fork: &Anchor) -> serde_json:: ] }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_body_yields_identity() { + assert_eq!( + identity_from_health_body(r#"{"up":true,"witness_name":"acme-w1"}"#), + "acme-w1" + ); + assert_eq!( + identity_from_health_body( + r#"{"witness_did":"did:keri:EWit","witness_name":"acme-w1"}"# + ), + "did:keri:EWit" + ); + assert!(identity_from_health_body("").contains("unknown")); + assert!(identity_from_health_body("{}").contains("unknown")); + } + + #[test] + fn emit_creates_missing_dir() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("vectors"); + assert!(!target.exists()); + run(Some(&target)).unwrap(); + assert!(target.join("conformance-vectors.json").exists()); + } +} diff --git a/deploy/witness/README.md b/deploy/witness/README.md index fab7bebde..a8f106edf 100644 --- a/deploy/witness/README.md +++ b/deploy/witness/README.md @@ -29,11 +29,25 @@ witness-node serve --roles anchor # anchor-only witness A role whose required ports lack a working adapter refuses to serve **that role** at startup with a named error — no half-nodes (I-DEPLOY-6). +### Prefer bare metal? + +Install the node binary, then run it directly: + +```bash +# From a release: extract witness-node from the v0.1.x tarball onto PATH, or build it: +cargo build --release -p auths-witness-node +./target/release/witness-node serve --roles anchor,kel,cosign \ + --bind 0.0.0.0:3333 --data-dir ./wdata --registry ./registry --witness-name my-w1 +``` + ## Quickstart (Compose) ```bash cd deploy/witness -WITNESS_SEED=$(openssl rand -hex 32) docker compose up +# A witness resolves submitter keys against a local copy of the parties' public +# registry. Sync it first; WITNESS_REGISTRY defaults to ./registry. +git clone ./registry +WITNESS_SEED=$(openssl rand -hex 32) WITNESS_REGISTRY=$PWD/registry docker compose up # health: http://127.0.0.1:3333/health # anchors: POST http://127.0.0.1:3333/v1/anchor ``` diff --git a/deploy/witness/docker-compose.yml b/deploy/witness/docker-compose.yml index 4c59e0040..ac49f6a1c 100644 --- a/deploy/witness/docker-compose.yml +++ b/deploy/witness/docker-compose.yml @@ -31,7 +31,13 @@ services: - witness-data:/data # The operator-synced local copy of the parties' public identity # registry, used to resolve current keys for party signatures. - - ${WITNESS_REGISTRY:?set WITNESS_REGISTRY to the local registry path}:/registry:ro + # + # First boot has no registry yet: default to ./registry beside this file, + # and sync the parties' public registry into it before anchoring — + # git clone ./registry + # The node's readiness gate refuses to serve until this holds a real git + # repo, so an empty default fails loudly instead of 422-ing every request. + - ${WITNESS_REGISTRY:-./registry}:/registry:ro healthcheck: test: ["CMD", "/usr/local/bin/witness-node", "healthcheck", "--url", "http://127.0.0.1:3333/health"] interval: 30s diff --git a/docs/errors/AUTHS-E1002.md b/docs/errors/AUTHS-E1002.md index 3e6d58cdc..992d3bbbe 100644 --- a/docs/errors/AUTHS-E1002.md +++ b/docs/errors/AUTHS-E1002.md @@ -7,3 +7,7 @@ ## Message Invalid public key length: expected {expected}, got {actual} + +## Suggestion + +Ensure the key length matches the declared curve (32 bytes Ed25519, 33 bytes P-256 compressed SEC1) diff --git a/docs/errors/AUTHS-E1005.md b/docs/errors/AUTHS-E1005.md index 2f2d27112..71d0c4d2e 100644 --- a/docs/errors/AUTHS-E1005.md +++ b/docs/errors/AUTHS-E1005.md @@ -7,3 +7,7 @@ ## Message Operation not supported on current compilation target + +## Suggestion + +This operation is not available on the current platform diff --git a/docs/errors/AUTHS-E1201.md b/docs/errors/AUTHS-E1201.md index 303b99786..47e8ba7a7 100644 --- a/docs/errors/AUTHS-E1201.md +++ b/docs/errors/AUTHS-E1201.md @@ -7,3 +7,7 @@ ## Message Unsupported KERI key type: prefix '{0}' + +## Suggestion + +Supported verkey prefixes: 'D'/'B' (Ed25519 transferable/non-transferable), '1AAJ'/'1AAI' (P-256 transferable/non-transferable). diff --git a/docs/errors/AUTHS-E1302.md b/docs/errors/AUTHS-E1302.md index a84da9c5f..5aa2146c9 100644 --- a/docs/errors/AUTHS-E1302.md +++ b/docs/errors/AUTHS-E1302.md @@ -7,3 +7,7 @@ ## Message Unsupported key type: expected ssh-ed25519 or ecdsa-sha2-nistp256 + +## Suggestion + +Supported key types: ssh-ed25519, ecdsa-sha2-nistp256 diff --git a/docs/errors/AUTHS-E2001.md b/docs/errors/AUTHS-E2001.md index 6cdf27e68..47663d888 100644 --- a/docs/errors/AUTHS-E2001.md +++ b/docs/errors/AUTHS-E2001.md @@ -7,3 +7,7 @@ ## Message Issuer signature verification failed: {0} + +## Suggestion + +Verify the attestation was signed with the correct issuer key diff --git a/docs/errors/AUTHS-E2004.md b/docs/errors/AUTHS-E2004.md index c2829da41..d7401d252 100644 --- a/docs/errors/AUTHS-E2004.md +++ b/docs/errors/AUTHS-E2004.md @@ -7,3 +7,7 @@ ## Message Attestation revoked + +## Suggestion + +This device has been revoked; contact the identity admin diff --git a/docs/errors/AUTHS-E2006.md b/docs/errors/AUTHS-E2006.md index efdd76510..e87ebd689 100644 --- a/docs/errors/AUTHS-E2006.md +++ b/docs/errors/AUTHS-E2006.md @@ -7,3 +7,7 @@ ## Message Missing required capability: required {required:?}, available {available:?} + +## Suggestion + +Request an attestation with the required capability diff --git a/docs/errors/AUTHS-E2007.md b/docs/errors/AUTHS-E2007.md index 2d8943fd0..70c67e919 100644 --- a/docs/errors/AUTHS-E2007.md +++ b/docs/errors/AUTHS-E2007.md @@ -7,3 +7,7 @@ ## Message Signing failed: {0} + +## Suggestion + +The cryptographic signing operation failed; verify key material is valid diff --git a/docs/errors/AUTHS-E2009.md b/docs/errors/AUTHS-E2009.md index 36bb7bc6f..d4ecbf9de 100644 --- a/docs/errors/AUTHS-E2009.md +++ b/docs/errors/AUTHS-E2009.md @@ -7,3 +7,7 @@ ## Message Serialization error: {0} + +## Suggestion + +Failed to serialize/deserialize attestation data; check JSON format diff --git a/docs/errors/AUTHS-E2010.md b/docs/errors/AUTHS-E2010.md index 24a32e85b..bfbc14231 100644 --- a/docs/errors/AUTHS-E2010.md +++ b/docs/errors/AUTHS-E2010.md @@ -7,3 +7,7 @@ ## Message Input too large: {0} + +## Suggestion + +Reduce the size of the JSON input or split into smaller batches diff --git a/docs/errors/AUTHS-E2011.md b/docs/errors/AUTHS-E2011.md index 9597f5a27..51c0d263e 100644 --- a/docs/errors/AUTHS-E2011.md +++ b/docs/errors/AUTHS-E2011.md @@ -7,3 +7,7 @@ ## Message Invalid input: {0} + +## Suggestion + +Check the input parameters and ensure they match the expected format diff --git a/docs/errors/AUTHS-E2012.md b/docs/errors/AUTHS-E2012.md index 9225611ff..5733ee138 100644 --- a/docs/errors/AUTHS-E2012.md +++ b/docs/errors/AUTHS-E2012.md @@ -7,3 +7,7 @@ ## Message Crypto error: {0} + +## Suggestion + +A cryptographic operation failed; verify key material is valid diff --git a/docs/errors/AUTHS-E2013.md b/docs/errors/AUTHS-E2013.md index 701860bb7..45e129bb6 100644 --- a/docs/errors/AUTHS-E2013.md +++ b/docs/errors/AUTHS-E2013.md @@ -7,3 +7,7 @@ ## Message Internal error: {0} + +## Suggestion + +An unexpected internal error occurred; please report this issue diff --git a/docs/errors/AUTHS-E2014.md b/docs/errors/AUTHS-E2014.md index 3f41c9b37..ae036d3ab 100644 --- a/docs/errors/AUTHS-E2014.md +++ b/docs/errors/AUTHS-E2014.md @@ -7,3 +7,7 @@ ## Message Organizational Attestation verification failed: {0} + +## Suggestion + +Verify organizational identity is properly configured diff --git a/docs/errors/AUTHS-E2015.md b/docs/errors/AUTHS-E2015.md index 0c518cb27..45e4f59e2 100644 --- a/docs/errors/AUTHS-E2015.md +++ b/docs/errors/AUTHS-E2015.md @@ -7,3 +7,7 @@ ## Message Organizational Attestation expired + +## Suggestion + +Request a new organizational attestation from the admin diff --git a/docs/errors/AUTHS-E2016.md b/docs/errors/AUTHS-E2016.md index 15231f473..36180a1b1 100644 --- a/docs/errors/AUTHS-E2016.md +++ b/docs/errors/AUTHS-E2016.md @@ -7,3 +7,7 @@ ## Message Organizational DID resolution failed: {0} + +## Suggestion + +Check that the organization's DID is correctly configured diff --git a/docs/errors/AUTHS-E2017.md b/docs/errors/AUTHS-E2017.md index 841d4468c..594fe8db7 100644 --- a/docs/errors/AUTHS-E2017.md +++ b/docs/errors/AUTHS-E2017.md @@ -7,3 +7,7 @@ ## Message Bundle is {age_secs}s old (max {max_secs}s). Refresh with: auths id export-bundle + +## Suggestion + +Re-export the bundle: auths id export-bundle --alias --output bundle.json --max-age-secs diff --git a/docs/errors/AUTHS-E2018.md b/docs/errors/AUTHS-E2018.md index dacbf869a..b7e1165f1 100644 --- a/docs/errors/AUTHS-E2018.md +++ b/docs/errors/AUTHS-E2018.md @@ -7,3 +7,7 @@ ## Message Attestation is {age_secs}s old (max {max_secs}s) + +## Suggestion + +Request a fresh attestation or increase the max_age threshold diff --git a/docs/errors/AUTHS-E2022.md b/docs/errors/AUTHS-E2022.md index 20ded3910..ddccc50c3 100644 --- a/docs/errors/AUTHS-E2022.md +++ b/docs/errors/AUTHS-E2022.md @@ -7,3 +7,7 @@ ## Message Delegator attestation could not be resolved + +## Suggestion + +Re-issue the delegated attestation within the delegator's capability and validity scope diff --git a/docs/errors/AUTHS-E2101.md b/docs/errors/AUTHS-E2101.md index fb0f3990f..f1bc3dc09 100644 --- a/docs/errors/AUTHS-E2101.md +++ b/docs/errors/AUTHS-E2101.md @@ -10,4 +10,4 @@ commit is unsigned ## Suggestion -Sign commits with: git commit -S +This commit has no Auths-Id/Auths-Device trailer. Run `auths init` so the prepare-commit-msg hook signs future commits, or backfill with `auths sign `. diff --git a/docs/errors/AUTHS-E2102.md b/docs/errors/AUTHS-E2102.md index 88045e92c..8c837d53e 100644 --- a/docs/errors/AUTHS-E2102.md +++ b/docs/errors/AUTHS-E2102.md @@ -6,8 +6,8 @@ ## Message -GPG signatures not supported, use SSH signing +GPG signatures are not verified by Auths — use did:keri trailers via `auths init` ## Suggestion -Configure SSH signing: git config gpg.format ssh +Auths verifies its own did:keri commit trailers, not GPG or SSH signatures. Run `auths init` to enable Auths signing. diff --git a/docs/errors/AUTHS-E2103.md b/docs/errors/AUTHS-E2103.md index f1a43a012..330a3062a 100644 --- a/docs/errors/AUTHS-E2103.md +++ b/docs/errors/AUTHS-E2103.md @@ -7,3 +7,7 @@ ## Message SSHSIG parse failed: {0} + +## Suggestion + +The SSH signature could not be parsed; verify the commit was signed correctly diff --git a/docs/errors/AUTHS-E2104.md b/docs/errors/AUTHS-E2104.md index 6061bce65..10da3b211 100644 --- a/docs/errors/AUTHS-E2104.md +++ b/docs/errors/AUTHS-E2104.md @@ -7,3 +7,7 @@ ## Message unsupported SSH key type: {found} + +## Suggestion + +Use an Ed25519 or ECDSA P-256 SSH key for signing diff --git a/docs/errors/AUTHS-E2105.md b/docs/errors/AUTHS-E2105.md index 0aa696da4..cb16e60fb 100644 --- a/docs/errors/AUTHS-E2105.md +++ b/docs/errors/AUTHS-E2105.md @@ -7,3 +7,7 @@ ## Message namespace mismatch: expected \"{expected}\", found \"{found}\" + +## Suggestion + +The signature namespace doesn't match; ensure git config gpg.ssh.defaultKeyCommand is set correctly diff --git a/docs/errors/AUTHS-E2106.md b/docs/errors/AUTHS-E2106.md index 1e5edac57..ef8eb6305 100644 --- a/docs/errors/AUTHS-E2106.md +++ b/docs/errors/AUTHS-E2106.md @@ -7,3 +7,7 @@ ## Message unsupported hash algorithm: {0} + +## Suggestion + +Use SHA-256 or SHA-512 hash algorithm for signing diff --git a/docs/errors/AUTHS-E2107.md b/docs/errors/AUTHS-E2107.md index 327fc3404..68cd196ea 100644 --- a/docs/errors/AUTHS-E2107.md +++ b/docs/errors/AUTHS-E2107.md @@ -7,3 +7,7 @@ ## Message signature verification failed + +## Suggestion + +The commit signature does not match the signed data; the commit may have been modified after signing diff --git a/docs/errors/AUTHS-E2108.md b/docs/errors/AUTHS-E2108.md index 24e312643..681d11f90 100644 --- a/docs/errors/AUTHS-E2108.md +++ b/docs/errors/AUTHS-E2108.md @@ -6,8 +6,8 @@ ## Message -signer key not in allowed keys +signer identity is not trusted (no matching pinned root) ## Suggestion -Add the signer's key to the allowed signers list +The signer's identity is not trusted here. Pin it with `auths trust pin --did `, or add it to .auths/roots. diff --git a/docs/errors/AUTHS-E2109.md b/docs/errors/AUTHS-E2109.md index e42e627ac..763427840 100644 --- a/docs/errors/AUTHS-E2109.md +++ b/docs/errors/AUTHS-E2109.md @@ -7,3 +7,7 @@ ## Message commit parse failed: {0} + +## Suggestion + +The Git commit object is malformed; check repository integrity with `git fsck` diff --git a/docs/errors/AUTHS-E2201.md b/docs/errors/AUTHS-E2201.md index 060a52391..1fa84334f 100644 --- a/docs/errors/AUTHS-E2201.md +++ b/docs/errors/AUTHS-E2201.md @@ -7,3 +7,7 @@ ## Message bundle integrity failure for '{id}': {reason} + +## Suggestion + +The bundle was modified after it was produced; obtain a fresh, untampered bundle diff --git a/docs/errors/AUTHS-E2202.md b/docs/errors/AUTHS-E2202.md index d31c75ed2..eaed27518 100644 --- a/docs/errors/AUTHS-E2202.md +++ b/docs/errors/AUTHS-E2202.md @@ -7,3 +7,7 @@ ## Message bundle is missing the KEL for delegated member '{member}' + +## Suggestion + +The bundle is incomplete; re-produce it with `auths org bundle` diff --git a/docs/errors/AUTHS-E2204.md b/docs/errors/AUTHS-E2204.md index 2c5c69850..252a04d94 100644 --- a/docs/errors/AUTHS-E2204.md +++ b/docs/errors/AUTHS-E2204.md @@ -7,3 +7,7 @@ ## Message canonicalization failed: {0} + +## Suggestion + +The file is not a valid air-gapped org bundle; re-export it diff --git a/docs/errors/AUTHS-E2206.md b/docs/errors/AUTHS-E2206.md index b71bed76e..46654ff28 100644 --- a/docs/errors/AUTHS-E2206.md +++ b/docs/errors/AUTHS-E2206.md @@ -7,3 +7,7 @@ ## Message offboarding record invalid: {0} + +## Suggestion + +The off-boarding record does not match the org KEL; obtain a fresh bundle from the org diff --git a/docs/errors/AUTHS-E2301.md b/docs/errors/AUTHS-E2301.md index fbd21ced2..4d2d45302 100644 --- a/docs/errors/AUTHS-E2301.md +++ b/docs/errors/AUTHS-E2301.md @@ -7,3 +7,7 @@ ## Message canonicalization failed: {0} + +## Suggestion + +The file is not a valid evidence pack; re-export it with `auths compliance report` diff --git a/docs/errors/AUTHS-E2303.md b/docs/errors/AUTHS-E2303.md index 46c1a273e..55f9ee103 100644 --- a/docs/errors/AUTHS-E2303.md +++ b/docs/errors/AUTHS-E2303.md @@ -7,3 +7,7 @@ ## Message offline verification failed: {0} + +## Suggestion + +The pack failed offline verification; obtain a fresh, untampered pack from the org diff --git a/docs/errors/AUTHS-E3002.md b/docs/errors/AUTHS-E3002.md index 461de4540..f6ab6ac65 100644 --- a/docs/errors/AUTHS-E3002.md +++ b/docs/errors/AUTHS-E3002.md @@ -7,3 +7,7 @@ ## Message Incorrect passphrase + +## Suggestion + +Check your passphrase and try again. Set AUTHS_PASSPHRASE for automation, or run `auths agent start` for session caching diff --git a/docs/errors/AUTHS-E3003.md b/docs/errors/AUTHS-E3003.md index 0a5977164..35765c83e 100644 --- a/docs/errors/AUTHS-E3003.md +++ b/docs/errors/AUTHS-E3003.md @@ -7,3 +7,7 @@ ## Message Missing Passphrase + +## Suggestion + +Provide a passphrase with --passphrase or set AUTHS_PASSPHRASE diff --git a/docs/errors/AUTHS-E3004.md b/docs/errors/AUTHS-E3004.md index f3916ad87..48c700c71 100644 --- a/docs/errors/AUTHS-E3004.md +++ b/docs/errors/AUTHS-E3004.md @@ -7,3 +7,7 @@ ## Message Security error: {0} + +## Suggestion + +Run `auths doctor` to check system keychain access and security configuration diff --git a/docs/errors/AUTHS-E3005.md b/docs/errors/AUTHS-E3005.md index bc0931d26..6dbca8fde 100644 --- a/docs/errors/AUTHS-E3005.md +++ b/docs/errors/AUTHS-E3005.md @@ -7,3 +7,7 @@ ## Message Crypto error: {0} + +## Suggestion + +A cryptographic operation failed; check key material with `auths key list` diff --git a/docs/errors/AUTHS-E3006.md b/docs/errors/AUTHS-E3006.md index fda421eab..b0e869fa1 100644 --- a/docs/errors/AUTHS-E3006.md +++ b/docs/errors/AUTHS-E3006.md @@ -7,3 +7,7 @@ ## Message Key deserialization error: {0} + +## Suggestion + +The stored key is corrupted; re-import with `auths key import` diff --git a/docs/errors/AUTHS-E3007.md b/docs/errors/AUTHS-E3007.md index 4aa547c49..c03456a2d 100644 --- a/docs/errors/AUTHS-E3007.md +++ b/docs/errors/AUTHS-E3007.md @@ -7,3 +7,7 @@ ## Message Signing failed: {0} + +## Suggestion + +The signing operation failed; verify your key is accessible with `auths key list` diff --git a/docs/errors/AUTHS-E3008.md b/docs/errors/AUTHS-E3008.md index 1b74a7b02..c0800768a 100644 --- a/docs/errors/AUTHS-E3008.md +++ b/docs/errors/AUTHS-E3008.md @@ -7,3 +7,7 @@ ## Message Protocol error: {0} + +## Suggestion + +A protocol error occurred; check that both sides are running compatible versions diff --git a/docs/errors/AUTHS-E3014.md b/docs/errors/AUTHS-E3014.md index 06d1f572d..7cc6e29d9 100644 --- a/docs/errors/AUTHS-E3014.md +++ b/docs/errors/AUTHS-E3014.md @@ -7,3 +7,7 @@ ## Message User input cancelled + +## Suggestion + +Run the command again and provide the required input diff --git a/docs/errors/AUTHS-E3015.md b/docs/errors/AUTHS-E3015.md index 189e4c3bf..c0d923717 100644 --- a/docs/errors/AUTHS-E3015.md +++ b/docs/errors/AUTHS-E3015.md @@ -7,3 +7,7 @@ ## Message Keychain backend unavailable: {backend} - {reason} + +## Suggestion + +Run `auths doctor` to diagnose keychain issues diff --git a/docs/errors/AUTHS-E3017.md b/docs/errors/AUTHS-E3017.md index f6ec79191..92a572a0f 100644 --- a/docs/errors/AUTHS-E3017.md +++ b/docs/errors/AUTHS-E3017.md @@ -7,3 +7,7 @@ ## Message Failed to initialize keychain backend: {backend} - {error} + +## Suggestion + +Run `auths doctor` to diagnose keychain issues diff --git a/docs/errors/AUTHS-E3018.md b/docs/errors/AUTHS-E3018.md index e99a1b0d1..39f8440e1 100644 --- a/docs/errors/AUTHS-E3018.md +++ b/docs/errors/AUTHS-E3018.md @@ -7,3 +7,7 @@ ## Message Credential too large for backend (max {max_bytes} bytes, got {actual_bytes}) + +## Suggestion + +Reduce the credential size or use file-based storage with AUTHS_KEYCHAIN_BACKEND=file diff --git a/docs/errors/AUTHS-E3019.md b/docs/errors/AUTHS-E3019.md index 26db3a6f7..b9b946815 100644 --- a/docs/errors/AUTHS-E3019.md +++ b/docs/errors/AUTHS-E3019.md @@ -7,3 +7,7 @@ ## Message Agent is locked. Unlock with 'auths agent unlock' or restart the agent. + +## Suggestion + +Run `auths agent unlock` or restart with `auths agent start` diff --git a/docs/errors/AUTHS-E3020.md b/docs/errors/AUTHS-E3020.md index 34a63f1dd..857369eb6 100644 --- a/docs/errors/AUTHS-E3020.md +++ b/docs/errors/AUTHS-E3020.md @@ -7,3 +7,7 @@ ## Message Passphrase too weak: {0} + +## Suggestion + +Use at least 12 characters with uppercase, lowercase, and a digit or symbol diff --git a/docs/errors/AUTHS-E3024.md b/docs/errors/AUTHS-E3024.md index 41cbdc40f..aa3a74f6a 100644 --- a/docs/errors/AUTHS-E3024.md +++ b/docs/errors/AUTHS-E3024.md @@ -7,3 +7,7 @@ ## Message HSM does not support mechanism: {0} + +## Suggestion + +Check that your HSM supports Ed25519 (CKM_EDDSA) diff --git a/docs/errors/AUTHS-E3025.md b/docs/errors/AUTHS-E3025.md index 01d77ef61..0f58f57c1 100644 --- a/docs/errors/AUTHS-E3025.md +++ b/docs/errors/AUTHS-E3025.md @@ -7,3 +7,7 @@ ## Message Operation '{operation}' requires a software-backed key; hardware-backed keys (e.g. Secure Enclave) cannot export raw material + +## Suggestion + +Use a software-backed keychain backend for this operation, or re-initialize your identity without Secure Enclave diff --git a/docs/errors/AUTHS-E3102.md b/docs/errors/AUTHS-E3102.md index d4ae73a04..b6929d796 100644 --- a/docs/errors/AUTHS-E3102.md +++ b/docs/errors/AUTHS-E3102.md @@ -7,3 +7,7 @@ ## Message {0} + +## Suggestion + +The trust store may be corrupted; delete and re-pin with `auths trust pin` diff --git a/docs/errors/AUTHS-E3104.md b/docs/errors/AUTHS-E3104.md index 5824aca34..b94760432 100644 --- a/docs/errors/AUTHS-E3104.md +++ b/docs/errors/AUTHS-E3104.md @@ -7,3 +7,7 @@ ## Message serialization error: {0} + +## Suggestion + +The trust store data is corrupted; delete and re-pin with `auths trust pin` diff --git a/docs/errors/AUTHS-E3202.md b/docs/errors/AUTHS-E3202.md index b0afe9f8a..591f9f481 100644 --- a/docs/errors/AUTHS-E3202.md +++ b/docs/errors/AUTHS-E3202.md @@ -7,3 +7,7 @@ ## Message QR code generation failed: {0} + +## Suggestion + +QR code generation failed; try relay-based pairing with `auths device pair --registry ` instead diff --git a/docs/errors/AUTHS-E3204.md b/docs/errors/AUTHS-E3204.md index bb74f1650..8abbb4baf 100644 --- a/docs/errors/AUTHS-E3204.md +++ b/docs/errors/AUTHS-E3204.md @@ -7,3 +7,7 @@ ## Message Local server error: {0} + +## Suggestion + +The local pairing server failed to start; check that the port is available diff --git a/docs/errors/AUTHS-E3205.md b/docs/errors/AUTHS-E3205.md index 6e1cfad2a..92f4f5287 100644 --- a/docs/errors/AUTHS-E3205.md +++ b/docs/errors/AUTHS-E3205.md @@ -7,3 +7,7 @@ ## Message mDNS error: {0} + +## Suggestion + +mDNS discovery failed; try relay-based pairing with `auths device pair --registry ` instead diff --git a/docs/errors/AUTHS-E3402.md b/docs/errors/AUTHS-E3402.md index 4a277320d..fdb082416 100644 --- a/docs/errors/AUTHS-E3402.md +++ b/docs/errors/AUTHS-E3402.md @@ -7,3 +7,7 @@ ## Message duplicity detected: {0} + +## Suggestion + +This identity may be compromised — investigate immediately diff --git a/docs/errors/AUTHS-E3503.md b/docs/errors/AUTHS-E3503.md index 7f911c4e6..bb5816447 100644 --- a/docs/errors/AUTHS-E3503.md +++ b/docs/errors/AUTHS-E3503.md @@ -7,3 +7,7 @@ ## Message compare-and-swap conflict + +## Suggestion + +Retry the operation — another process made a concurrent change diff --git a/docs/errors/AUTHS-E3603.md b/docs/errors/AUTHS-E3603.md index 4cdba8c12..027b51282 100644 --- a/docs/errors/AUTHS-E3603.md +++ b/docs/errors/AUTHS-E3603.md @@ -7,3 +7,7 @@ ## Message resource not found: {resource} + +## Suggestion + +The requested resource was not found on the server; verify the URL or identifier diff --git a/docs/errors/AUTHS-E3605.md b/docs/errors/AUTHS-E3605.md index 86e3f9a54..88cfbdf82 100644 --- a/docs/errors/AUTHS-E3605.md +++ b/docs/errors/AUTHS-E3605.md @@ -7,3 +7,7 @@ ## Message invalid response: {detail} + +## Suggestion + +The server returned an unexpected response; check server compatibility diff --git a/docs/errors/AUTHS-E3606.md b/docs/errors/AUTHS-E3606.md index b2ae5faf6..2abb0dc2f 100644 --- a/docs/errors/AUTHS-E3606.md +++ b/docs/errors/AUTHS-E3606.md @@ -7,3 +7,7 @@ ## Message internal network error: {0} + +## Suggestion + +The server encountered an internal error; retry later or contact the server administrator diff --git a/docs/errors/AUTHS-E3702.md b/docs/errors/AUTHS-E3702.md index dea80e2c9..09ad37265 100644 --- a/docs/errors/AUTHS-E3702.md +++ b/docs/errors/AUTHS-E3702.md @@ -7,3 +7,7 @@ ## Message invalid DID {did}: {reason} + +## Suggestion + +Check the DID format (e.g., did:key:z6Mk... or did:keri:E...) diff --git a/docs/errors/AUTHS-E3703.md b/docs/errors/AUTHS-E3703.md index 6d0614adc..e7efd5e40 100644 --- a/docs/errors/AUTHS-E3703.md +++ b/docs/errors/AUTHS-E3703.md @@ -7,3 +7,7 @@ ## Message key revoked for DID: {did} + +## Suggestion + +This key has been revoked — contact the identity owner diff --git a/docs/errors/AUTHS-E3801.md b/docs/errors/AUTHS-E3801.md index 77013ceb0..cbde40663 100644 --- a/docs/errors/AUTHS-E3801.md +++ b/docs/errors/AUTHS-E3801.md @@ -7,3 +7,7 @@ ## Message OAuth authorization pending + +## Suggestion + +Complete the authorization on the linked device, then the CLI will continue automatically diff --git a/docs/errors/AUTHS-E3802.md b/docs/errors/AUTHS-E3802.md index f85650c49..3ceec7a70 100644 --- a/docs/errors/AUTHS-E3802.md +++ b/docs/errors/AUTHS-E3802.md @@ -7,3 +7,7 @@ ## Message OAuth slow down + +## Suggestion + +The authorization server is rate-limiting; the CLI will retry automatically diff --git a/docs/errors/AUTHS-E3806.md b/docs/errors/AUTHS-E3806.md index 0e007b535..60ce1f263 100644 --- a/docs/errors/AUTHS-E3806.md +++ b/docs/errors/AUTHS-E3806.md @@ -7,3 +7,7 @@ ## Message platform error: {message} + +## Suggestion + +A platform-specific error occurred; run `auths doctor` to diagnose diff --git a/docs/errors/AUTHS-E3901.md b/docs/errors/AUTHS-E3901.md index c44903b84..9709210cf 100644 --- a/docs/errors/AUTHS-E3901.md +++ b/docs/errors/AUTHS-E3901.md @@ -7,3 +7,7 @@ ## Message ssh-add command failed: {0} + +## Suggestion + +Check that the key file exists and has correct permissions diff --git a/docs/errors/AUTHS-E3961.md b/docs/errors/AUTHS-E3961.md index ea22653d3..228445ae5 100644 --- a/docs/errors/AUTHS-E3961.md +++ b/docs/errors/AUTHS-E3961.md @@ -7,3 +7,7 @@ ## Message unsupported ecosystem: {ecosystem} + +## Suggestion + +Supported ecosystems: npm, pypi, cargo, docker, go, maven, nuget diff --git a/docs/errors/AUTHS-E3962.md b/docs/errors/AUTHS-E3962.md index 5af305395..94cbde657 100644 --- a/docs/errors/AUTHS-E3962.md +++ b/docs/errors/AUTHS-E3962.md @@ -7,3 +7,7 @@ ## Message package '{package_name}' not found in {ecosystem} + +## Suggestion + +Check the package name and ensure it exists on the registry diff --git a/docs/errors/AUTHS-E3963.md b/docs/errors/AUTHS-E3963.md index 49d523a37..bcf54ef68 100644 --- a/docs/errors/AUTHS-E3963.md +++ b/docs/errors/AUTHS-E3963.md @@ -7,3 +7,7 @@ ## Message ownership of '{package_name}' on {ecosystem} not confirmed for the given identity + +## Suggestion + +Ensure you are listed as an owner/collaborator on the upstream registry diff --git a/docs/errors/AUTHS-E3965.md b/docs/errors/AUTHS-E3965.md index 215027656..8ad2e152b 100644 --- a/docs/errors/AUTHS-E3965.md +++ b/docs/errors/AUTHS-E3965.md @@ -7,3 +7,7 @@ ## Message invalid verification token: {reason} + +## Suggestion + +Tokens must start with 'auths-verify-' followed by a hex string diff --git a/docs/errors/AUTHS-E3966.md b/docs/errors/AUTHS-E3966.md index 910c8a1e8..2946d73cd 100644 --- a/docs/errors/AUTHS-E3966.md +++ b/docs/errors/AUTHS-E3966.md @@ -7,3 +7,7 @@ ## Message invalid package name '{name}': {reason} + +## Suggestion + +Package names cannot be empty, contain control characters, or use path traversal diff --git a/docs/errors/AUTHS-E4002.md b/docs/errors/AUTHS-E4002.md index 9fd7a8224..2e8d22c7c 100644 --- a/docs/errors/AUTHS-E4002.md +++ b/docs/errors/AUTHS-E4002.md @@ -7,3 +7,7 @@ ## Message failed to parse freeze state: {0} + +## Suggestion + +The freeze state file may be corrupted; try deleting it diff --git a/docs/errors/AUTHS-E4003.md b/docs/errors/AUTHS-E4003.md index 9647de56f..74e1a36e7 100644 --- a/docs/errors/AUTHS-E4003.md +++ b/docs/errors/AUTHS-E4003.md @@ -7,3 +7,7 @@ ## Message invalid duration format: {0} + +## Suggestion + +Use a valid duration format (e.g. '30m', '2h', '7d') diff --git a/docs/errors/AUTHS-E4102.md b/docs/errors/AUTHS-E4102.md index 946e5b7a5..d7b3d086a 100644 --- a/docs/errors/AUTHS-E4102.md +++ b/docs/errors/AUTHS-E4102.md @@ -7,3 +7,7 @@ ## Message serialization error: {0} + +## Suggestion + +Failed to serialize storage data; this may indicate a version mismatch diff --git a/docs/errors/AUTHS-E4203.md b/docs/errors/AUTHS-E4203.md index 128aeccf7..bc3f143dd 100644 --- a/docs/errors/AUTHS-E4203.md +++ b/docs/errors/AUTHS-E4203.md @@ -10,4 +10,4 @@ key operation failed: {0} ## Suggestion -Check keychain access and passphrase +Check keychain access and passphrase. Headless/CI (no Touch ID): set AUTHS_KEYCHAIN_BACKEND=file AUTHS_KEYCHAIN_FILE= AUTHS_PASSPHRASE=, or run `auths init --profile ci`. diff --git a/docs/errors/AUTHS-E4204.md b/docs/errors/AUTHS-E4204.md index 0ffae7a51..22afa7dec 100644 --- a/docs/errors/AUTHS-E4204.md +++ b/docs/errors/AUTHS-E4204.md @@ -7,3 +7,7 @@ ## Message {0} + +## Suggestion + +Identity data is malformed; try re-initializing with `auths init` diff --git a/docs/errors/AUTHS-E4207.md b/docs/errors/AUTHS-E4207.md index 14dae7cab..026647e37 100644 --- a/docs/errors/AUTHS-E4207.md +++ b/docs/errors/AUTHS-E4207.md @@ -7,3 +7,7 @@ ## Message crypto operation failed: {0} + +## Suggestion + +A cryptographic operation during initialization failed; check your keychain access diff --git a/docs/errors/AUTHS-E4208.md b/docs/errors/AUTHS-E4208.md index 2f198fe17..ed9a1a4da 100644 --- a/docs/errors/AUTHS-E4208.md +++ b/docs/errors/AUTHS-E4208.md @@ -7,3 +7,7 @@ ## Message identity error: {0} + +## Suggestion + +Identity initialization failed; check storage and keychain configuration diff --git a/docs/errors/AUTHS-E4410.md b/docs/errors/AUTHS-E4410.md index 6610646ac..5415f9975 100644 --- a/docs/errors/AUTHS-E4410.md +++ b/docs/errors/AUTHS-E4410.md @@ -7,3 +7,7 @@ ## Message _(transparent — see inner error)_ + +## Suggestion + +A concurrent modification was detected; retry the operation diff --git a/docs/errors/AUTHS-E4411.md b/docs/errors/AUTHS-E4411.md index 2ccf705dd..aa352115b 100644 --- a/docs/errors/AUTHS-E4411.md +++ b/docs/errors/AUTHS-E4411.md @@ -7,3 +7,7 @@ ## Message _(transparent — see inner error)_ + +## Suggestion + +Check file permissions, disk space, and storage backend connectivity diff --git a/docs/errors/AUTHS-E4606.md b/docs/errors/AUTHS-E4606.md index 9e3875855..ae846e014 100644 --- a/docs/errors/AUTHS-E4606.md +++ b/docs/errors/AUTHS-E4606.md @@ -7,3 +7,7 @@ ## Message Chain integrity error: {0} + +## Suggestion + +The KEL has non-linear history; this indicates tampering diff --git a/docs/errors/AUTHS-E4825.md b/docs/errors/AUTHS-E4825.md index 1c07891ba..97ad0df56 100644 --- a/docs/errors/AUTHS-E4825.md +++ b/docs/errors/AUTHS-E4825.md @@ -7,3 +7,7 @@ ## Message Key commitment mismatch + +## Suggestion + +The provided key does not match the pre-committed next key. Use the key that was generated during initialization or the last rotation. diff --git a/docs/errors/AUTHS-E4826.md b/docs/errors/AUTHS-E4826.md index e441d470d..57f3ae6aa 100644 --- a/docs/errors/AUTHS-E4826.md +++ b/docs/errors/AUTHS-E4826.md @@ -7,3 +7,7 @@ ## Message Identity is abandoned (empty next commitment) + +## Suggestion + +This identity has been permanently abandoned. Create a new identity with 'auths init'. diff --git a/docs/errors/AUTHS-E4852.md b/docs/errors/AUTHS-E4852.md index 31e6895b5..41a1701de 100644 --- a/docs/errors/AUTHS-E4852.md +++ b/docs/errors/AUTHS-E4852.md @@ -7,3 +7,7 @@ ## Message contains disallowed character {0:?} (only [a-z0-9_-] allowed) + +## Suggestion + +Only lowercase letters, digits, hyphens, and underscores are allowed diff --git a/docs/errors/AUTHS-E4868.md b/docs/errors/AUTHS-E4868.md index f88bdad63..449f9a91e 100644 --- a/docs/errors/AUTHS-E4868.md +++ b/docs/errors/AUTHS-E4868.md @@ -7,3 +7,7 @@ ## Message Concurrent modification: {0} + +## Suggestion + +Retry the operation; another process modified the registry diff --git a/docs/errors/AUTHS-E4875.md b/docs/errors/AUTHS-E4875.md index 7ac7764a2..d75e65be9 100644 --- a/docs/errors/AUTHS-E4875.md +++ b/docs/errors/AUTHS-E4875.md @@ -7,3 +7,7 @@ ## Message Stale attestation: {0} + +## Suggestion + +The attestation has been superseded by a newer version diff --git a/docs/errors/AUTHS-E4876.md b/docs/errors/AUTHS-E4876.md index acadf40f4..1f7cc3059 100644 --- a/docs/errors/AUTHS-E4876.md +++ b/docs/errors/AUTHS-E4876.md @@ -7,3 +7,7 @@ ## Message Not implemented: {method} + +## Suggestion + +This operation is not supported by the current backend diff --git a/docs/errors/AUTHS-E4906.md b/docs/errors/AUTHS-E4906.md index 0ff013dd7..96a22e369 100644 --- a/docs/errors/AUTHS-E4906.md +++ b/docs/errors/AUTHS-E4906.md @@ -7,3 +7,7 @@ ## Message Invalid threshold {threshold} for key_count={key_count}: {reason} + +## Suggestion + +Ensure the threshold count does not exceed the number of keys, and that weighted clauses have one weight per key summing to at least 1 diff --git a/docs/errors/AUTHS-E4952.md b/docs/errors/AUTHS-E4952.md index 0a019b3d0..77728e0b6 100644 --- a/docs/errors/AUTHS-E4952.md +++ b/docs/errors/AUTHS-E4952.md @@ -7,3 +7,7 @@ ## Message Chain continuity error: expected previous SAID {expected}, got {actual} + +## Suggestion + +The KEL chain is broken; clear the cache and retry diff --git a/docs/errors/AUTHS-E4953.md b/docs/errors/AUTHS-E4953.md index a6a642941..351edd4a4 100644 --- a/docs/errors/AUTHS-E4953.md +++ b/docs/errors/AUTHS-E4953.md @@ -7,3 +7,7 @@ ## Message Sequence error: expected {expected}, got {actual} + +## Suggestion + +The KEL has sequence gaps; re-sync from a trusted source diff --git a/docs/errors/AUTHS-E4956.md b/docs/errors/AUTHS-E4956.md index 818e54880..cc9a81832 100644 --- a/docs/errors/AUTHS-E4956.md +++ b/docs/errors/AUTHS-E4956.md @@ -7,3 +7,7 @@ ## Message KEL history is non-linear: commit {commit} has {parent_count} parents (expected 1) + +## Suggestion + +The KEL has merge commits, indicating tampering diff --git a/docs/errors/AUTHS-E4966.md b/docs/errors/AUTHS-E4966.md index 1e564b9fc..d347d1def 100644 --- a/docs/errors/AUTHS-E4966.md +++ b/docs/errors/AUTHS-E4966.md @@ -7,3 +7,7 @@ ## Message Signing error: {0} + +## Suggestion + +Check that the key alias exists and the passphrase is correct diff --git a/docs/errors/AUTHS-E4967.md b/docs/errors/AUTHS-E4967.md index 582b48200..de6a508b1 100644 --- a/docs/errors/AUTHS-E4967.md +++ b/docs/errors/AUTHS-E4967.md @@ -7,3 +7,7 @@ ## Message Identity cannot emit interaction events: {0} + +## Suggestion + +Device authorization requires a transferable identity (non-empty n[]) without establishment-only restriction (no EO in c[]). Create a new identity with auths init. diff --git a/docs/errors/AUTHS-E4968.md b/docs/errors/AUTHS-E4968.md index e820e9dca..667837903 100644 --- a/docs/errors/AUTHS-E4968.md +++ b/docs/errors/AUTHS-E4968.md @@ -7,3 +7,7 @@ ## Message Witness quorum not met: {0} + +## Suggestion + +Check witness server connectivity and threshold configuration diff --git a/docs/errors/AUTHS-E4971.md b/docs/errors/AUTHS-E4971.md index 5dfad73a4..fc4b4cfce 100644 --- a/docs/errors/AUTHS-E4971.md +++ b/docs/errors/AUTHS-E4971.md @@ -7,3 +7,7 @@ ## Message Receipt collection failed: {0} + +## Suggestion + +Check witness server connectivity and threshold configuration diff --git a/docs/errors/AUTHS-E4974.md b/docs/errors/AUTHS-E4974.md index be4e92731..bafddda7d 100644 --- a/docs/errors/AUTHS-E4974.md +++ b/docs/errors/AUTHS-E4974.md @@ -7,3 +7,7 @@ ## Message witness quorum not met: {valid} valid receipt(s), need {required} + +## Suggestion + +Too few witnesses returned a valid, verifiable receipt for this event diff --git a/docs/errors/AUTHS-E4981.md b/docs/errors/AUTHS-E4981.md index e62b3dfd9..63f0e5877 100644 --- a/docs/errors/AUTHS-E4981.md +++ b/docs/errors/AUTHS-E4981.md @@ -7,3 +7,7 @@ ## Message issuer '{issuer}' is multi-signature (kt≥2); credential registry anchoring is single-author only + +## Suggestion + +Credential issuance currently requires a single-signature (kt=1) issuer diff --git a/docs/errors/AUTHS-E4986.md b/docs/errors/AUTHS-E4986.md index 4d4cebecd..e008aa833 100644 --- a/docs/errors/AUTHS-E4986.md +++ b/docs/errors/AUTHS-E4986.md @@ -7,3 +7,7 @@ ## Message I/O error: {0} + +## Suggestion + +Check cache directory permissions; the cache is optional and can be cleared diff --git a/docs/errors/AUTHS-E4987.md b/docs/errors/AUTHS-E4987.md index 9c46446d9..1d55e3406 100644 --- a/docs/errors/AUTHS-E4987.md +++ b/docs/errors/AUTHS-E4987.md @@ -7,3 +7,7 @@ ## Message JSON serialization error: {0} + +## Suggestion + +The cache file may be corrupted; try clearing it with 'auths cache clear' diff --git a/docs/errors/AUTHS-E5001.md b/docs/errors/AUTHS-E5001.md index 739c9bfcb..db8a1d842 100644 --- a/docs/errors/AUTHS-E5001.md +++ b/docs/errors/AUTHS-E5001.md @@ -7,3 +7,7 @@ ## Message identity already exists: {did} + +## Suggestion + +Use `auths id show` to inspect the existing identity diff --git a/docs/errors/AUTHS-E5002.md b/docs/errors/AUTHS-E5002.md index 6ec1c69bf..4fa539dac 100644 --- a/docs/errors/AUTHS-E5002.md +++ b/docs/errors/AUTHS-E5002.md @@ -7,3 +7,7 @@ ## Message keychain unavailable ({backend}): {reason} + +## Suggestion + +Run `auths doctor` to diagnose keychain issues diff --git a/docs/errors/AUTHS-E5004.md b/docs/errors/AUTHS-E5004.md index 96c08d6b1..489a66ed5 100644 --- a/docs/errors/AUTHS-E5004.md +++ b/docs/errors/AUTHS-E5004.md @@ -7,3 +7,7 @@ ## Message git config error: {0} + +## Suggestion + +Ensure Git is configured: git config --global user.name/email diff --git a/docs/errors/AUTHS-E5006.md b/docs/errors/AUTHS-E5006.md index f528ee25a..9338b8ca0 100644 --- a/docs/errors/AUTHS-E5006.md +++ b/docs/errors/AUTHS-E5006.md @@ -7,3 +7,7 @@ ## Message platform verification failed: {0} + +## Suggestion + +Platform identity verification failed; check your platform credentials and network connectivity diff --git a/docs/errors/AUTHS-E5008.md b/docs/errors/AUTHS-E5008.md index d2407a7f3..60662089d 100644 --- a/docs/errors/AUTHS-E5008.md +++ b/docs/errors/AUTHS-E5008.md @@ -7,3 +7,7 @@ ## Message passphrase from {source_name} is too weak: {reason} + +## Suggestion + +Use at least 12 characters with 3 of 4 character classes (lowercase, uppercase, digit, symbol) diff --git a/docs/errors/AUTHS-E5103.md b/docs/errors/AUTHS-E5103.md index e81fe9c42..9e2e4846a 100644 --- a/docs/errors/AUTHS-E5103.md +++ b/docs/errors/AUTHS-E5103.md @@ -7,3 +7,7 @@ ## Message attestation error: {0} + +## Suggestion + +The attestation operation failed; run `auths device list` to check device status diff --git a/docs/errors/AUTHS-E5106.md b/docs/errors/AUTHS-E5106.md index 566488d4a..6d0ecd22a 100644 --- a/docs/errors/AUTHS-E5106.md +++ b/docs/errors/AUTHS-E5106.md @@ -7,3 +7,7 @@ ## Message device delegation failed: {0} + +## Suggestion + +The device delegation could not be authored or anchored; check the root identity diff --git a/docs/errors/AUTHS-E5202.md b/docs/errors/AUTHS-E5202.md index f19ab3378..79d9ddc7c 100644 --- a/docs/errors/AUTHS-E5202.md +++ b/docs/errors/AUTHS-E5202.md @@ -7,3 +7,7 @@ ## Message no attestation found for device {device_did} + +## Suggestion + +Run `auths device link` to create an attestation for this device diff --git a/docs/errors/AUTHS-E5203.md b/docs/errors/AUTHS-E5203.md index 5651d7ff6..e46802e82 100644 --- a/docs/errors/AUTHS-E5203.md +++ b/docs/errors/AUTHS-E5203.md @@ -7,3 +7,7 @@ ## Message device {device_did} is already revoked + +## Suggestion + +This device has been revoked and cannot be extended; link a new device with `auths device link` diff --git a/docs/errors/AUTHS-E5204.md b/docs/errors/AUTHS-E5204.md index 35f8e8e47..69808ab45 100644 --- a/docs/errors/AUTHS-E5204.md +++ b/docs/errors/AUTHS-E5204.md @@ -7,3 +7,7 @@ ## Message attestation creation failed: {0} + +## Suggestion + +Failed to create the extension attestation; check key access and try again diff --git a/docs/errors/AUTHS-E5305.md b/docs/errors/AUTHS-E5305.md index 7cc26c36b..4cda1ea1a 100644 --- a/docs/errors/AUTHS-E5305.md +++ b/docs/errors/AUTHS-E5305.md @@ -7,3 +7,7 @@ ## Message rotation failed: {0} + +## Suggestion + +Key rotation failed; verify your current key is accessible with `auths key list` diff --git a/docs/errors/AUTHS-E5306.md b/docs/errors/AUTHS-E5306.md index ebf91df0d..9a7ef3f3b 100644 --- a/docs/errors/AUTHS-E5306.md +++ b/docs/errors/AUTHS-E5306.md @@ -7,3 +7,7 @@ ## Message rotation event committed to KEL but keychain write failed — manual recovery required: {0} + +## Suggestion + +Re-run the rotation with the same new key to complete the keychain write diff --git a/docs/errors/AUTHS-E5307.md b/docs/errors/AUTHS-E5307.md index 2f607acf8..952a5cc64 100644 --- a/docs/errors/AUTHS-E5307.md +++ b/docs/errors/AUTHS-E5307.md @@ -7,3 +7,7 @@ ## Message rotation requires a software-backed key; alias '{alias}' is hardware-backed (Secure Enclave) and cannot export the raw key material rotation needs + +## Suggestion + +Hardware-backed keys (Secure Enclave / HSM) cannot be rotated in-place; provision a software-backed identity or rotate by creating a new identity diff --git a/docs/errors/AUTHS-E5311.md b/docs/errors/AUTHS-E5311.md index 2ac12cbf8..096fc7f35 100644 --- a/docs/errors/AUTHS-E5311.md +++ b/docs/errors/AUTHS-E5311.md @@ -7,3 +7,7 @@ ## Message identity not found: {did} + +## Suggestion + +Run `auths init` to create a root identity first diff --git a/docs/errors/AUTHS-E5312.md b/docs/errors/AUTHS-E5312.md index 4bd0f3bef..5e66c6ac8 100644 --- a/docs/errors/AUTHS-E5312.md +++ b/docs/errors/AUTHS-E5312.md @@ -7,3 +7,7 @@ ## Message an agent key already exists under alias '{alias}' + +## Suggestion + +An agent already exists under this alias. Reuse it, rotate it with `auths id agent rotate`, or pass a fresh --label; `auths id agent list` shows existing agents. diff --git a/docs/errors/AUTHS-E5313.md b/docs/errors/AUTHS-E5313.md index d709518ce..a4e3db90c 100644 --- a/docs/errors/AUTHS-E5313.md +++ b/docs/errors/AUTHS-E5313.md @@ -7,3 +7,7 @@ ## Message agent delegation failed: {0} + +## Suggestion + +The agent delegation could not be authored or anchored; check the root identity diff --git a/docs/errors/AUTHS-E5314.md b/docs/errors/AUTHS-E5314.md index 0101ec500..764d058a5 100644 --- a/docs/errors/AUTHS-E5314.md +++ b/docs/errors/AUTHS-E5314.md @@ -7,3 +7,7 @@ ## Message agent not found: {did} + +## Suggestion + +Run `auths id agent list` to see the agents this identity has delegated diff --git a/docs/errors/AUTHS-E5315.md b/docs/errors/AUTHS-E5315.md index 8d0720aaf..2112d7525 100644 --- a/docs/errors/AUTHS-E5315.md +++ b/docs/errors/AUTHS-E5315.md @@ -7,3 +7,7 @@ ## Message agent {did} is revoked + +## Suggestion + +This agent was revoked and cannot be rotated; delegate a new agent instead diff --git a/docs/errors/AUTHS-E5316.md b/docs/errors/AUTHS-E5316.md index b73403d7f..c4ae244f2 100644 --- a/docs/errors/AUTHS-E5316.md +++ b/docs/errors/AUTHS-E5316.md @@ -7,3 +7,7 @@ ## Message requested capability '{capability}' exceeds the delegator's scope + +## Suggestion + +Narrow the agent's --scope to a subset of the delegator's own capabilities diff --git a/docs/errors/AUTHS-E5317.md b/docs/errors/AUTHS-E5317.md index 2e9c49763..4203dc3ab 100644 --- a/docs/errors/AUTHS-E5317.md +++ b/docs/errors/AUTHS-E5317.md @@ -7,3 +7,7 @@ ## Message agent delegation attestation failed: {0} + +## Suggestion + +The delegation attestation could not be signed; check the keychain aliases diff --git a/docs/errors/AUTHS-E5318.md b/docs/errors/AUTHS-E5318.md index 142bfc1a8..31ad86cc8 100644 --- a/docs/errors/AUTHS-E5318.md +++ b/docs/errors/AUTHS-E5318.md @@ -7,3 +7,7 @@ ## Message agent delegation attestation anchoring failed: {0} + +## Suggestion + +The delegation attestation could not be anchored; check the root identity's KEL diff --git a/docs/errors/AUTHS-E5400.md b/docs/errors/AUTHS-E5400.md index c7bae09ff..7d8409069 100644 --- a/docs/errors/AUTHS-E5400.md +++ b/docs/errors/AUTHS-E5400.md @@ -7,3 +7,7 @@ ## Message no registry configured. Auths needs no registry: identity, signing and verification are local and git-native, and a signer's KEL reaches a verifier over the same git remote as the code. Pass --registry or set AUTHS_REGISTRY_URL only if you are running one. + +## Suggestion + +Registration is optional — signing and verification need no registry. Pass --registry or set AUTHS_REGISTRY_URL only if you run one diff --git a/docs/errors/AUTHS-E5401.md b/docs/errors/AUTHS-E5401.md index 94e4f0f6d..f468bad5e 100644 --- a/docs/errors/AUTHS-E5401.md +++ b/docs/errors/AUTHS-E5401.md @@ -7,3 +7,7 @@ ## Message identity already registered at this registry + +## Suggestion + +This identity is already registered; use `auths id show` to see registration details diff --git a/docs/errors/AUTHS-E5403.md b/docs/errors/AUTHS-E5403.md index 1057572a5..489acbcc0 100644 --- a/docs/errors/AUTHS-E5403.md +++ b/docs/errors/AUTHS-E5403.md @@ -7,3 +7,7 @@ ## Message invalid DID format: {did} + +## Suggestion + +Run `auths doctor` to check local identity data diff --git a/docs/errors/AUTHS-E5503.md b/docs/errors/AUTHS-E5503.md index fdc004aba..e0100775f 100644 --- a/docs/errors/AUTHS-E5503.md +++ b/docs/errors/AUTHS-E5503.md @@ -7,3 +7,7 @@ ## Message invalid response: {0} + +## Suggestion + +The OIDC bridge returned an unexpected response; verify the bridge URL and try again diff --git a/docs/errors/AUTHS-E5504.md b/docs/errors/AUTHS-E5504.md index e0d357244..f4b6a4520 100644 --- a/docs/errors/AUTHS-E5504.md +++ b/docs/errors/AUTHS-E5504.md @@ -7,3 +7,7 @@ ## Message insufficient capabilities: requested {requested:?} + +## Suggestion + +Request fewer capabilities or contact your administrator diff --git a/docs/errors/AUTHS-E5551.md b/docs/errors/AUTHS-E5551.md index 6094e6cb1..1d6f851c1 100644 --- a/docs/errors/AUTHS-E5551.md +++ b/docs/errors/AUTHS-E5551.md @@ -7,3 +7,7 @@ ## Message Unknown identity '{did}' and trust policy is '{policy}' + +## Suggestion + +Run `auths trust pin --did ` or add the identity to .auths/roots.json diff --git a/docs/errors/AUTHS-E5552.md b/docs/errors/AUTHS-E5552.md index 93f77929b..fb975cb2b 100644 --- a/docs/errors/AUTHS-E5552.md +++ b/docs/errors/AUTHS-E5552.md @@ -7,3 +7,7 @@ ## Message Failed to resolve public key for identity {did} + +## Suggestion + +Verify the identity exists and has a valid public key registered diff --git a/docs/errors/AUTHS-E5553.md b/docs/errors/AUTHS-E5553.md index 28dc74ccd..cc8b61180 100644 --- a/docs/errors/AUTHS-E5553.md +++ b/docs/errors/AUTHS-E5553.md @@ -7,3 +7,7 @@ ## Message Invalid trust store: {0} + +## Suggestion + +Check the format of your trust store (roots.json or ~/.auths/known_identities.json) diff --git a/docs/errors/AUTHS-E5554.md b/docs/errors/AUTHS-E5554.md index a67c39f30..a74428840 100644 --- a/docs/errors/AUTHS-E5554.md +++ b/docs/errors/AUTHS-E5554.md @@ -7,3 +7,7 @@ ## Message TOFU trust decision required but running in non-interactive mode + +## Suggestion + +Run interactively (on a TTY), or pre-pin the identity with `auths trust pin` so no interactive decision is needed diff --git a/docs/errors/AUTHS-E5601.md b/docs/errors/AUTHS-E5601.md index 3f04f6542..40fb5cfa1 100644 --- a/docs/errors/AUTHS-E5601.md +++ b/docs/errors/AUTHS-E5601.md @@ -7,3 +7,7 @@ ## Message no admin with the given public key found in organization '{org}' + +## Suggestion + +Verify you are using the correct admin key for this organization diff --git a/docs/errors/AUTHS-E5602.md b/docs/errors/AUTHS-E5602.md index 621c6c3a6..80f2c696a 100644 --- a/docs/errors/AUTHS-E5602.md +++ b/docs/errors/AUTHS-E5602.md @@ -7,3 +7,7 @@ ## Message member '{did}' not found in organization '{org}' + +## Suggestion + +Run `auths org list-members` to see current members diff --git a/docs/errors/AUTHS-E5603.md b/docs/errors/AUTHS-E5603.md index cf3c25cb4..fe48b0418 100644 --- a/docs/errors/AUTHS-E5603.md +++ b/docs/errors/AUTHS-E5603.md @@ -7,3 +7,7 @@ ## Message member '{did}' is already revoked + +## Suggestion + +This member has already been revoked from the organization diff --git a/docs/errors/AUTHS-E5604.md b/docs/errors/AUTHS-E5604.md index 3e8c2ef49..65620e20d 100644 --- a/docs/errors/AUTHS-E5604.md +++ b/docs/errors/AUTHS-E5604.md @@ -7,3 +7,7 @@ ## Message invalid capability '{cap}': {reason} + +## Suggestion + +Use a valid capability (e.g., 'sign_commit', 'manage_members', 'admin') diff --git a/docs/errors/AUTHS-E5607.md b/docs/errors/AUTHS-E5607.md index 1aa4ab814..fa910f245 100644 --- a/docs/errors/AUTHS-E5607.md +++ b/docs/errors/AUTHS-E5607.md @@ -7,3 +7,7 @@ ## Message signing error: {0} + +## Suggestion + +The signing operation failed; check your key access with `auths key list` diff --git a/docs/errors/AUTHS-E5608.md b/docs/errors/AUTHS-E5608.md index ca3ff0de0..5df76a523 100644 --- a/docs/errors/AUTHS-E5608.md +++ b/docs/errors/AUTHS-E5608.md @@ -7,3 +7,7 @@ ## Message identity error: {0} + +## Suggestion + +Failed to load identity; run `auths id show` to check identity status diff --git a/docs/errors/AUTHS-E5609.md b/docs/errors/AUTHS-E5609.md index cfd92f2b9..8fb29fa78 100644 --- a/docs/errors/AUTHS-E5609.md +++ b/docs/errors/AUTHS-E5609.md @@ -7,3 +7,7 @@ ## Message key storage error: {0} + +## Suggestion + +Failed to access key storage; run `auths doctor` to diagnose diff --git a/docs/errors/AUTHS-E5610.md b/docs/errors/AUTHS-E5610.md index 606ddda5f..0b75d1e62 100644 --- a/docs/errors/AUTHS-E5610.md +++ b/docs/errors/AUTHS-E5610.md @@ -7,3 +7,7 @@ ## Message storage error: {0} + +## Suggestion + +Failed to access organization storage; check repository permissions diff --git a/docs/errors/AUTHS-E5612.md b/docs/errors/AUTHS-E5612.md index a832e83e9..df286e00c 100644 --- a/docs/errors/AUTHS-E5612.md +++ b/docs/errors/AUTHS-E5612.md @@ -7,3 +7,7 @@ ## Message organization '{org}' uses a multi-signature controller (kt≥2); KERI-native member delegation requires a single-signature (kt=1) org + +## Suggestion + +Multi-signature org anchoring is not yet supported; use a single-signature (kt=1) org diff --git a/docs/errors/AUTHS-E5613.md b/docs/errors/AUTHS-E5613.md index e1da1fc65..8c7d6e851 100644 --- a/docs/errors/AUTHS-E5613.md +++ b/docs/errors/AUTHS-E5613.md @@ -7,3 +7,7 @@ ## Message a member key already exists under alias '{alias}' + +## Suggestion + +Choose a different member alias; run `auths org list-members` to see existing members diff --git a/docs/errors/AUTHS-E5614.md b/docs/errors/AUTHS-E5614.md index 4089ac693..30e6f3991 100644 --- a/docs/errors/AUTHS-E5614.md +++ b/docs/errors/AUTHS-E5614.md @@ -7,3 +7,7 @@ ## Message member delegation failed: {0} + +## Suggestion + +The member delegation could not be authored or anchored; check the org identity diff --git a/docs/errors/AUTHS-E5615.md b/docs/errors/AUTHS-E5615.md index 6f674c29d..41e566176 100644 --- a/docs/errors/AUTHS-E5615.md +++ b/docs/errors/AUTHS-E5615.md @@ -7,3 +7,7 @@ ## Message an identity already exists at {location}; refusing to create an organization over it + +## Suggestion + +An identity already exists here; use a fresh repository path to create a new organization diff --git a/docs/errors/AUTHS-E5616.md b/docs/errors/AUTHS-E5616.md index 0620b138d..8570d90f0 100644 --- a/docs/errors/AUTHS-E5616.md +++ b/docs/errors/AUTHS-E5616.md @@ -7,3 +7,7 @@ ## Message failed to initialize organization identity: {0} + +## Suggestion + +Failed to initialize the org identity; check key access and repository state diff --git a/docs/errors/AUTHS-E5617.md b/docs/errors/AUTHS-E5617.md index 84558e489..dba0824f3 100644 --- a/docs/errors/AUTHS-E5617.md +++ b/docs/errors/AUTHS-E5617.md @@ -7,3 +7,7 @@ ## Message failed to create admin attestation: {0} + +## Suggestion + +Failed to sign the admin attestation; check your key access with `auths key list` diff --git a/docs/errors/AUTHS-E5618.md b/docs/errors/AUTHS-E5618.md index e889f89d1..bc90c4598 100644 --- a/docs/errors/AUTHS-E5618.md +++ b/docs/errors/AUTHS-E5618.md @@ -7,3 +7,7 @@ ## Message member '{did}' is not a delegated identifier of organization '{org}' + +## Suggestion + +The member must first incept a delegated identity naming this org as delegator (pairing) before it can be off-boarded diff --git a/docs/errors/AUTHS-E5622.md b/docs/errors/AUTHS-E5622.md index aea4a7f2c..0455f45d4 100644 --- a/docs/errors/AUTHS-E5622.md +++ b/docs/errors/AUTHS-E5622.md @@ -7,3 +7,7 @@ ## Message invalid org policy: {reason} + +## Suggestion + +Fix the policy JSON (a serialized `Expr`); see `auths org policy show` for the current policy diff --git a/docs/errors/AUTHS-E5623.md b/docs/errors/AUTHS-E5623.md index 16938aa33..2b7421e66 100644 --- a/docs/errors/AUTHS-E5623.md +++ b/docs/errors/AUTHS-E5623.md @@ -7,3 +7,7 @@ ## Message org policy blob for hash '{hash}' is missing from storage + +## Suggestion + +The policy blob is missing; re-anchor it with `auths org policy set` diff --git a/docs/errors/AUTHS-E5624.md b/docs/errors/AUTHS-E5624.md index ab479dad1..00fe2a238 100644 --- a/docs/errors/AUTHS-E5624.md +++ b/docs/errors/AUTHS-E5624.md @@ -7,3 +7,7 @@ ## Message org policy integrity failure: KEL committed hash '{expected}' but the stored blob hashes to '{actual}' + +## Suggestion + +The stored policy was modified after anchoring; re-anchor a trusted policy with `auths org policy set` diff --git a/docs/errors/AUTHS-E5625.md b/docs/errors/AUTHS-E5625.md index 6dd1c4e5f..a7119f837 100644 --- a/docs/errors/AUTHS-E5625.md +++ b/docs/errors/AUTHS-E5625.md @@ -7,3 +7,7 @@ ## Message delegation chain cycle detected at '{did}' + +## Suggestion + +The delegation chain is malformed (a cycle); inspect the identifiers' KELs diff --git a/docs/errors/AUTHS-E5626.md b/docs/errors/AUTHS-E5626.md index 224df5281..471562567 100644 --- a/docs/errors/AUTHS-E5626.md +++ b/docs/errors/AUTHS-E5626.md @@ -7,3 +7,7 @@ ## Message delegation chain exceeds the maximum depth of {max} hops + +## Suggestion + +The delegation chain is too deep; reduce delegation nesting diff --git a/docs/errors/AUTHS-E5627.md b/docs/errors/AUTHS-E5627.md index c6992eb74..3d0f2ddee 100644 --- a/docs/errors/AUTHS-E5627.md +++ b/docs/errors/AUTHS-E5627.md @@ -7,3 +7,7 @@ ## Message delegation chain is broken: no KEL found for '{did}' + +## Suggestion + +A KEL in the delegation chain is missing; ensure all delegators' KELs are present diff --git a/docs/errors/AUTHS-E5628.md b/docs/errors/AUTHS-E5628.md index cefda501e..45117853f 100644 --- a/docs/errors/AUTHS-E5628.md +++ b/docs/errors/AUTHS-E5628.md @@ -7,3 +7,7 @@ ## Message invalid OIDC-subject policy: {reason} + +## Suggestion + +Fix the OIDC-subject policy JSON (issuer + repository, optional workflow_ref); nothing was anchored diff --git a/docs/errors/AUTHS-E5701.md b/docs/errors/AUTHS-E5701.md index 647b45678..9a345b8ff 100644 --- a/docs/errors/AUTHS-E5701.md +++ b/docs/errors/AUTHS-E5701.md @@ -7,3 +7,7 @@ ## Message decision is not RequiresApproval + +## Suggestion + +This operation does not require approval; run it directly without the --approve flag diff --git a/docs/errors/AUTHS-E5702.md b/docs/errors/AUTHS-E5702.md index 5191eb0a2..1a432834d 100644 --- a/docs/errors/AUTHS-E5702.md +++ b/docs/errors/AUTHS-E5702.md @@ -7,3 +7,7 @@ ## Message approval request not found: {hash} + +## Suggestion + +Run `auths approval list` to see pending requests diff --git a/docs/errors/AUTHS-E5850.md b/docs/errors/AUTHS-E5850.md index c3434b5e2..c617e9502 100644 --- a/docs/errors/AUTHS-E5850.md +++ b/docs/errors/AUTHS-E5850.md @@ -7,3 +7,7 @@ ## Message identity not found in configured identity storage + +## Suggestion + +Run `auths init` to create an identity, or `auths key import` to restore one diff --git a/docs/errors/AUTHS-E5851.md b/docs/errors/AUTHS-E5851.md index e9d20bbda..a46cc94f6 100644 --- a/docs/errors/AUTHS-E5851.md +++ b/docs/errors/AUTHS-E5851.md @@ -7,3 +7,7 @@ ## Message key resolution failed: {0} + +## Suggestion + +Run `auths status` to see available device aliases diff --git a/docs/errors/AUTHS-E5855.md b/docs/errors/AUTHS-E5855.md index 29e0bc1ee..0acc68b06 100644 --- a/docs/errors/AUTHS-E5855.md +++ b/docs/errors/AUTHS-E5855.md @@ -7,3 +7,7 @@ ## Message attestation re-signing failed: {0} + +## Suggestion + +Verify your device key is accessible with `auths status` diff --git a/docs/errors/AUTHS-E5856.md b/docs/errors/AUTHS-E5856.md index 16614cd46..3f00bab5e 100644 --- a/docs/errors/AUTHS-E5856.md +++ b/docs/errors/AUTHS-E5856.md @@ -7,3 +7,7 @@ ## Message invalid commit SHA: {0} (expected 40 or 64 hex characters) + +## Suggestion + +Provide a full SHA-1 (40 hex chars) or SHA-256 (64 hex chars) commit hash diff --git a/docs/errors/AUTHS-E5857.md b/docs/errors/AUTHS-E5857.md index fa86342ef..23500ef8a 100644 --- a/docs/errors/AUTHS-E5857.md +++ b/docs/errors/AUTHS-E5857.md @@ -7,3 +7,7 @@ ## Message device revoked: {0} + +## Suggestion + +This device has been removed; pair a new device or sign from an active one diff --git a/docs/errors/AUTHS-E5858.md b/docs/errors/AUTHS-E5858.md index 56bea8389..7f0878591 100644 --- a/docs/errors/AUTHS-E5858.md +++ b/docs/errors/AUTHS-E5858.md @@ -7,3 +7,7 @@ ## Message signing key rotated out of the KEL: {0} + +## Suggestion + +This key was rotated out; sign with the identity's current key diff --git a/docs/errors/AUTHS-E5903.md b/docs/errors/AUTHS-E5903.md index bc8744087..b5bb352a7 100644 --- a/docs/errors/AUTHS-E5903.md +++ b/docs/errors/AUTHS-E5903.md @@ -7,3 +7,7 @@ ## Message signing operation failed: {0} + +## Suggestion + +The signing operation failed; verify your key is accessible with `auths key list` diff --git a/docs/errors/AUTHS-E5905.md b/docs/errors/AUTHS-E5905.md index a20720c83..1c731dc69 100644 --- a/docs/errors/AUTHS-E5905.md +++ b/docs/errors/AUTHS-E5905.md @@ -7,3 +7,7 @@ ## Message PEM encoding failed: {0} + +## Suggestion + +Failed to encode the key in PEM format; the key material may be corrupted diff --git a/docs/errors/AUTHS-E5908.md b/docs/errors/AUTHS-E5908.md index c4379ed8e..11f5c4792 100644 --- a/docs/errors/AUTHS-E5908.md +++ b/docs/errors/AUTHS-E5908.md @@ -7,3 +7,7 @@ ## Message passphrase exhausted after {attempts} attempt(s) + +## Suggestion + +The passphrase you entered is incorrect (tried 3 times). Verify it matches what you set during init, or try: auths key export --key-alias --format pub diff --git a/docs/errors/AUTHS-E5911.md b/docs/errors/AUTHS-E5911.md new file mode 100644 index 000000000..c492812a3 --- /dev/null +++ b/docs/errors/AUTHS-E5911.md @@ -0,0 +1,13 @@ +# AUTHS-E5911 + +**Crate:** `auths-sdk` + +**Type:** `SigningError::KeyNotFound` + +## Message + +no signing key under alias '{alias}' + +## Suggestion + +Run `auths key list` to see available aliases, or `auths init` to create one diff --git a/docs/errors/AUTHS-E6003.md b/docs/errors/AUTHS-E6003.md index ac57a2522..453ac1cfc 100644 --- a/docs/errors/AUTHS-E6003.md +++ b/docs/errors/AUTHS-E6003.md @@ -7,3 +7,7 @@ ## Message canonical JSON serialization failed: {0} + +## Suggestion + +This is an internal error; please report it as a bug diff --git a/docs/errors/AUTHS-E6101.md b/docs/errors/AUTHS-E6101.md index 277259bdd..9e246dbf7 100644 --- a/docs/errors/AUTHS-E6101.md +++ b/docs/errors/AUTHS-E6101.md @@ -7,3 +7,7 @@ ## Message issuee identity not found (no KEL): {did} + +## Suggestion + +The issuee must have an incepted identity (KEL) before it can be credentialed diff --git a/docs/errors/AUTHS-E6102.md b/docs/errors/AUTHS-E6102.md index 0868b2fe2..fab9958df 100644 --- a/docs/errors/AUTHS-E6102.md +++ b/docs/errors/AUTHS-E6102.md @@ -7,3 +7,7 @@ ## Message credential registry error: {0} + +## Suggestion + +Check the issuer identity and registry storage are reachable diff --git a/docs/errors/AUTHS-E6103.md b/docs/errors/AUTHS-E6103.md index 6fec0d15a..7386c6fcb 100644 --- a/docs/errors/AUTHS-E6103.md +++ b/docs/errors/AUTHS-E6103.md @@ -7,3 +7,7 @@ ## Message credential already revoked: {said} + +## Suggestion + +This credential is already revoked; no further action is needed diff --git a/docs/errors/AUTHS-E6104.md b/docs/errors/AUTHS-E6104.md index 8d06ce12e..aaab56b0b 100644 --- a/docs/errors/AUTHS-E6104.md +++ b/docs/errors/AUTHS-E6104.md @@ -7,3 +7,7 @@ ## Message issuer is multi-signature (kt≥2); credential anchoring is single-author only + +## Suggestion + +Credential issuance currently requires a single-signature (kt=1) issuer diff --git a/docs/errors/AUTHS-E6105.md b/docs/errors/AUTHS-E6105.md index 4545c2bb6..4c2b6b32c 100644 --- a/docs/errors/AUTHS-E6105.md +++ b/docs/errors/AUTHS-E6105.md @@ -7,3 +7,7 @@ ## Message capability schema unknown or uncomputable + +## Suggestion + +The compiled-in capability schema is unavailable; this is a build defect diff --git a/docs/errors/AUTHS-E6106.md b/docs/errors/AUTHS-E6106.md index 26e199f31..ad139b4f0 100644 --- a/docs/errors/AUTHS-E6106.md +++ b/docs/errors/AUTHS-E6106.md @@ -7,3 +7,7 @@ ## Message credential status is stale or unresolvable: {reason} + +## Suggestion + +No fresh witnessed tip was reachable; sync the issuer KEL/receipts and retry, or relax --require-witnesses diff --git a/docs/errors/AUTHS-E6107.md b/docs/errors/AUTHS-E6107.md index b100ce735..0d52dbdc8 100644 --- a/docs/errors/AUTHS-E6107.md +++ b/docs/errors/AUTHS-E6107.md @@ -7,3 +7,7 @@ ## Message malformed quantitative usage cap '{cap}': a calls: capability must carry a non-negative integer bound (e.g. calls:3) + +## Suggestion + +Use a quantitative cap with a non-negative integer bound, e.g. --cap calls:3 diff --git a/docs/errors/AUTHS-E6301.md b/docs/errors/AUTHS-E6301.md new file mode 100644 index 000000000..f25bfedf8 --- /dev/null +++ b/docs/errors/AUTHS-E6301.md @@ -0,0 +1,13 @@ +# AUTHS-E6301 + +**Crate:** `auths-cli` + +**Type:** `SignerKelError::Unavailable` + +## Message + +signer's KEL for {did} is not available locally: {reason} + +## Suggestion + +Fetch the signer's KEL with `git fetch 'refs/auths/*:refs/auths/*'`, or verify against an evidence bundle with `--identity-bundle`. diff --git a/docs/errors/AUTHS-E8001.md b/docs/errors/AUTHS-E8001.md index 0ef461881..d69ef21fd 100644 --- a/docs/errors/AUTHS-E8001.md +++ b/docs/errors/AUTHS-E8001.md @@ -7,3 +7,7 @@ ## Message JWT decode failed: {0} + +## Suggestion + +Verify the token format and ensure it is a valid JWT diff --git a/docs/errors/AUTHS-E8002.md b/docs/errors/AUTHS-E8002.md index 15e600be4..20f7c5d34 100644 --- a/docs/errors/AUTHS-E8002.md +++ b/docs/errors/AUTHS-E8002.md @@ -7,3 +7,7 @@ ## Message signature verification failed + +## Suggestion + +Check that the JWKS endpoint is up-to-date and the token is from a trusted issuer diff --git a/docs/errors/AUTHS-E8003.md b/docs/errors/AUTHS-E8003.md index b8aee648b..3724a7e8a 100644 --- a/docs/errors/AUTHS-E8003.md +++ b/docs/errors/AUTHS-E8003.md @@ -7,3 +7,7 @@ ## Message claim validation failed - {claim}: {reason} + +## Suggestion + +The token has expired; acquire a new token from the OIDC provider diff --git a/docs/errors/AUTHS-E8004.md b/docs/errors/AUTHS-E8004.md index 91c83c252..9aa52035c 100644 --- a/docs/errors/AUTHS-E8004.md +++ b/docs/errors/AUTHS-E8004.md @@ -7,3 +7,7 @@ ## Message unknown key ID: {0} + +## Suggestion + +The JWKS cache may be stale; refresh the JWKS from the issuer endpoint diff --git a/docs/errors/AUTHS-E8005.md b/docs/errors/AUTHS-E8005.md index f7b08c346..ab06defc7 100644 --- a/docs/errors/AUTHS-E8005.md +++ b/docs/errors/AUTHS-E8005.md @@ -7,3 +7,7 @@ ## Message JWKS resolution failed: {0} + +## Suggestion + +Check network connectivity to the JWKS endpoint and ensure the issuer URL is correct diff --git a/docs/errors/AUTHS-E8006.md b/docs/errors/AUTHS-E8006.md index a33d049b9..e820d011f 100644 --- a/docs/errors/AUTHS-E8006.md +++ b/docs/errors/AUTHS-E8006.md @@ -7,3 +7,7 @@ ## Message algorithm mismatch: expected {expected}, got {got} + +## Suggestion + +Verify that the expected algorithm matches the algorithm used by the OIDC provider diff --git a/docs/errors/AUTHS-E8007.md b/docs/errors/AUTHS-E8007.md index 36002801c..b90365055 100644 --- a/docs/errors/AUTHS-E8007.md +++ b/docs/errors/AUTHS-E8007.md @@ -7,3 +7,7 @@ ## Message token expired (exp: {token_exp}, now: {current_time}, leeway: {leeway}s) + +## Suggestion + +Synchronize the system clock or increase the configured clock skew tolerance diff --git a/docs/errors/AUTHS-E8008.md b/docs/errors/AUTHS-E8008.md index f4ab750e7..c31c950cd 100644 --- a/docs/errors/AUTHS-E8008.md +++ b/docs/errors/AUTHS-E8008.md @@ -7,3 +7,7 @@ ## Message token replay detected (jti: {0}) + +## Suggestion + +A token with this ID has already been used; acquire a new token from the OIDC provider diff --git a/docs/errors/AUTHS-E9001.md b/docs/errors/AUTHS-E9001.md index f45131ac3..13b37285d 100644 --- a/docs/errors/AUTHS-E9001.md +++ b/docs/errors/AUTHS-E9001.md @@ -7,3 +7,7 @@ ## Message submission rejected: {reason} + +## Suggestion + +Check the attestation format and payload size diff --git a/docs/errors/AUTHS-E9004.md b/docs/errors/AUTHS-E9004.md index ecfe5e5ce..e3c3998cf 100644 --- a/docs/errors/AUTHS-E9004.md +++ b/docs/errors/AUTHS-E9004.md @@ -7,3 +7,7 @@ ## Message invalid response: {0} + +## Suggestion + +The log returned an unexpected response; check the log version diff --git a/docs/errors/AUTHS-E9006.md b/docs/errors/AUTHS-E9006.md index 3b62281f8..b89876af5 100644 --- a/docs/errors/AUTHS-E9006.md +++ b/docs/errors/AUTHS-E9006.md @@ -7,3 +7,7 @@ ## Message consistency violation: {0} + +## Suggestion + +The log returned data that does not match what was submitted diff --git a/docs/errors/AUTHS-E9007.md b/docs/errors/AUTHS-E9007.md index 967f2a2d5..889e32e9f 100644 --- a/docs/errors/AUTHS-E9007.md +++ b/docs/errors/AUTHS-E9007.md @@ -7,3 +7,7 @@ ## Message log unavailable: {0} + +## Suggestion + +The transparency log is unavailable; retry later or use --allow-unlogged diff --git a/docs/errors/index.md b/docs/errors/index.md index 4b5c37995..2147e0b71 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -43,13 +43,13 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E2021](AUTHS-E2021.md) | `auths-verifier` | `AttestationError::DelegatorRevoked` | Delegator attestation is revoked | | [AUTHS-E2022](AUTHS-E2022.md) | `auths-verifier` | `AttestationError::DelegatorUnresolved` | Delegator attestation could not be resolved | | [AUTHS-E2101](AUTHS-E2101.md) | `auths-verifier` | `CommitVerificationError::UnsignedCommit` | commit is unsigned | -| [AUTHS-E2102](AUTHS-E2102.md) | `auths-verifier` | `CommitVerificationError::GpgNotSupported` | GPG signatures not supported, use SSH signing | +| [AUTHS-E2102](AUTHS-E2102.md) | `auths-verifier` | `CommitVerificationError::GpgNotSupported` | GPG signatures are not verified by Auths — use did:keri trailers via `auths init` | | [AUTHS-E2103](AUTHS-E2103.md) | `auths-verifier` | `CommitVerificationError::SshSigParseFailed` | SSHSIG parse failed: {0} | | [AUTHS-E2104](AUTHS-E2104.md) | `auths-verifier` | `CommitVerificationError::UnsupportedKeyType` | unsupported SSH key type: {found} | | [AUTHS-E2105](AUTHS-E2105.md) | `auths-verifier` | `CommitVerificationError::NamespaceMismatch` | namespace mismatch: expected \"{expected}\", found \"{found}\" | | [AUTHS-E2106](AUTHS-E2106.md) | `auths-verifier` | `CommitVerificationError::HashAlgorithmUnsupported` | unsupported hash algorithm: {0} | | [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-E2108](AUTHS-E2108.md) | `auths-verifier` | `CommitVerificationError::UnknownSigner` | signer identity is not trusted (no matching pinned root) | | [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}' | @@ -351,6 +351,7 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E5908](AUTHS-E5908.md) | `auths-sdk` | `SigningError::PassphraseExhausted` | passphrase exhausted after {attempts} attempt(s) | | [AUTHS-E5909](AUTHS-E5909.md) | `auths-sdk` | `SigningError::KeychainUnavailable` | keychain unavailable: {0} | | [AUTHS-E5910](AUTHS-E5910.md) | `auths-sdk` | `SigningError::KeyDecryptionFailed` | key decryption failed: {0} | +| [AUTHS-E5911](AUTHS-E5911.md) | `auths-sdk` | `SigningError::KeyNotFound` | no signing key under alias '{alias}' | | [AUTHS-E6001](AUTHS-E6001.md) | `auths-sdk` | `AuthChallengeError::EmptyNonce` | nonce must not be empty | | [AUTHS-E6002](AUTHS-E6002.md) | `auths-sdk` | `AuthChallengeError::EmptyDomain` | domain must not be empty | | [AUTHS-E6003](AUTHS-E6003.md) | `auths-sdk` | `AuthChallengeError::Canonicalization` | canonical JSON serialization failed: {0} | @@ -361,6 +362,7 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E6105](AUTHS-E6105.md) | `auths-sdk` | `CredentialError::SchemaUnknown` | capability schema unknown or uncomputable | | [AUTHS-E6106](AUTHS-E6106.md) | `auths-sdk` | `CredentialError::StaleOrUnresolvable` | credential status is stale or unresolvable: {reason} | | [AUTHS-E6107](AUTHS-E6107.md) | `auths-sdk` | `CredentialError::MalformedUsageCap` | malformed quantitative usage cap '{cap}': a calls: capability must carry a non-negative integer bound (e.g. calls:3) | +| [AUTHS-E6301](AUTHS-E6301.md) | `auths-cli` | `SignerKelError::Unavailable` | signer's KEL for {did} is not available locally: {reason} | | [AUTHS-E8001](AUTHS-E8001.md) | `auths-oidc-port` | `OidcError::JwtDecode` | JWT decode failed: {0} | | [AUTHS-E8002](AUTHS-E8002.md) | `auths-oidc-port` | `OidcError::SignatureVerificationFailed` | signature verification failed | | [AUTHS-E8003](AUTHS-E8003.md) | `auths-oidc-port` | `OidcError::ClaimsValidationFailed` | claim validation failed - {claim}: {reason} | diff --git a/docs/errors/registry.lock b/docs/errors/registry.lock index 50f1b5155..7a02fe912 100644 --- a/docs/errors/registry.lock +++ b/docs/errors/registry.lock @@ -355,6 +355,7 @@ AUTHS-E5907 = auths-sdk::SigningError::AgentSigningFailed AUTHS-E5908 = auths-sdk::SigningError::PassphraseExhausted AUTHS-E5909 = auths-sdk::SigningError::KeychainUnavailable AUTHS-E5910 = auths-sdk::SigningError::KeyDecryptionFailed +AUTHS-E5911 = auths-sdk::SigningError::KeyNotFound AUTHS-E6001 = auths-sdk::AuthChallengeError::EmptyNonce AUTHS-E6002 = auths-sdk::AuthChallengeError::EmptyDomain AUTHS-E6003 = auths-sdk::AuthChallengeError::Canonicalization @@ -365,6 +366,7 @@ AUTHS-E6104 = auths-sdk::CredentialError::KtThresholdUnsupported AUTHS-E6105 = auths-sdk::CredentialError::SchemaUnknown AUTHS-E6106 = auths-sdk::CredentialError::StaleOrUnresolvable AUTHS-E6107 = auths-sdk::CredentialError::MalformedUsageCap +AUTHS-E6301 = auths-cli::SignerKelError::Unavailable AUTHS-E8001 = auths-oidc-port::OidcError::JwtDecode AUTHS-E8002 = auths-oidc-port::OidcError::SignatureVerificationFailed AUTHS-E8003 = auths-oidc-port::OidcError::ClaimsValidationFailed diff --git a/packages/auths-node/Cargo.toml b/packages/auths-node/Cargo.toml index 696f92320..68d2d2a57 100644 --- a/packages/auths-node/Cargo.toml +++ b/packages/auths-node/Cargo.toml @@ -1,6 +1,10 @@ [package] name = "auths-node" -version = "0.1.0" +# Tracks the published npm package version so `version()` (which reads +# CARGO_PKG_VERSION) reports the truth. auths-node is its own (nested) Cargo +# workspace, so the source of truth is the [workspace.package] version below — +# the release step bumps it in lockstep with package.json. +version.workspace = true edition = "2024" description = "Node.js bindings for the Auths decentralized identity SDK" license = "Apache-2.0" @@ -8,6 +12,9 @@ publish = false [workspace] +[workspace.package] +version = "0.1.12" + [lib] crate-type = ["cdylib"] diff --git a/packages/auths-node/__test__/activity.spec.ts b/packages/auths-node/__test__/activity.spec.ts new file mode 100644 index 000000000..7d17622e2 --- /dev/null +++ b/packages/auths-node/__test__/activity.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const native = require('../index.js') + +// A minimal activity/v1-shaped document that PARSES (so verification reaches the +// registry path rather than the "not activity/v1-shaped" early return). Its +// as_of is in the past, so the future-rejection guard does not fire. +const shapedDoc = JSON.stringify({ + version: 'activity/v1', + suite: 'json-canon/ed25519', + subject: { root: 'did:keri:Eroot', agent: 'did:keri:Eagent' }, + head: '11'.repeat(32), + count: 1, + cumulative_cents: 100, + as_of: { ts: '2020-01-01T00:00:00Z' }, + signature: 'AAAA', +}) + +describe('verifyActivityAttestation', () => { + it('is exported', () => { + expect(typeof native.verifyActivityAttestation).toBe('function') + }) + + it('returns a JSON verdict (not a throw) for malformed input', () => { + const v = JSON.parse(native.verifyActivityAttestation('{not json', '/nonexistent')) + expect(v.ok).toBe(false) + expect(typeof v.reason).toBe('string') + }) + + it('accepts the options argument and returns a parseable verdict', () => { + const dir = mkdtempSync(join(tmpdir(), 'auths-activity-')) + // No resolvable KEL at this path, so the verdict is a clean ok:false — the + // point is that passing options does not throw at the binding boundary. + const v = JSON.parse( + native.verifyActivityAttestation(shapedDoc, dir, { + requireWitness: true, + witnessTipIndex: 5, + }), + ) + expect(v.ok).toBe(false) + expect(typeof v.reason).toBe('string') + }) + + // The verdict SHAPE now carries the anchor tier and freshness bound a relying + // party gates on — locked here against the shipped type contract so a + // regression that drops a field goes red even without a live registry (the + // valid-verdict field VALUES are exercised by the Rust attestation battery). + it('the shipped contract declares anchor, freshness, and headBound', () => { + const dts = readFileSync(new URL('../index.d.ts', import.meta.url), 'utf8') + expect(dts).toContain('VerifyActivityOpts') + expect(dts).toMatch(/requireWitness\?/) + expect(dts).toMatch(/witnessTipIndex\?/) + expect(dts).toMatch(/anchor/) + expect(dts).toMatch(/freshness/) + expect(dts).toMatch(/headBound/) + }) +}) diff --git a/packages/auths-node/__test__/exports.spec.ts b/packages/auths-node/__test__/exports.spec.ts index a7052d1db..6fc68beab 100644 --- a/packages/auths-node/__test__/exports.spec.ts +++ b/packages/auths-node/__test__/exports.spec.ts @@ -51,4 +51,10 @@ describe('top-level exports', () => { expect(auths.version).toBeDefined() expect(typeof auths.version).toBe('function') }) + + it('version() matches the package.json version', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require('../package.json') + expect(auths.version()).toBe(pkg.version) + }) }) diff --git a/packages/auths-node/index.d.ts b/packages/auths-node/index.d.ts index 80f3633ad..a123a5ed6 100644 --- a/packages/auths-node/index.d.ts +++ b/packages/auths-node/index.d.ts @@ -570,26 +570,41 @@ export declare function signCommit(data: Buffer, identityKeyAlias: string, repoP */ export declare function verifyActionEnvelope(envelopeJson: string, publicKeyHex: string, curve?: string | undefined | null): NapiVerificationResult +/** Options for {@link verifyActivityAttestation}. */ +export interface VerifyActivityOpts { + /** Fail the whole document when there is no verified witness anchor. */ + requireWitness?: boolean + /** An independently-known witness tip index; a tip past the document's count marks the anchor stale. */ + witnessTipIndex?: number +} + /** * Verify a published `activity/v1` attestation against a fetched registry copy: * resolve the agent's current keys from the KEL (identity resolution ONLY — * never a spend log), require its delegator to be the claimed root, verify the - * signature — including the embedded quorum anchor when one is present (a - * document with a bad anchor fails whole). Returns `{ok, reason?, head, count, - * cumulativeCents, asOfTs, subjectRoot, subjectAgent, anchor}` as JSON — + * signature — including the embedded quorum anchor when one is present, or as a + * required gate under `opts.requireWitness` (a document with a bad or missing + * anchor fails whole). Returns `{ok, reason?, head, count, cumulativeCents, + * asOfTs, subjectRoot, subjectAgent, anchor, freshness, headBound}` as JSON — * everything the market's receipts worker needs, as verdict fields (the report * is the only API). `anchor` is `null` for an unanchored document, else the * VERIFIED quorum shape `{tier, threshold, witnesses, cosigners, seedId, - * witnessSetSaid}` — a relying party never derives a tier from the seller's - * own claims. + * witnessSetSaid, stale}` — a relying party never derives a tier from the + * seller's own claims. `freshness` is `"fresh" | "unknown" | "stale"`; + * `headBound` is `true` only when a verified witness anchor cosigns the head. + * + * `cumulativeCents` is a SIGNED CLAIM, not a re-derived fact: the per-call log is + * structurally private, so the magnitude is provable only when `anchor` is a + * non-stale `witness` summary. A consumer MUST NOT present `cumulativeCents` as + * verified earnings unless `anchor?.tier === "witness" && anchor.stale === false`. * * Usage: * ```ignore - * const check = JSON.parse(verifyActivityAttestation(doc, registryDir)); + * const check = JSON.parse(verifyActivityAttestation(doc, registryDir, { requireWitness: true })); * if (!check.ok) markStale(check.reason); * ``` */ -export declare function verifyActivityAttestation(attestationJson: string, registryPath: string): string +export declare function verifyActivityAttestation(attestationJson: string, registryPath: string, opts?: VerifyActivityOpts | undefined | null): string export declare function verifyAttestation(attestationJson: string, issuerPkHex: string): Promise diff --git a/packages/auths-node/src/evidence.rs b/packages/auths-node/src/evidence.rs index 8cb5087c2..56f14ee9c 100644 --- a/packages/auths-node/src/evidence.rs +++ b/packages/auths-node/src/evidence.rs @@ -60,27 +60,57 @@ pub async fn verify_offline(bundle_json: String) -> Result { serde_json::to_string(&verdict).map_err(|e| Error::from_reason(e.to_string())) } +/// Options for [`verify_activity_attestation`]. +#[napi(object)] +pub struct VerifyActivityOpts { + /// Fail the whole document when there is no verified witness anchor — promotes + /// the anchor from an additive assurance tier to a required gate. + pub require_witness: Option, + /// An independently-known witness tip index for this seed, if the caller has + /// one. A tip greater than the document's count marks the anchor `stale`. + pub witness_tip_index: Option, +} + +impl From for auths_evidence::VerifyActivityOpts { + fn from(o: VerifyActivityOpts) -> Self { + Self { + require_witness: o.require_witness.unwrap_or(false), + witness_tip_index: o.witness_tip_index.map(|v| v as u64), + } + } +} + /// Verify a published `activity/v1` attestation against a fetched registry copy: /// resolve the agent's current keys from the KEL (identity resolution ONLY — /// never a spend log), require its delegator to be the claimed root, verify the -/// signature — including the embedded quorum anchor when one is present (a -/// document with a bad anchor fails whole). Returns `{ok, reason?, head, count, -/// cumulativeCents, asOfTs, subjectRoot, subjectAgent, anchor}` as JSON — +/// signature — including the embedded quorum anchor when one is present, or as a +/// required gate under `opts.requireWitness` (a document with a bad or missing +/// anchor fails whole). Returns `{ok, reason?, head, count, cumulativeCents, +/// asOfTs, subjectRoot, subjectAgent, anchor, freshness, headBound}` as JSON — /// everything the market's receipts worker needs, as verdict fields (the report /// is the only API). `anchor` is `null` for an unanchored document, else the /// VERIFIED quorum shape `{tier, threshold, witnesses, cosigners, seedId, -/// witnessSetSaid}` — a relying party never derives a tier from the seller's -/// own claims. +/// witnessSetSaid, stale}` — a relying party never derives a tier from the +/// seller's own claims. `freshness` is `"fresh" | "unknown" | "stale"`; +/// `headBound` is `true` only when a verified witness anchor cosigns the head. +/// +/// `cumulativeCents` is a SIGNED CLAIM, not a re-derived fact: the per-call log +/// is structurally private, so the magnitude is provable only when `anchor` is a +/// non-stale `witness` summary. A consumer MUST NOT present `cumulativeCents` as +/// verified earnings unless `anchor?.tier === "witness" && anchor.stale === false`. +/// At first-seen (`anchor === null`) the honest label is "seller-claimed, +/// unwitnessed". /// /// Usage: /// ```ignore -/// const check = JSON.parse(verifyActivityAttestation(doc, registryDir)); +/// const check = JSON.parse(verifyActivityAttestation(doc, registryDir, { requireWitness: true })); /// if (!check.ok) markStale(check.reason); /// ``` #[napi] pub fn verify_activity_attestation( attestation_json: String, registry_path: String, + opts: Option, ) -> Result { let doc: auths_evidence::ActivityV1 = match serde_json::from_str(&attestation_json) { Ok(doc) => doc, @@ -91,12 +121,19 @@ pub fn verify_activity_attestation( .map_err(|e| Error::from_reason(e.to_string())); } }; + let native_opts = opts + .map(auths_evidence::VerifyActivityOpts::from) + .unwrap_or_default(); + #[allow(clippy::disallowed_methods)] // binding boundary: wall clock injected here + let now = chrono::Utc::now(); let outcome = auths_evidence::verify_activity_against_registry( &doc, std::path::Path::new(®istry_path), + now, + native_opts, ); let body = match outcome { - Ok(()) => serde_json::json!({ + Ok(verdict) => serde_json::json!({ "ok": true, "head": doc.head, "count": doc.count, @@ -104,16 +141,18 @@ pub fn verify_activity_attestation( "asOfTs": doc.as_of.ts.to_rfc3339(), "subjectRoot": doc.subject.root, "subjectAgent": doc.subject.agent, - // Only reachable when verify_embedded_anchor already re-checked the - // finalization, so this summary restates proven facts. - "anchor": doc.anchor.as_ref().map(|finalized| serde_json::json!({ - "tier": "witness", - "threshold": finalized.witness_set.threshold, - "witnesses": finalized.witness_set.members.len(), - "cosigners": finalized.cosignatures.len(), - "seedId": finalized.anchor.seed_id.to_hex(), - "witnessSetSaid": finalized.witness_set.said, - })), + // Only reachable when verify_activity_with_keys already re-checked the + // finalization, so these fields restate proven facts. + "anchor": verdict.anchor, + "freshness": verdict.freshness, + "headBound": verdict.head_bound, + }), + // A present-but-bad (or required-but-absent) anchor fails whole AND names + // the leg the market can gate on. + Err(auths_evidence::EvidenceError::AnchorInvalid { code, detail }) => serde_json::json!({ + "ok": false, + "reason": format!("embedded anchor: {detail}"), + "anchor": { "status": "invalid", "failedCheck": code }, }), Err(e) => serde_json::json!({ "ok": false, "reason": e.to_string() }), }; diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index e5f4e2f6d..fde218a0f 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -128,6 +128,28 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auths-anchor" +version = "0.1.12" +dependencies = [ + "auths-crypto", + "auths-keri", + "auths-transparency", + "auths-verifier", + "base64", + "chrono", + "ed25519-dalek", + "getrandom 0.2.17", + "hex", + "json-canon", + "p256", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 2.0.18", +] + [[package]] name = "auths-core" version = "0.1.12" @@ -203,6 +225,7 @@ dependencies = [ name = "auths-evidence" version = "0.1.12" dependencies = [ + "auths-anchor", "auths-crypto", "auths-id", "auths-keri", diff --git a/packages/auths-python/src/evidence.rs b/packages/auths-python/src/evidence.rs index 45b6f501d..579e2b1e7 100644 --- a/packages/auths-python/src/evidence.rs +++ b/packages/auths-python/src/evidence.rs @@ -65,11 +65,15 @@ pub fn verify_activity(attestation_json: String, registry_path: String) -> PyRes .map_err(|e| PyValueError::new_err(e.to_string())); } }; + #[allow(clippy::disallowed_methods)] // binding boundary: wall clock injected here + let now = chrono::Utc::now(); let body = match auths_evidence::verify_activity_against_registry( &doc, std::path::Path::new(®istry_path), + now, + auths_evidence::VerifyActivityOpts::default(), ) { - Ok(()) => serde_json::json!({ + Ok(verdict) => serde_json::json!({ "ok": true, "head": doc.head, "count": doc.count, @@ -77,6 +81,9 @@ pub fn verify_activity(attestation_json: String, registry_path: String) -> PyRes "as_of_ts": doc.as_of.ts.to_rfc3339(), "subject_root": doc.subject.root, "subject_agent": doc.subject.agent, + "anchor": verdict.anchor, + "freshness": verdict.freshness, + "head_bound": verdict.head_bound, }), Err(e) => serde_json::json!({ "ok": false, "reason": e.to_string() }), }; diff --git a/tests/e2e/helpers/mcp_downstream.py b/tests/e2e/helpers/mcp_downstream.py new file mode 100644 index 000000000..85e6e3ac9 --- /dev/null +++ b/tests/e2e/helpers/mcp_downstream.py @@ -0,0 +1,115 @@ +"""A minimal, self-contained downstream MCP server for the gateway `wrap` e2e tests. + +Speaks MCP JSON-RPC over stdio (initialize / tools/list / tools/call). It is the +server the gateway wraps, so it is the TRIPWIRE: every `tools/call` that actually +reaches it appends one line to the file named by ``AUTHS_TRIPWIRE``. A refused call +never reaches the downstream, so the tripwire line count is exactly the number of +calls the gateway FORWARDED — the ground truth every gate-bounds test asserts against. + +It advertises the reference filesystem-server tool family (read + write) so the +capability map (`fs.read` / `fs.write`) and the scope boundary can be exercised. +No real filesystem access — each tool returns a deterministic stub so the tests stay +hermetic. A metered call (`_auths_cost_cents` + `_auths_rail` in its arguments) is +echoed straight back; the gateway meters it from those declared fields. +""" + +import json +import os +import sys + +READ_TOOLS = [ + "read_file", + "read_text_file", + "read_media_file", + "read_multiple_files", + "list_directory", + "list_directory_with_sizes", + "directory_tree", + "get_file_info", + "search_files", +] +WRITE_TOOLS = ["write_file", "edit_file", "create_directory", "move_file"] + + +def _tripwire(tool: str) -> None: + """Record that a call actually reached the downstream (the gateway forwarded it).""" + path = os.environ.get("AUTHS_TRIPWIRE") + if path: + with open(path, "a", encoding="utf-8") as fh: + fh.write(tool + "\n") + + +def _tool_defs(): + defs = [] + for name in READ_TOOLS: + defs.append({ + "name": name, + "description": f"{name} (read family)", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + }) + for name in WRITE_TOOLS: + defs.append({ + "name": name, + "description": f"{name} (write family)", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": False}, + }) + return defs + + +def _handle(msg): + method = msg.get("method") + mid = msg.get("id") + if method == "initialize": + return { + "jsonrpc": "2.0", "id": mid, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "e2e-downstream", "version": "0"}, + }, + } + if method == "notifications/initialized": + return None + if method == "tools/list": + return {"jsonrpc": "2.0", "id": mid, "result": {"tools": _tool_defs()}} + if method == "tools/call": + params = msg.get("params", {}) + tool = params.get("name", "") + args = params.get("arguments", {}) or {} + _tripwire(tool) + # A metered downstream echoes the declared cost so the gateway meters it; a plain + # read/write returns a deterministic stub. Either way the body is one text block. + body = {"tool": tool, "ok": True} + if "_auths_cost_cents" in args: + body["cost_cents"] = args["_auths_cost_cents"] + return { + "jsonrpc": "2.0", "id": mid, + "result": {"content": [{"type": "text", "text": json.dumps(body)}]}, + } + if mid is not None: + return { + "jsonrpc": "2.0", "id": mid, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + return None + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + reply = _handle(msg) + if reply is not None: + sys.stdout.write(json.dumps(reply) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/test_anchor_duplicity.py b/tests/e2e/test_anchor_duplicity.py new file mode 100644 index 000000000..3e19bc441 --- /dev/null +++ b/tests/e2e/test_anchor_duplicity.py @@ -0,0 +1,315 @@ +"""E2E: the witness-network duplicity funnel. + +These drive the shipped binaries the way an operator and a counterparty do: + +* the anchor role refuses to serve against an unsynced registry (the readiness + gate) rather than booting healthy and then rejecting every submission; +* `auths anchor verify` re-checks a real, self-contained duplicity proof offline + — no witness contacted — and rejects a tampered copy (the regulator's command); +* the watcher pushes a withholding alert to a configured webhook the moment a + watched seed goes dark, instead of only writing a log line. + +The proofs here are signed with a Python-held Ed25519 key against the exact +`party_signing_bytes` canonicalization the node commits to, so what the CLI +verifies is a genuine cryptographic contradiction, not a fixture blob. +""" + +import json +import os +import socket +import subprocess +import threading +import time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +crypto = pytest.importorskip("cryptography") +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +def _find_binary(env_var: str, name: str): + if path := os.environ.get(env_var): + p = Path(path) + if p.exists(): + return p + workspace_root = Path(__file__).resolve().parent.parent.parent + for profile in ("debug", "release"): + cand = workspace_root / "target" / profile / name + if cand.exists(): + return cand + return None + + +@pytest.fixture(scope="session") +def auths_bin(): + b = _find_binary("AUTHS_BIN", "auths") + if b is None: + pytest.skip("auths binary not built (set AUTHS_BIN or build with cargo)") + return b + + +@pytest.fixture(scope="session") +def witness_node_bin(): + b = _find_binary("WITNESS_NODE_BIN", "witness-node") + if b is None: + pytest.skip("witness-node binary not built (set WITNESS_NODE_BIN or build with cargo)") + return b + + +@pytest.fixture(scope="session") +def monitor_bin(): + b = _find_binary("MONITOR_BIN", "auths-monitor") + if b is None: + pytest.skip("auths-monitor binary not built (set MONITOR_BIN or build with cargo)") + return b + + +# --------------------------------------------------------------------------- +# Anchor signing that mirrors the node's canonical party message +# --------------------------------------------------------------------------- + +SEED_HEX = "ab" * 32 +SAID = "EWitSet" +THRESHOLD = 1 + + +def _party_signing_bytes(seed_hex, index, head_hex, cumulative, ts, said, threshold): + """The RFC-8785 canonical bytes the party signs — sorted keys, compact.""" + message = { + "v": "auths-anchor/party/v1", + "seedId": seed_hex, + "index": index, + "head": head_hex, + "cumulative": str(cumulative), + "ts": ts, + "witnessSet": {"said": said, "threshold": threshold}, + } + return json.dumps(message, sort_keys=True, separators=(",", ":")).encode() + + +def _wire_anchor(sk, index, head_hex, cumulative, when, *, signature=None): + ts = int(when.timestamp()) + if signature is None: + signature = sk.sign( + _party_signing_bytes(SEED_HEX, index, head_hex, cumulative, ts, SAID, THRESHOLD) + ) + pub = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + return { + "seed_id": SEED_HEX, + "index": index, + "head": head_hex, + "cumulative": cumulative, + "timestamp": when.strftime("%Y-%m-%dT%H:%M:%SZ"), + "witness_set": {"said": SAID, "threshold": THRESHOLD}, + "sig_party": { + "curve": "ed25519", + "public_key": pub.hex(), + "signature": signature.hex(), + }, + } + + +def _duplicity_proof(): + """A genuine same-party fork at one (seed, index): two heads, one key.""" + sk = Ed25519PrivateKey.from_private_bytes(bytes([9]) * 32) + when = datetime(2024, 1, 1, tzinfo=timezone.utc) + anchor_a = _wire_anchor(sk, 1, "01" * 32, 100, when) + anchor_b = _wire_anchor(sk, 1, "02" * 32, 100, when) + return { + "seed_id": SEED_HEX, + "index": 1, + "anchor_a": anchor_a, + "anchor_b": anchor_b, + } + + +def _run_auths(auths_bin, args, tmp_path, timeout=30): + env = { + **os.environ, + "HOME": str(tmp_path), + "AUTHS_HOME": str(tmp_path / ".auths"), + "NO_COLOR": "1", + } + return subprocess.run( + [str(auths_bin)] + args, + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + + +# --------------------------------------------------------------------------- +# 1. Readiness gate: an unsynced registry refuses to serve +# --------------------------------------------------------------------------- + + +def test_empty_registry_node_reports_not_ready(witness_node_bin, tmp_path): + empty_registry = tmp_path / "registry" + empty_registry.mkdir() # exists, but is not a synced git repo + data_dir = tmp_path / "wdata" + + env = {**os.environ, "WITNESS_SEED": "11" * 32} + result = subprocess.run( + [ + str(witness_node_bin), + "serve", + "--roles", + "anchor", + "--data-dir", + str(data_dir), + "--registry", + str(empty_registry), + "--witness-name", + "e2e-w1", + ], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + assert result.returncode != 0, "an unsynced registry must not serve" + assert "no synced registry" in result.stderr, result.stderr + + +# --------------------------------------------------------------------------- +# 2. The regulator's command: verify a proof offline, reject a tampered one +# --------------------------------------------------------------------------- + + +def test_cli_verifies_proof_and_rejects_tamper(auths_bin, tmp_path): + proof = _duplicity_proof() + proof_path = tmp_path / "duplicity-proof.json" + proof_path.write_text(json.dumps(proof)) + + ok = _run_auths(auths_bin, ["anchor", "verify", "--proof", str(proof_path)], tmp_path) + assert ok.returncode == 0, f"stdout={ok.stdout}\nstderr={ok.stderr}" + assert "DUPLICITY PROVEN" in ok.stdout, ok.stdout + + tampered = _duplicity_proof() + head = bytearray.fromhex(tampered["anchor_b"]["head"]) + head[0] ^= 0xFF + tampered["anchor_b"]["head"] = head.hex() + tampered_path = tmp_path / "tampered-proof.json" + tampered_path.write_text(json.dumps(tampered)) + + bad = _run_auths(auths_bin, ["anchor", "verify", "--proof", str(tampered_path)], tmp_path) + assert bad.returncode != 0, "a tampered proof must not verify" + assert "INVALID duplicity proof" in bad.stderr, bad.stderr + + +# --------------------------------------------------------------------------- +# 3. The watcher pushes a withholding alert to a webhook when a seed goes dark +# --------------------------------------------------------------------------- + + +def _free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _serve(handler_cls, port): + server = HTTPServer(("127.0.0.1", port), handler_cls) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + + +def test_watcher_pushes_on_dark_seed(monitor_bin, tmp_path): + # A witness that serves one long-stale anchor for the watched seed. The + # withholding path never verifies the party signature, so a placeholder one + # is enough to exercise the gap detection. + stale = datetime(2000, 1, 1, tzinfo=timezone.utc) + dark_anchor = { + "seed_id": SEED_HEX, + "index": 1, + "head": "01" * 32, + "cumulative": 100, + "timestamp": stale.strftime("%Y-%m-%dT%H:%M:%SZ"), + "witness_set": {"said": SAID, "threshold": THRESHOLD}, + "sig_party": {"curve": "ed25519", "public_key": "00" * 32, "signature": "00" * 64}, + } + + class Witness(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path == f"/v1/anchor/{SEED_HEX}": + body = json.dumps({"anchor": dark_anchor}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args): + pass + + received = [] + received_lock = threading.Lock() + + class Sink(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + length = int(self.headers.get("content-length", 0)) + payload = self.rfile.read(length) + with received_lock: + received.append(payload) + self.send_response(200) + self.end_headers() + + def log_message(self, *args): + pass + + witness_port = _free_port() + sink_port = _free_port() + witness = _serve(Witness, witness_port) + sink = _serve(Sink, sink_port) + witness_url = f"http://127.0.0.1:{witness_port}" + sink_url = f"http://127.0.0.1:{sink_port}/" + + env = { + **os.environ, + "AUTHS_REGISTRY_URL": witness_url, # /v1/log/checkpoint 404s → cycle errors, loop continues + "AUTHS_MONITOR_INTERVAL_SECS": "1", + "AUTHS_MONITOR_STATE_PATH": str(tmp_path / "monitor_state.json"), + "AUTHS_LOG_PUBLIC_KEY": "00" * 32, + "AUTHS_WATCH_WITNESSES": witness_url, + "AUTHS_WATCH_SEEDS": SEED_HEX, + "AUTHS_WATCH_GAP_SECS": "0", + "AUTHS_ALERT_WEBHOOK": sink_url, + } + + proc = subprocess.Popen( + [str(monitor_bin)], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.time() + 15 + alert = None + while time.time() < deadline and alert is None: + with received_lock: + for raw in received: + event = json.loads(raw) + if event.get("kind") == "withholding": + alert = event + break + time.sleep(0.2) + finally: + proc.terminate() + proc.wait(timeout=10) + witness.shutdown() + sink.shutdown() + + assert alert is not None, "the watcher never pushed a withholding alert" + assert alert["event"]["seed_id"] == SEED_HEX + assert alert["event"]["gap_seconds"] > 0 diff --git a/tests/e2e/test_git_signing.py b/tests/e2e/test_git_signing.py index d95086d3e..873c6f829 100644 --- a/tests/e2e/test_git_signing.py +++ b/tests/e2e/test_git_signing.py @@ -1,11 +1,41 @@ """E2E tests for git commit signing and verification (KEL-native).""" +import os import shutil import pytest from helpers.cli import export_identity_bundle, run_auths, run_git -from helpers.git import make_commit +from helpers.git import init_git_repo, make_commit + + +def _headless_repo_root_env(tmp_path, auths_bin, *, registry_name="registry"): + """Env whose storage root is AUTHS_REPO and whose HOME has no ~/.auths. + + This deliberately diverges the storage root from HOME, so `verify` must read + the *same* root `init`/`sign` wrote to (not `~/.auths`) for a self-signed + commit to verify. + """ + registry = tmp_path / registry_name + registry.mkdir() + home = tmp_path / f"home-{registry_name}" + home.mkdir() + keychain_file = tmp_path / f"keys-{registry_name}.enc" + bin_dir = str(auths_bin.parent) + return { + "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", + "HOME": str(home), + "AUTHS_REPO": str(registry), + "AUTHS_KEYCHAIN_BACKEND": "file", + "AUTHS_KEYCHAIN_FILE": str(keychain_file), + "AUTHS_PASSPHRASE": "TestPassphrase!42", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Test User", + "GIT_COMMITTER_NAME": "Test User", + "GIT_AUTHOR_EMAIL": "test@auths.dev", + "GIT_COMMITTER_EMAIL": "test@auths.dev", + "NO_COLOR": "1", + } def _sign_head(auths_bin, repo, env) -> str: @@ -85,3 +115,111 @@ def test_auths_sign_binary_direct( # Should produce SSHSIG output. assert "SIGNATURE" in result.stdout or result.returncode == 0 + + def test_headless_sign_then_verify_passes(self, auths_bin, tmp_path): + # The headless success criterion: with the storage root set via AUTHS_REPO + # (and HOME's ~/.auths absent), a freshly-signed commit must verify — verify + # reads the same root sign wrote to, not the default home. + env = _headless_repo_root_env(tmp_path, auths_bin) + repo = tmp_path / "repo" + repo.mkdir() + init_git_repo(repo, env) + + run_auths( + auths_bin, + ["init", "--profile", "developer", "--non-interactive", "--git-scope", "local"], + cwd=repo, + env=env, + ).assert_success() + + make_commit(repo, "signed via repo root", env) + run_auths(auths_bin, ["sign", "HEAD"], cwd=repo, env=env).assert_success() + sha = run_git(["rev-parse", "HEAD"], cwd=repo, env=env).stdout.strip() + + result = run_auths(auths_bin, ["verify", sha], cwd=repo, env=env) + result.assert_success() + assert "verified" in result.stdout.lower() + + def test_unsigned_commit_advice_is_kel_native(self, auths_bin, init_identity, git_repo): + # Verifying an unsigned commit must speak the KEL-native flow, never the + # retired SSH/GPG advice that produces a commit auths still rejects. + sha = make_commit(git_repo, "unsigned commit", init_identity) + result = run_auths(auths_bin, ["verify", sha], cwd=git_repo, env=init_identity) + combined = (result.stdout + result.stderr).lower() + assert "git commit -s" not in combined + assert "allowed signers" not in combined + assert "auths sign" in combined or "trailer" in combined + + show = run_auths(auths_bin, ["error", "show", "AUTHS-E2101"], env=init_identity) + if show.returncode == 0: + out = show.stdout.lower() + assert "git commit -s" not in out + assert "auths " in out + + def test_sign_missing_alias_names_key_list( + self, auths_sign_bin, init_identity, tmp_path + ): + # A nonexistent alias is not a broken keychain: the error must name the + # missing-key code and `auths key list`, not the keychain-unavailable code. + data_file = tmp_path / "message.txt" + data_file.write_text("test message") + result = run_auths( + auths_sign_bin, + ["-Y", "sign", "-n", "git", "-f", "auths:nonexistent-key", str(data_file)], + env=init_identity, + ) + assert result.returncode != 0 + combined = result.stdout + result.stderr + assert "AUTHS-E5911" in combined, combined + assert "auths key list" in combined, combined + assert "AUTHS-E5909" not in combined, combined + + def test_verify_absent_teammate_kel_names_fetch(self, auths_bin, tmp_path): + # A commit whose signer KEL is not in the local registry (and no bundle) is + # the common teammate case: the error must carry the code and the fetch + # remedy, not a bare, uncoded string. + signer_env = _headless_repo_root_env(tmp_path, auths_bin, registry_name="signer") + repo = tmp_path / "repo" + repo.mkdir() + init_git_repo(repo, signer_env) + run_auths( + auths_bin, + ["init", "--profile", "developer", "--non-interactive", "--git-scope", "local"], + cwd=repo, + env=signer_env, + ).assert_success() + make_commit(repo, "signed by a teammate", signer_env) + run_auths(auths_bin, ["sign", "HEAD"], cwd=repo, env=signer_env).assert_success() + sha = run_git(["rev-parse", "HEAD"], cwd=repo, env=signer_env).stdout.strip() + + # Verify against a DIFFERENT, empty storage root: the signer's KEL is absent. + verifier_env = _headless_repo_root_env(tmp_path, auths_bin, registry_name="verifier") + result = run_auths(auths_bin, ["verify", sha], cwd=repo, env=verifier_env) + assert result.returncode != 0 + combined = result.stdout + result.stderr + assert "AUTHS-E6301" in combined, combined + assert "git fetch" in combined, combined + + def test_bad_verify_option_has_no_orphan_code( + self, auths_sign_bin, init_identity, tmp_path + ): + # The disallowed -O guard once tagged a code that resolved to "unknown"; + # rejecting a bad -O must no longer print that orphan tag. + data_file = tmp_path / "message.txt" + data_file.write_text("test message") + result = run_auths( + auths_sign_bin, + [ + "-Y", + "verify", + "-n", + "git", + "-f", + "auths:main", + "-O", + "bogus-option", + str(data_file), + ], + env=init_identity, + ) + assert "AUTHS-E0031" not in (result.stdout + result.stderr) diff --git a/tests/e2e/test_identity_lifecycle.py b/tests/e2e/test_identity_lifecycle.py index d79fb8786..6fec0d0dc 100644 --- a/tests/e2e/test_identity_lifecycle.py +++ b/tests/e2e/test_identity_lifecycle.py @@ -1,6 +1,7 @@ """E2E tests for the core identity lifecycle.""" import json +import subprocess import pytest @@ -22,20 +23,57 @@ def test_init_developer_profile(self, auths_bin, isolated_env): # status may or may not support --json yet assert status.returncode == 0 - @pytest.mark.xfail( - reason="`init --profile ci` (Memory keychain) intermittently fails with " - "AUTHS-E4105 'invalid KERI version string: KERI10JSON' — a flaky CLI " - "serialization bug tracked in issue #246 (not an e2e-test issue).", - strict=False, - ) def test_init_ci_profile(self, auths_bin, isolated_env): + # Unset AUTHS_PASSPHRASE so the CI profile's *generated* passphrase is the + # one exercised — it must satisfy the same strength policy the CLI enforces, + # or inception errors before it starts. + env = dict(isolated_env) + env.pop("AUTHS_PASSPHRASE", None) result = run_auths( auths_bin, ["init", "--profile", "ci", "--non-interactive"], - env=isolated_env, + env=env, ) result.assert_success() + def test_headless_init_does_not_hang(self, auths_bin, isolated_env): + # With no AUTHS_KEYCHAIN_BACKEND set, the platform default wins. On a machine + # with a hardware keychain this must never block on a Touch ID prompt that + # never arrives headless — it either selects the configured file backend and + # succeeds, or fails fast with a coded, actionable error. + env = dict(isolated_env) + env.pop("AUTHS_KEYCHAIN_BACKEND", None) + try: + result = run_auths( + auths_bin, + ["init", "--non-interactive"], + env=env, + timeout=15, + stdin_data="", + ) + except subprocess.TimeoutExpired: + pytest.fail("headless init hung waiting for an interactive keychain prompt") + if result.returncode != 0: + assert "AUTHS-E4203" in result.stderr, result.stderr + assert "AUTHS_KEYCHAIN_BACKEND=file" in result.stderr, result.stderr + + def test_weak_passphrase_shows_code(self, auths_bin, isolated_env): + # A weak env passphrase must fail with the typed, coded error — not an + # untyped string — and the bare-code lookup must resolve offline. + env = dict(isolated_env) + env["AUTHS_PASSPHRASE"] = "weak" + result = run_auths( + auths_bin, + ["init", "--profile", "developer", "--non-interactive"], + env=env, + ) + assert result.returncode != 0 + assert "[AUTHS-E5008]" in result.stderr, result.stderr + + show = run_auths(auths_bin, ["error", "show", "E5008"], env=env) + show.assert_success() + assert "## Suggestion" in show.stdout, show.stdout + def test_init_agent_profile_is_retired(self, auths_bin, isolated_env): # Standalone agent initialization is retired: an agent is now a KERI # delegated identifier created with `auths id agent add` after a root diff --git a/tests/e2e/test_mcp_gate_bounds.py b/tests/e2e/test_mcp_gate_bounds.py new file mode 100644 index 000000000..2d85b941c --- /dev/null +++ b/tests/e2e/test_mcp_gate_bounds.py @@ -0,0 +1,348 @@ +"""E2E: the live `wrap` gateway enforces each bound BEFORE the downstream is touched. + +Why this exists (what a user expects): a bounded agent cannot exceed its bounds. The +gateway wraps a real downstream MCP server; every `tools/call` passes the per-call gate +(scope / budget / ttl / revocation) and a REFUSED call never reaches the downstream. The +downstream here is a tripwire — it appends one line per call it actually receives — so the +tripwire line count is the ground truth of what was forwarded. + +Enforced bounds: + --ttl is a real expiry seal — an expired agent is refused before the downstream. + a refusal carries a structured, re-checkable verdict object in error.data. + fs.read admits the whole read family; write_file is out of scope. + the budget boundary is strictly-over (exact-exhaust allowed, +1 refused). + a mid-session revocation propagates within the recheck SLA. + the spend-log path is announced and can be pinned with --spend-log. + a second wrap on the same key file is idempotent, not a bare error. + exact-token scope — every near-miss of `read_file` is refused. +""" + +import json +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +HELPERS = Path(__file__).resolve().parent / "helpers" +DOWNSTREAM = HELPERS / "mcp_downstream.py" + + +def _find_binary(env_var: str, name: str): + if path := os.environ.get(env_var): + p = Path(path) + if p.exists(): + return p + if found := shutil.which(name): + return Path(found) + workspace_root = Path(__file__).resolve().parent.parent.parent + for profile in ("debug", "release"): + cand = workspace_root / "target" / profile / name + if cand.exists(): + return cand + return None + + +@pytest.fixture(scope="module") +def gateway_bin(): + b = _find_binary("GATEWAY_BIN", "auths-mcp-gateway") + if b is None: + pytest.skip("auths-mcp-gateway binary not built") + return b + + +@pytest.fixture(scope="module") +def auths_bin(): + b = _find_binary("AUTHS_BIN", "auths") + if b is None: + pytest.skip("auths binary not built") + return b + + +def _lab_env(tmp_path: Path, tripwire: Path, keyfile: Path | None = None) -> dict: + lab = tmp_path / "lab" + lab.mkdir(exist_ok=True) + gateway = _find_binary("GATEWAY_BIN", "auths-mcp-gateway") + bindir = gateway.parent if gateway else None + env = { + **os.environ, + "HOME": str(tmp_path), + "LAB_DIR": str(lab), + "AUTHS_MCP_LIVE_DIR": str(lab), + "AUTHS_HOME": str(lab / "registry"), + "AUTHS_REPO": str(lab / "registry"), + "AUTHS_KEYCHAIN_BACKEND": "file", + "AUTHS_KEYCHAIN_FILE": str(keyfile or (lab / "keys.enc")), + "AUTHS_PASSPHRASE": "TestPassphrase!42", + "AUTHS_TRIPWIRE": str(tripwire), + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "e2e", "GIT_AUTHOR_EMAIL": "e2e@auths.dev", + "GIT_COMMITTER_NAME": "e2e", "GIT_COMMITTER_EMAIL": "e2e@auths.dev", + "NO_COLOR": "1", + } + if bindir: + env.setdefault("AUTHS_BIN", str(bindir / "auths")) + env.setdefault("AUTHS_SIGN", str(bindir / "auths-sign")) + return env + + +class Wrap: + """An MCP stdio client driving a live `auths-mcp-gateway wrap` session.""" + + def __init__(self, gateway_bin, wrap_args, env): + argv = [str(gateway_bin), "wrap", *wrap_args, "--", + "python3", str(DOWNSTREAM)] + self.proc = subprocess.Popen( + argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, env=env, + ) + self._id = 0 + self._send({"jsonrpc": "2.0", "id": self._next(), "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "e2e", "version": "0"}}}) + self._recv() + self._send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + def _next(self): + self._id += 1 + return self._id + + def _send(self, msg): + self.proc.stdin.write(json.dumps(msg) + "\n") + self.proc.stdin.flush() + + def _recv(self, timeout=60): + while True: + line = self.proc.stdout.readline() + if not line: + raise AssertionError("gateway closed the stream") + line = line.strip() + if not line: + continue + msg = json.loads(line) + if "id" in msg: + return msg + + def call(self, tool, args=None): + self._send({"jsonrpc": "2.0", "id": self._next(), "method": "tools/call", + "params": {"name": tool, "arguments": args or {}}}) + return self._recv() + + def close(self): + try: + self.proc.terminate() + _, err = self.proc.communicate(timeout=15) + return err or "" + except Exception: + self.proc.kill() + return "" + + +def _tripwire_count(tripwire: Path) -> int: + if not tripwire.exists(): + return 0 + return len([ln for ln in tripwire.read_text().splitlines() if ln.strip()]) + + +@pytest.mark.slow +@pytest.mark.requires_binary +class TestGateBounds: + def test_expired_agent_call_refused_before_downstream(self, gateway_bin, tmp_path): + # A 1s TTL, an 8s pre-call delay, then a call that must be + # refused `agent-expired` with the downstream NEVER run. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "1s"], env) + try: + time.sleep(8) + resp = w.call("read_text_file", {"path": "/etc/hosts"}) + assert "error" in resp, resp + assert resp["error"]["code"] == -32600, resp + assert resp["error"].get("data", {}).get("code") == "agent-expired", resp + assert _tripwire_count(tripwire) == 0, "downstream ran on an expired grant" + finally: + w.close() + + def test_ttl_control_allows_the_same_call(self, gateway_bin, tmp_path): + # Control: a generous TTL allows the same call and the tripwire fires once. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + try: + resp = w.call("read_text_file", {"path": "/etc/hosts"}) + assert "error" not in resp, resp + assert _tripwire_count(tripwire) == 1 + finally: + w.close() + + def test_refusal_carries_verdict_data(self, gateway_bin, tmp_path): + # An out-of-scope refusal co-delivers a structured, re-checkable verdict object. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + try: + resp = w.call("write_file", {"path": "/tmp/x", "content": "y"}) + assert "error" in resp, resp + assert resp["error"]["data"]["code"] == "outside-agent-scope", resp + assert resp["error"]["data"]["refused_before_downstream"] is True + assert _tripwire_count(tripwire) == 0 + finally: + w.close() + + def test_fs_read_admits_the_read_family(self, gateway_bin, tmp_path): + # fs.read covers the whole read family; write_file is out of scope. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + try: + ok = w.call("read_text_file", {"path": "/etc/hosts"}) + assert "error" not in ok, ok + refused = w.call("write_file", {"path": "/tmp/x", "content": "y"}) + assert "error" in refused, refused + assert refused["error"]["data"]["code"] == "outside-agent-scope" + assert _tripwire_count(tripwire) == 1, "only the read call reaches the downstream" + finally: + w.close() + + def test_budget_boundary_off_by_one(self, gateway_bin, tmp_path): + # 5c metered calls against a $0.10 cap: calls 0/1 allowed (cumulative 10c == cap), + # call 2 refused `usage-cap-exceeded`; the downstream ran exactly twice. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$0.10", "--ttl", "30m"], env) + try: + meta = {"path": "/etc/hosts", "_auths_cost_cents": 5, "_auths_rail": "x402"} + assert "error" not in w.call("read_text_file", dict(meta)), "call 0" + assert "error" not in w.call("read_text_file", dict(meta)), "call 1" + over = w.call("read_text_file", dict(meta)) + assert "error" in over, over + assert over["error"]["data"]["code"] == "usage-cap-exceeded", over + assert _tripwire_count(tripwire) == 2 + finally: + w.close() + + def test_scope_near_misses_all_refused(self, gateway_bin, tmp_path): + # Only the exact `read_file` (last) fires the + # tripwire; every near-miss is refused before the downstream is touched. + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + near_misses = [ + "write_file", # different family + "Read_File", # case + "read_file_secret", # suffix + "xread_file", # prefix + "reаd_file", # Cyrillic 'а' + "../read_file", # traversal + ] + try: + for tool in near_misses: + resp = w.call(tool, {"path": "/etc/hosts"}) + assert "error" in resp, f"{tool} should be refused: {resp}" + assert resp["error"]["data"]["refused_before_downstream"] is True + # Only the exact real tool is admitted. + ok = w.call("read_file", {"path": "/etc/hosts"}) + assert "error" not in ok, ok + assert _tripwire_count(tripwire) == 1, "only exact `read_file` forwarded" + finally: + w.close() + + def test_spend_log_path_is_announced_and_stable(self, gateway_bin, tmp_path): + # --spend-log pins the log dir and it is announced; a default run flags ephemeral. + tripwire = tmp_path / "tripwire.log" + stable = tmp_path / "receipts" + env = _lab_env(tmp_path, tripwire) + # Pin the dir explicitly; drop the LAB overrides so --spend-log is the resolver. + env.pop("AUTHS_MCP_LIVE_DIR", None) + env.pop("LAB_DIR", None) + w = Wrap(gateway_bin, + ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m", + "--spend-log", str(stable)], env) + try: + w.call("read_text_file", {"path": "/etc/hosts"}) + err = w.close() + finally: + pass + assert f"spend-log: {stable}" in err, err + # The rotated log lands under the pinned dir. + assert (stable / "registry").exists() + + def test_default_spend_log_is_flagged_ephemeral(self, gateway_bin, tmp_path): + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + env.pop("AUTHS_MCP_LIVE_DIR", None) + env.pop("LAB_DIR", None) + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + w.call("read_text_file", {"path": "/etc/hosts"}) + err = w.close() + assert "ephemeral" in err, err + + def test_second_wrap_same_keyfile_is_idempotent(self, gateway_bin, tmp_path): + # A second wrap against the same key file + live dir resumes the SAME agent DID, + # never `an agent key already exists under alias 'agent'`. + tripwire = tmp_path / "tripwire.log" + keyfile = tmp_path / "shared-keys.enc" + env = _lab_env(tmp_path, tripwire, keyfile=keyfile) + + def agent_did(err_text): + m = re.search(r"agent=(did:keri:\S+)", err_text) + return m.group(1) if m else None + + w1 = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + w1.call("read_text_file", {"path": "/etc/hosts"}) + err1 = w1.close() + did1 = agent_did(err1) + assert did1, err1 + + w2 = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + w2.call("read_text_file", {"path": "/etc/hosts"}) + err2 = w2.close() + assert "already exists under alias" not in err2, err2 + did2 = agent_did(err2) + assert did2 == did1, f"agent DID must be stable: {did1} vs {did2}" + + def test_revocation_propagates_within_sla(self, gateway_bin, auths_bin, tmp_path): + # A mid-session revoke is observed within the recheck SLA (not only on restart). + tripwire = tmp_path / "tripwire.log" + env = _lab_env(tmp_path, tripwire) + env["AUTHS_MCP_REVOCATION_RECHECK_SECS"] = "1" + registry = env["AUTHS_HOME"] + w = Wrap(gateway_bin, ["--scope", "fs.read", "--budget", "$5", "--ttl", "30m"], env) + try: + # One allowed call to establish the agent, then read its DID from stderr later. + assert "error" not in w.call("read_text_file", {"path": "/etc/hosts"}) + before = _tripwire_count(tripwire) + # Resolve the agent DID from the live registry and revoke it out-of-band. + show = subprocess.run( + [str(auths_bin), "--repo", registry, "--json", "id", "show"], + capture_output=True, text=True, env=env, timeout=60, + ) + m = re.search(r"did:keri:[A-Za-z0-9_-]+", show.stdout) + # Revoke by resolving the agent from the gateway's own verify-spend-cmd banner if the + # root show is ambiguous; the revoke targets the delegated agent. + agent = None + # The gateway printed `agent=` on stderr; but stderr is drained on close, so + # instead revoke every non-root agent the registry lists. + listing = subprocess.run( + [str(auths_bin), "--repo", registry, "--json", "id", "agent", "list"], + capture_output=True, text=True, env=env, timeout=60, + ) + for did in re.findall(r"did:keri:[A-Za-z0-9_-]+", listing.stdout): + agent = did + if agent is None: + pytest.skip("could not resolve the delegated agent DID to revoke") + subprocess.run( + [str(auths_bin), "--repo", registry, "--json", + "id", "agent", "revoke", agent, "--key", "root"], + capture_output=True, text=True, env=env, timeout=60, + ) + time.sleep(2) # past the 1s recheck SLA + after = w.call("read_text_file", {"path": "/etc/hosts"}) + assert "error" in after, "a revoked delegation must be refused" + assert after["error"]["data"]["code"] in ("revoked", "proof-unauthentic"), after + assert _tripwire_count(tripwire) == before, "downstream ran after revocation" + finally: + w.close() diff --git a/tests/e2e/test_mcp_spend_log_audit.py b/tests/e2e/test_mcp_spend_log_audit.py index 27847a6d2..cb8c1b299 100644 --- a/tests/e2e/test_mcp_spend_log_audit.py +++ b/tests/e2e/test_mcp_spend_log_audit.py @@ -100,16 +100,32 @@ def _replay(gateway_bin, transcript, tmp_path): return stdout, args -def _verify_spend(gateway_bin, log, args): - out = subprocess.run( +def _verify_run(gateway_bin, log, args, extra=()): + return subprocess.run( [str(gateway_bin), "verify-spend", "--log", str(log), "--registry", args["registry"], - "--agent", args["agent"], "--root", args["root"]], + "--agent", args["agent"], "--root", args["root"], *extra], capture_output=True, text=True, env=args["env"], timeout=120, ) + + +def _verify_spend(gateway_bin, log, args): + out = _verify_run(gateway_bin, log, args) return out.stdout + out.stderr +def _read_records(log_path: Path): + """The one-period-file record lines this hermetic replay wrote (JSONL).""" + return [ln for ln in Path(log_path).read_text().splitlines() if ln.strip()] + + +def _head_of(gateway_bin, log, args): + """The checkpoint head (`binding=`) the auditor pins — the anti-rollback anchor.""" + text = _verify_spend(gateway_bin, log, args) + m = re.search(r"binding=(\S+)", text) + return m.group(1) if m else None + + @pytest.mark.slow @pytest.mark.requires_binary class TestSpendLogAuditAcrossRotation: @@ -118,7 +134,8 @@ def test_replay_self_audit_finds_the_rotated_log(self, gateway_bin, transcript, never 'SKIPPED — no spend log to audit' (the exact regression).""" stdout, args = _replay(gateway_bin, transcript, tmp_path) assert "audit: SKIPPED" not in stdout, f"audit skipped a rotated log:\n{stdout}" - assert "audit: consistent" in stdout, f"self-audit not consistent:\n{stdout}" + # The honest offline verdict is `self-consistent` (completeness is unprovable offline). + assert "audit: self-consistent" in stdout, f"self-audit not consistent:\n{stdout}" assert args is not None, f"no audit-cmd emitted:\n{stdout}" # The emitted log path is the rotated layout: a per-delegation directory. assert f"spend-log{os.sep}" in args["log"] and args["log"].endswith(".jsonl") @@ -141,7 +158,8 @@ def test_verify_spend_walks_a_multi_period_rotated_log(self, gateway_bin, transc log_file.unlink() # remove the original single-period file out = _verify_spend(gateway_bin, spend_dir, args) - assert "verify-spend: consistent" in out, f"rotated multi-file audit not consistent:\n{out}" + assert "verify-spend: self-consistent" in out, \ + f"rotated multi-file audit not consistent:\n{out}" def test_tampering_is_caught_across_the_rotation_boundary(self, gateway_bin, transcript, tmp_path): """A dropped record in an EARLIER period file breaks the signed back-link @@ -160,6 +178,232 @@ def test_tampering_is_caught_across_the_rotation_boundary(self, gateway_bin, tra log_file.unlink() out = _verify_spend(gateway_bin, spend_dir, args) - assert "verify-spend: consistent" not in out, f"tampered rotated log passed:\n{out}" - assert re.search(r"dropped-call|budget-mismatch|tampered-proof", out), \ + assert "verify-spend: self-consistent" not in out, f"tampered rotated log passed:\n{out}" + assert re.search(r"chain-break|budget-mismatch|tampered-proof", out), \ f"tamper not caught with a distinct verdict:\n{out}" + + +@pytest.mark.slow +@pytest.mark.requires_binary +class TestSpendLogAuditQuality: + """The audit says exactly what it proved — no bare `consistent`, distinct break + kinds, a portable verdict object, and an unverified operator block.""" + + def test_tail_truncation_reads_self_consistent_with_caveat(self, gateway_bin, transcript, tmp_path): + # Dropping the LAST record of a $0 log is invisible to the offline + # audit (completeness is unprovable offline) — but the verdict must SAY so and the pinned + # head must change, never a bare `consistent`. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + log_file = Path(args["log"]) + full_head = _head_of(gateway_bin, log_file, args) + records = _read_records(log_file) + assert len(records) >= 2 + log_file.write_text("\n".join(records[:-1]) + "\n") + + out = _verify_run(gateway_bin, log_file, args) + text = out.stdout + out.stderr + assert out.returncode == 0, text # truncation of a $0 tail is undetectable offline + assert "self-consistent" in text and "consistent —" not in text.replace("self-consistent", ""), text + assert "completeness unproven" in text, text + truncated_head = _head_of(gateway_bin, log_file, args) + assert truncated_head and truncated_head != full_head, "the pinned head must change" + + def test_verify_spend_json_is_a_portable_object(self, gateway_bin, transcript, tmp_path): + # `--json` emits one portable `audit/v1` line, not scraped text. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + out = _verify_run(gateway_bin, Path(args["log"]), args, extra=["--json"]) + line = next(ln for ln in out.stdout.splitlines() if ln.strip().startswith("{")) + report = json.loads(line) + assert report["version"] == "audit/v1", report + assert report["code"] == "self-consistent", report + assert report["checkpoint"]["binding"], report + + def test_verdict_word_is_not_doubled(self, gateway_bin, transcript, tmp_path): + # The clean line is exactly `verify-spend: self-consistent — …`, the word once. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + out = _verify_run(gateway_bin, Path(args["log"]), args) + line = next(ln for ln in (out.stdout + out.stderr).splitlines() + if ln.startswith("verify-spend:")) + assert re.match(r"^verify-spend: self-consistent — ", line), line + assert "self-consistent — self-consistent" not in line, line + + def test_structural_breaks_are_distinguished(self, gateway_bin, transcript, tmp_path): + # Delete-middle → Missing, swap → OutOfOrder, + # duplicate → Duplicate, each naming an index. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + log_file = Path(args["log"]) + base = _read_records(log_file) + assert len(base) >= 4, "need >=4 records for delete-middle/swap/duplicate" + + # delete the middle record. + dropped = base[:1] + base[2:] + log_file.write_text("\n".join(dropped) + "\n") + text = _verify_spend(gateway_bin, log_file, args) + assert "chain-break" in text and "Missing" in text, text + assert re.search(r"record \d+", text), text + + # swap two adjacent records. + swapped = base[:1] + [base[2], base[1]] + base[3:] + log_file.write_text("\n".join(swapped) + "\n") + text = _verify_spend(gateway_bin, log_file, args) + assert "chain-break" in text and "OutOfOrder" in text, text + + # duplicate a record. + dup = base[:2] + [base[1]] + base[2:] + log_file.write_text("\n".join(dup) + "\n") + text = _verify_spend(gateway_bin, log_file, args) + assert "chain-break" in text and "Duplicate" in text, text + + def test_receipt_display_edit_does_not_forge_a_verdict(self, gateway_bin, transcript, tmp_path): + # The operator block is `unverified_display`; editing its `tool` forges no + # verdict because the audit re-derives from signed material only. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + log_file = Path(args["log"]) + records = [json.loads(ln) for ln in _read_records(log_file)] + assert "unverified_display" in records[0], records[0].keys() + assert "receipt" not in records[0], "the wire must not carry a bare `receipt`" + records[0]["unverified_display"]["tool"] = "wire_money" + log_file.write_text("\n".join(json.dumps(r) for r in records) + "\n") + + out = _verify_run(gateway_bin, log_file, args, extra=["--json"]) + line = next(ln for ln in out.stdout.splitlines() if ln.strip().startswith("{")) + report = json.loads(line) + # The re-derived verdict reflects only signed material — the display edit changes nothing. + assert report["code"] == "self-consistent", report + + def test_emptied_log_is_not_a_clean_pass(self, gateway_bin, transcript, tmp_path): + # An emptied log re-derives records:0 with the completeness caveat, never a + # silent clean bill of health. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + log_file = Path(args["log"]) + log_file.write_text("") + out = _verify_run(gateway_bin, log_file, args, extra=["--json"]) + line = next(ln for ln in out.stdout.splitlines() if ln.strip().startswith("{")) + report = json.loads(line) + assert report["records"] == 0, report + assert report.get("completeness") == "unproven-offline" or report["consistent"], report + + def test_reformat_and_truncation_speak_not_serde(self, gateway_bin, transcript, tmp_path): + # `jq .` and a truncated tail SPEAK `malformed-log`, never serde noise + # and never `tampered-proof`. + stdout, args = _replay(gateway_bin, transcript, tmp_path) + assert args is not None, stdout + log_file = Path(args["log"]) + records = _read_records(log_file) + + # `jq .` pretty-prints each record across many lines. + pretty = "\n".join(json.dumps(json.loads(r), indent=2) for r in records) + log_file.write_text(pretty + "\n") + text = _verify_spend(gateway_bin, log_file, args) + assert "malformed-log" in text, text + assert "tampered-proof" not in text, text + + # A crash-truncated final record. + half = records[-1][: len(records[-1]) // 2] + log_file.write_text("\n".join(records[:-1] + [half]) + "\n") + text = _verify_spend(gateway_bin, log_file, args) + assert "malformed-log" in text and "truncated" in text, text + assert "tampered-proof" not in text, text + + +def _stripe_fixture(dir_path: Path, charge_id="ch_3MmlLrLkdIwHu7ix0snN0B15", cents=300): + body = { + "rail": "stripe", + "charge": {"id": charge_id, "object": "charge", "amount": cents, + "amount_captured": cents, "amount_refunded": 0, "currency": "usd", + "captured": True, "paid": True, "status": "succeeded", "livemode": False}, + } + dir_path.mkdir(parents=True, exist_ok=True) + (dir_path / "stripe-charge.json").write_text(json.dumps(body)) + return "stripe-charge.json" + + +def _replay_metered(gateway_bin, tmp_path): + """Replay a METERED transcript (a stripe fixture drives the cost) and return (stdout, args).""" + fixtures = tmp_path / "fixtures" + _stripe_fixture(fixtures) + t = { + "grant": {"scope": ["paid.call"], "budget": "$50.00", "ttl": "30m"}, + "calls": [ + {"tool": "paid_call", "rail": "stripe", + "response_fixture": "stripe-charge.json", "expect": "allowed"}, + ], + } + p = tmp_path / "metered.json" + p.write_text(json.dumps(t)) + lab = tmp_path / "lab" + lab.mkdir() + env = { + **os.environ, + "HOME": str(tmp_path), "LAB_DIR": str(lab), + "AUTHS_HOME": str(lab / "registry"), "AUTHS_REPO": str(lab / "registry"), + "AUTHS_KEYCHAIN_BACKEND": "file", "AUTHS_KEYCHAIN_FILE": str(lab / "keys.enc"), + "AUTHS_PASSPHRASE": "TestPassphrase!42", + "AUTHS_MCP_RAIL_FIXTURES": str(fixtures), + "AUTHS_MCP_TEST_MODE": "1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "e2e", "GIT_AUTHOR_EMAIL": "e2e@auths.dev", + "GIT_COMMITTER_NAME": "e2e", "GIT_COMMITTER_EMAIL": "e2e@auths.dev", + "NO_COLOR": "1", + } + out = subprocess.run( + [str(gateway_bin), "replay", "--transcript", str(p)], + capture_output=True, text=True, env=env, timeout=240, + ) + stdout = out.stdout + out.stderr + m = re.search(r"audit-cmd:\s*--log\s+(\S+)\s+--registry\s+(\S+)\s+--agent\s+(\S+)\s+--root\s+(\S+)", stdout) + if not m: + return stdout, None + return stdout, {"log": m.group(1), "registry": m.group(2), + "agent": m.group(3), "root": m.group(4), "env": env} + + +@pytest.mark.slow +@pytest.mark.requires_binary +class TestMeteredAudit: + def test_counterparty_rewrite_is_caught(self, gateway_bin, tmp_path): + # Rewrite the rail_response charge id (leaving the SIGNED + # Auths-Settle-Ref intact) — the audit catches `counterparty-mismatch`, exit non-zero. + stdout, args = _replay_metered(gateway_bin, tmp_path) + if args is None: + pytest.skip(f"metered replay produced no audit-cmd:\n{stdout}") + log_file = Path(args["log"]) + records = [json.loads(ln) for ln in _read_records(log_file)] + rewritten = False + for rec in records: + settle = rec.get("settlement", {}) + resp = settle.get("rail_response") + if resp: + text = bytes(resp).decode("utf-8", "replace") + if "ch_3Mml" in text: + text = text.replace("ch_3MmlLrLkdIwHu7ix0snN0B15", "ch_ATTACKERxxxxxxxxxxxx") + settle["rail_response"] = list(text.encode("utf-8")) + rewritten = True + if not rewritten: + pytest.skip("metered replay carried no re-writable rail response") + log_file.write_text("\n".join(json.dumps(r) for r in records) + "\n") + + out = _verify_run(gateway_bin, log_file, args) + text = out.stdout + out.stderr + assert out.returncode != 0, text + assert "counterparty-mismatch" in text, text + + def test_metered_replay_rederives_real_cents(self, gateway_bin, tmp_path): + # A metered replay re-derives its real (non-zero) total, never + # `budget-mismatch — re-derived 0c`. + stdout, args = _replay_metered(gateway_bin, tmp_path) + if args is None: + pytest.skip(f"metered replay produced no audit-cmd:\n{stdout}") + out = _verify_run(gateway_bin, Path(args["log"]), args, extra=["--json"]) + assert "budget-mismatch" not in (out.stdout + out.stderr), out.stdout + out.stderr + line = next((ln for ln in out.stdout.splitlines() if ln.strip().startswith("{")), None) + assert line, out.stdout + report = json.loads(line) + assert report["consistent"], report + assert report["settled_cents"] == 300, report diff --git a/tests/e2e/test_receipts_threat_model.py b/tests/e2e/test_receipts_threat_model.py index fc2e69a18..982477d1a 100644 --- a/tests/e2e/test_receipts_threat_model.py +++ b/tests/e2e/test_receipts_threat_model.py @@ -1,9 +1,9 @@ -"""E2E: the receipts threat-model gate (plan RC-E5.2) — a frozen transcript +"""E2E: the receipts threat-model gate — a frozen transcript drives the REAL gateway to mint a signed spend log + registry, then the REAL `auths-receipts-server` builds EvidenceBundles over it and every attack row is asserted against `receipt_verify`'s offline re-derivation. No network, no chain. -Coverage map (RC-E5.2 rows): +Coverage map: 1 clean deal → authorized/consistent, as-of head (here) 2 byte-flipped KEL event → tampered (here) 3 truncated spend log, same anchor → head-mismatch (here) @@ -20,7 +20,7 @@ 11–15, 18 escrow rows (Rust: cases/escrow.rs) 16 D1 gate-vs-rederivation mismatch → unverifiable (Rust: cases/judge.rs) 17 cross-rail budget (Rust: cases/judge.rs) - 19 wrong-call binding (S4) (here) + 19 wrong-call binding (here) 20 AllowList injection redirect → out-of-counterparty (here + Rust) 21 policy downgrade → still out-of-counterparty (Rust: cases/judge.rs) """ @@ -254,7 +254,7 @@ def test_row4b_sequential_chain_rederives_linear(self, gateway_bin, lab): "--agent", lab["agent"], "--root", lab["root"]], capture_output=True, text=True, env=lab["env"], timeout=120, ) - assert "verify-spend: consistent" in out.stdout + out.stderr + assert "verify-spend: self-consistent" in out.stdout + out.stderr def test_row5_swapped_verdict_is_tampered(self, receipts_bin, lab, clean_bundle): import copy @@ -266,7 +266,7 @@ def test_row5_swapped_verdict_is_tampered(self, receipts_bin, lab, clean_bundle) # both name the forgery. assert v["reason"] in ("invalid-signature", "tampered"), v - def test_row19_s4_wrong_call_binding_echo(self, receipts_bin, lab, clean_bundle): + def test_row19_wrong_call_binding_echo(self, receipts_bin, lab, clean_bundle): """A valid bundle for call X must not pass as evidence for call Y: the verifier echoes subject/tx/callIndex and the CALLER binds them.""" v = _verify(receipts_bin, lab, clean_bundle) @@ -276,7 +276,7 @@ def test_row19_s4_wrong_call_binding_echo(self, receipts_bin, lab, clean_bundle) # The caller's binding assertion — the disputed call here is #1, the # bundle is about #0: relevance check fails even though validity holds. disputed_index = 1 - assert v["callIndex"] != disputed_index, "the S4 check must be able to fail" + assert v["callIndex"] != disputed_index, "the binding check must be able to fail" def test_row8b_tel_revocation_before_head_is_unauthorized(self, receipts_bin, lab): server = _receipts_server(receipts_bin, lab) @@ -390,7 +390,7 @@ def test_dispute_bundle_carries_freshness_and_render(self, receipts_bin, lab): # D4 — the build-time online re-check stamp. fresh = bundle["verdicts"].get("onlineFreshness") assert fresh and "checkedAt" in fresh and "contradicted" in fresh - # S3 — the render exists and never re-expands hashed args. + # The render exists and never re-expands hashed args. assert "args hash" in bundle["rendered"] args_hash = bundle["call"]["args_hash"] assert args_hash in bundle["rendered"] @@ -443,3 +443,76 @@ def test_export_produces_pdf_exhibit(self, receipts_bin, lab, clean_bundle): assert b"VERIFICATION APPENDIX" in pdf or b"AUTHS EVIDENCE" in pdf finally: server.close() + + +def _mint_metered_lab(gateway_bin, tmp_path: Path): + """Replay a METERED transcript (a stripe fixture drives the cost, $3.00 = 300c) and return + the audit-cmd args — the material the cost-downgrade row attacks.""" + fixtures = tmp_path / "fixtures" + fixtures.mkdir(parents=True, exist_ok=True) + (fixtures / "stripe-charge.json").write_text(json.dumps({ + "rail": "stripe", + "charge": {"id": "ch_3MmlLrLkdIwHu7ix0snN0B15", "object": "charge", + "amount": 300, "amount_captured": 300, "amount_refunded": 0, + "currency": "usd", "captured": True, "paid": True, + "status": "succeeded", "livemode": False}, + })) + transcript = { + "grant": {"scope": ["paid.call"], "budget": "$50.00", "ttl": "30m"}, + "calls": [{"tool": "paid_call", "rail": "stripe", + "response_fixture": "stripe-charge.json", "expect": "allowed"}], + } + tpath = tmp_path / "metered.json" + tpath.write_text(json.dumps(transcript)) + env = {**_lab_env(tmp_path), + "AUTHS_MCP_RAIL_FIXTURES": str(fixtures), "AUTHS_MCP_TEST_MODE": "1"} + out = subprocess.run( + [str(gateway_bin), "replay", "--transcript", str(tpath)], + capture_output=True, text=True, env=env, timeout=240, + ) + stdout = out.stdout + out.stderr + import re + m = re.search( + r"audit-cmd:\s*--log\s+(\S+)\s+--registry\s+(\S+)\s+--agent\s+(\S+)\s+--root\s+(\S+)", + stdout, + ) + if not m: + return None + return {"log": m.group(1), "registry": m.group(2), + "agent": m.group(3), "root": m.group(4), "env": env} + + +@pytest.mark.slow +@pytest.mark.requires_binary +class TestReceiptsCostMismatch: + def test_row_amount_downgrade_is_cost_mismatch(self, gateway_bin, tmp_path_factory): + # Downgrade the rail response 300c → 50c while the SIGNED + # Auths-Settle-Cents stays 300 — the audit catches `cost-mismatch`, exit non-zero. + tmp_path = tmp_path_factory.mktemp("cost-mismatch") + args = _mint_metered_lab(gateway_bin, tmp_path) + if args is None: + pytest.skip("metered replay produced no audit-cmd") + log_file = Path(args["log"]) + records = [json.loads(ln) for ln in log_file.read_text().splitlines() if ln.strip()] + downgraded = False + for rec in records: + resp = rec.get("settlement", {}).get("rail_response") + if resp: + text = bytes(resp).decode("utf-8", "replace") + if '"amount_captured": 300' in text or '"amount_captured":300' in text: + text = text.replace('"amount_captured": 300', '"amount_captured": 50') + text = text.replace('"amount_captured":300', '"amount_captured":50') + rec["settlement"]["rail_response"] = list(text.encode("utf-8")) + downgraded = True + if not downgraded: + pytest.skip("metered replay carried no re-writable rail response") + log_file.write_text("\n".join(json.dumps(r) for r in records) + "\n") + + out = subprocess.run( + [str(gateway_bin), "verify-spend", "--log", str(log_file), + "--registry", args["registry"], "--agent", args["agent"], "--root", args["root"]], + capture_output=True, text=True, env=args["env"], timeout=120, + ) + text = out.stdout + out.stderr + assert out.returncode != 0, text + assert "cost-mismatch" in text, text