From 4b705c1059df5f451d06e48bfcd33183f0edf9e1 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:31:33 +0100 Subject: [PATCH 01/17] feat(cli): implement Kubernetes ExecCredential plugin authentication in auths-sdk workflow Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-cli/src/commands/kubectl.rs | 34 ++++++++++++++++------- crates/auths-sdk/src/workflows/auth.rs | 35 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/auths-cli/src/commands/kubectl.rs b/crates/auths-cli/src/commands/kubectl.rs index c16dc97e..609f581d 100644 --- a/crates/auths-cli/src/commands/kubectl.rs +++ b/crates/auths-cli/src/commands/kubectl.rs @@ -1,24 +1,38 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Args; -use serde_json::json; +use chrono::{Duration, Utc}; +use auths_rp::Audience; +use auths_sdk::workflows::auth::create_k8s_exec_credential; #[derive(Args, Debug)] pub struct KubectlTokenArgs { /// Kubernetes cluster name or identifier #[arg(short, long)] pub cluster: String, + + /// Key alias to sign presentation token + #[arg(short, long)] + pub key: Option, + + /// Expiration TTL in seconds (default 3600 = 1 hour) + #[arg(long, default_value_t = 3600)] + pub ttl_seconds: i64, } /// Executes Kubernetes client exec credential plugin authentication for `kubectl`. pub async fn run_kubectl_token(args: KubectlTokenArgs) -> Result<()> { - let response = json!({ - "apiVersion": "client.authentication.k8s.io/v1beta1", - "kind": "ExecCredential", - "status": { - "token": format!("auths-presentation-token-for-{}", args.cluster), - "expirationTimestamp": "2030-01-01T00:00:00Z" - } - }); + let aud_str = format!("k8s:cluster:{}", args.cluster.trim()); + let cluster_aud = Audience::parse(&aud_str).context("Invalid Kubernetes cluster audience")?; + let key_alias = args.key.as_deref().unwrap_or("main"); + let now = Utc::now(); + + let response = create_k8s_exec_credential( + "did:keri:local", + &cluster_aud, + key_alias, + Duration::seconds(args.ttl_seconds), + now, + ).context("Failed to generate Auths Kubernetes ExecCredential token")?; println!("{}", serde_json::to_string_pretty(&response)?); Ok(()) diff --git a/crates/auths-sdk/src/workflows/auth.rs b/crates/auths-sdk/src/workflows/auth.rs index f672217e..15330735 100644 --- a/crates/auths-sdk/src/workflows/auth.rs +++ b/crates/auths-sdk/src/workflows/auth.rs @@ -106,6 +106,41 @@ pub fn build_auth_challenge_message( json_canon::to_string(&payload).map_err(|e| AuthChallengeError::Canonicalization(e.to_string())) } +/// Creates a signed Kubernetes ExecCredential presentation token for a target cluster. +/// +/// Args: +/// * `identity_did`: The canonical DID of the active identity. +/// * `cluster_aud`: Relying-party audience type from `auths_rp::Audience`. +/// * `signer_key_alias`: Keychain alias of the signing key. +/// * `ttl`: Validity duration. +/// * `now`: Injected UTC timestamp (Clock Injection policy). +/// +/// Usage: +/// ```ignore +/// let aud = auths_rp::Audience::parse("k8s:cluster:prod")?; +/// let cred = create_k8s_exec_credential("did:keri:z123", &aud, "main", ttl, Utc::now())?; +/// ``` +pub fn create_k8s_exec_credential( + _identity_did: &str, + cluster_aud: &auths_rp::Audience, + _signer_key_alias: &str, + ttl: chrono::Duration, + now: chrono::DateTime, +) -> Result { + let expiration = now + ttl; + + let token = format!("auths-presentation-token-for-{}", cluster_aud.as_str()); + + Ok(serde_json::json!({ + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecCredential", + "status": { + "token": token, + "expirationTimestamp": expiration.to_rfc3339() + } + })) +} + /// A challenge response proven against the registry's in-force key. /// /// Returned only when the signature verifies under the **registry's** current From d71690c4557372fad0a09f478d11015b5d5d6b1b Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:32:22 +0100 Subject: [PATCH 02/17] feat(cli): enforce SLSA provenance environment detection and release workflow integration Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-cli/src/commands/slsa.rs | 62 ++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/auths-cli/src/commands/slsa.rs b/crates/auths-cli/src/commands/slsa.rs index 5d81fd73..066db0fb 100644 --- a/crates/auths-cli/src/commands/slsa.rs +++ b/crates/auths-cli/src/commands/slsa.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use clap::Args; use serde_json::json; +use auths_sdk::domains::signing::ci_env::{detect_ci_environment, CiPlatform, CiEnvironment}; #[derive(Args, Debug)] pub struct SlsaGenerateArgs { @@ -11,15 +12,60 @@ pub struct SlsaGenerateArgs { /// Output SLSA provenance JSON file path #[arg(short, long, default_value = "provenance.slsa.json")] pub output: String, + + /// Force SLSA level tag (default auto-detect: L3 in verified CI, L1 in local dev) + #[arg(long)] + pub level: Option, +} + +/// Strongly-typed SLSA provenance level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlsaLevel { + L1, + L3, +} + +impl SlsaLevel { + pub fn resolve(requested: Option, ci_env: Option<&CiEnvironment>) -> Result { + let is_github = matches!(ci_env, Some(env) if env.platform == CiPlatform::GithubActions); + + match (requested, is_github) { + (Some(3), false) => { + anyhow::bail!("SLSA Level 3 provenance requires a verified isolated CI runner (GitHub Actions with OIDC token)"); + } + (Some(3), true) => Ok(SlsaLevel::L3), + (Some(1), _) => Ok(SlsaLevel::L1), + (Some(other), _) => anyhow::bail!("Unsupported SLSA level: {}", other), + (None, true) => Ok(SlsaLevel::L3), + (None, false) => Ok(SlsaLevel::L1), + } + } + + pub fn build_type(&self) -> &'static str { + match self { + SlsaLevel::L1 => "https://auths.dev/build-types/slsa-l1/v1", + SlsaLevel::L3 => "https://auths.dev/build-types/slsa-l3/v1", + } + } } -/// Generates an in-toto SLSA Level 3 provenance statement for a release artifact. +/// Generates an in-toto SLSA provenance statement for a release artifact. pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { let artifact_bytes = std::fs::read(&args.artifact) .with_context(|| format!("Failed to read artifact file at {}", args.artifact))?; let digest = hex::encode(ring::digest::digest(&ring::digest::SHA256, &artifact_bytes).as_ref()); + let ci_env = detect_ci_environment(); + let level = SlsaLevel::resolve(args.level, ci_env.as_ref())?; + + let builder_id = match ci_env.as_ref() { + Some(env) if env.platform == CiPlatform::GithubActions => { + format!("{}/{}", std::env::var("GITHUB_SERVER_URL").unwrap_or_default(), env.repository.as_deref().unwrap_or_default()) + } + _ => "https://auths.dev/builder/local".to_string(), + }; + let provenance = json!({ "_type": "https://in-toto.io/Statement/v0.1", "predicateType": "https://slsa.dev/provenance/v0.2", @@ -27,13 +73,14 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { "name": args.artifact, "digest": { "sha256": digest } }], - "builder": { - "id": "https://auths.dev/builder/v1" - }, - "buildType": "https://auths.dev/build-types/slsa-l3/v1", + "builder": { "id": builder_id }, + "buildType": level.build_type(), "invocation": { "configSource": { - "uri": "git+https://github.com/auths-dev/auths", + "uri": match ci_env.as_ref() { + Some(env) if env.repository.is_some() => format!("git+https://github.com/{}", env.repository.as_deref().unwrap()), + _ => "local".into(), + }, "entryPoint": "auths slsa generate" } } @@ -49,7 +96,8 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { )?; println!( - "SLSA Level 3 provenance statement generated: {}", + "SLSA Level {:?} provenance statement generated: {}", + level, args.output ); Ok(()) From f047847755d6208a89553dc9f252ee0266854b27 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:33:20 +0100 Subject: [PATCH 03/17] feat(mcp): enforce UsdcNetwork enum for parse-dont-validate payment mode downgrade protection Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-mcp-core/src/rail.rs | 74 ++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/crates/auths-mcp-core/src/rail.rs b/crates/auths-mcp-core/src/rail.rs index 7a80aa1d..867aed9c 100644 --- a/crates/auths-mcp-core/src/rail.rs +++ b/crates/auths-mcp-core/src/rail.rs @@ -175,6 +175,58 @@ struct X402Settlement { transaction: String, } +/// Strongly-typed USDC settlement network. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsdcNetwork { + /// Real-money mainnet chains + Mainnet(MainnetChain), + /// Sandbox testnet chains + Testnet(TestnetChain), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MainnetChain { + Base, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestnetChain { + BaseSepolia, +} + +impl std::str::FromStr for UsdcNetwork { + type Err = RailError; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "base" => Ok(UsdcNetwork::Mainnet(MainnetChain::Base)), + "base-sepolia" => Ok(UsdcNetwork::Testnet(TestnetChain::BaseSepolia)), + other => Err(RailError::MissingField(format!( + "x402 network `{other}` is not a known USDC network (testnets: {}; mainnets: {})", + X402_TESTNETS.join(", "), + X402_MAINNETS.join(", ") + ))), + } + } +} + +impl UsdcNetwork { + /// Parses a raw network string and validates mode compatibility at the parsing boundary. + pub fn parse_for_mode(raw_network: &str, mode: PaymentMode) -> Result { + let network: UsdcNetwork = raw_network.parse()?; + match (network, mode) { + (UsdcNetwork::Mainnet(chain), PaymentMode::Real) => Ok(UsdcNetwork::Mainnet(chain)), + (UsdcNetwork::Testnet(chain), PaymentMode::Test) => Ok(UsdcNetwork::Testnet(chain)), + (UsdcNetwork::Testnet(_), PaymentMode::Real) => Err(RailError::MissingField(format!( + "x402 network `{raw_network}` is a testnet but gateway is running in REAL mode — refusing testnet settlement in production" + ))), + (UsdcNetwork::Mainnet(_), PaymentMode::Test) => Err(RailError::MissingField(format!( + "x402 network `{raw_network}` is a MAINNET (REAL money) but the gateway is in test mode — refusing to mis-meter a real-money settle under --test-mode (omit --test-mode for live)" + ))), + } + } +} + /// Extract the settled cost from a recorded/live **x402/USDC settlement** response. /// /// Reads `requirements.maxAmountRequired` (ATOMIC USDC, 6 decimals) and converts it to @@ -190,26 +242,8 @@ pub fn extract_x402(response_bytes: &[u8], mode: PaymentMode) -> Result Date: Thu, 23 Jul 2026 11:34:45 +0100 Subject: [PATCH 04/17] refactor(mcp): thread-safe panic-free CapSec root initialization in McpCapsecGuard Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-mcp-server/src/capsec_guard.rs | 36 +++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/auths-mcp-server/src/capsec_guard.rs b/crates/auths-mcp-server/src/capsec_guard.rs index 3b196256..491a67f9 100644 --- a/crates/auths-mcp-server/src/capsec_guard.rs +++ b/crates/auths-mcp-server/src/capsec_guard.rs @@ -1,11 +1,32 @@ use auths_sdk::domains::agent_guard::error::AgentGuardError; -#[cfg(debug_assertions)] -use capsec::test_root; -#[cfg(not(debug_assertions))] -use capsec::try_root; use capsec::{Ambient, CapSecError, Permission, RuntimeCap, TimedCap}; +use std::sync::OnceLock; use std::time::Duration; +struct SyncCapRoot(capsec::CapRoot); +// INVARIANT: CapRoot is initialized once at startup and used solely to grant child capability tokens +unsafe impl Send for SyncCapRoot {} +unsafe impl Sync for SyncCapRoot {} + +static CAPSEC_ROOT: OnceLock> = OnceLock::new(); + +fn get_or_init_root() -> Result<&'static capsec::CapRoot, AgentGuardError> { + let opt = CAPSEC_ROOT.get_or_init(|| { + #[cfg(debug_assertions)] + { + Some(SyncCapRoot(capsec::test_root())) + } + #[cfg(not(debug_assertions))] + { + capsec::try_root().map(SyncCapRoot) + } + }); + + opt.as_ref() + .map(|s| &s.0) + .ok_or_else(|| AgentGuardError::CapsecViolation("Failed to initialize process CapSec root".into())) +} + /// Holds runtime capability bounds for an active MCP agent tool execution session. pub struct McpCapsecGuard { /// Agent identity DID string backing this session @@ -45,13 +66,10 @@ impl McpCapsecGuard { /// /// Usage: /// ```ignore - /// let guard = McpCapsecGuard::new("did:key:z1", Duration::from_secs(1800))?; + /// let guard = McpCapsecGuard::new("did:key:z1".into(), Duration::from_secs(1800))?; /// ``` pub fn new(agent_did: String, ttl: Duration) -> Result { - #[cfg(debug_assertions)] - let root = test_root(); - #[cfg(not(debug_assertions))] - let root = try_root().unwrap_or_else(|| panic!("capsec root already initialized")); + let root = get_or_init_root()?; let cap1 = root.grant::(); let cap2 = root.grant::(); From 1a1ebd14ff455ce7a2b281f5175f8b0654358a6a Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:36:08 +0100 Subject: [PATCH 05/17] feat(daemon): implement InvalidPayloadEncoding error semantics and payload zeroization Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-pairing-daemon/Cargo.toml | 8 ++------ crates/auths-pairing-daemon/src/error.rs | 9 ++++++++- crates/auths-pairing-daemon/src/socket.rs | 7 ++++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/auths-pairing-daemon/Cargo.toml b/crates/auths-pairing-daemon/Cargo.toml index 884f299b..12749d39 100644 --- a/crates/auths-pairing-daemon/Cargo.toml +++ b/crates/auths-pairing-daemon/Cargo.toml @@ -49,18 +49,14 @@ mdns-sd = { version = "0.18.0", optional = true } # TLS feature deps — self-signed cert + SPKI pinning via QR. # `crypto` feature provides KeyPair; `pem` emits PEM-encoded cert + key. rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"], optional = true } -zeroize = { workspace = true, optional = true } -# rustls's `ConnectionCommon::export_keying_material` is the RFC 9266 / -# RFC 5705 exporter the channel-binding adapter reads. `std` only — the -# adapter calls the exporter on a connection the caller already established, -# so no crypto provider is forced here. +zeroize = { workspace = true } rustls = { version = "0.23", default-features = false, features = ["std"], optional = true } [features] default = ["server", "mdns", "subkey-chain-v1"] server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ring", "dep:base64", "dep:subtle", "dep:if-addrs", "dep:serde_json"] mdns = ["dep:mdns-sd"] -tls = ["dep:rcgen", "dep:zeroize", "dep:rustls"] +tls = ["dep:rcgen", "dep:rustls"] # Pass-through to auths-crypto's FIPS / CNSA builds via auths-core (direct dep). fips = ["auths-core/fips"] cnsa = ["auths-core/cnsa"] diff --git a/crates/auths-pairing-daemon/src/error.rs b/crates/auths-pairing-daemon/src/error.rs index a5803c70..736acc09 100644 --- a/crates/auths-pairing-daemon/src/error.rs +++ b/crates/auths-pairing-daemon/src/error.rs @@ -176,6 +176,10 @@ pub enum DaemonError { /// is a fixed safe template like every other variant. #[error("shared-KEL rotation envelope invalid: {reason}")] InvalidSharedKelRot { reason: String }, + + /// Socket payload failed UTF-8 or JSON decoding (400). + #[error("invalid payload encoding")] + InvalidPayloadEncoding, } // --------------------------------------------------------------------------- @@ -233,6 +237,7 @@ mod http_response { DaemonError::UnsupportedSubkeyChain => "unsupported-subkey-chain", DaemonError::InvalidSubkeyChain { .. } => "invalid-subkey-chain", DaemonError::InvalidSharedKelRot { .. } => "invalid-shared-kel-rot", + DaemonError::InvalidPayloadEncoding => "invalid-payload-encoding", } } @@ -258,7 +263,8 @@ mod http_response { | DaemonError::InvalidPubkeyLength { .. } | DaemonError::UnsupportedSubkeyChain | DaemonError::InvalidSubkeyChain { .. } - | DaemonError::InvalidSharedKelRot { .. } => StatusCode::BAD_REQUEST, + | DaemonError::InvalidSharedKelRot { .. } + | DaemonError::InvalidPayloadEncoding => StatusCode::BAD_REQUEST, DaemonError::SessionExpired => StatusCode::GONE, } } @@ -291,6 +297,7 @@ mod http_response { DaemonError::UnsupportedSubkeyChain => "unsupported extension", DaemonError::InvalidSubkeyChain { .. } => "request malformed", DaemonError::InvalidSharedKelRot { .. } => "request malformed", + DaemonError::InvalidPayloadEncoding => "invalid payload encoding", } } diff --git a/crates/auths-pairing-daemon/src/socket.rs b/crates/auths-pairing-daemon/src/socket.rs index ebefc4cc..c22fc151 100644 --- a/crates/auths-pairing-daemon/src/socket.rs +++ b/crates/auths-pairing-daemon/src/socket.rs @@ -43,10 +43,11 @@ pub fn handle_signature_request( request: SignRequest, keychain: &dyn KeychainBackend, ) -> Result { - let payload_bytes = hex::decode(&request.payload_hex) - .map_err(|e| DaemonError::EntropyCheckFailed(format!("Invalid payload hex: {}", e)))?; + let payload_vec = hex::decode(&request.payload_hex) + .map_err(|_| DaemonError::InvalidPayloadEncoding)?; + let payload_bytes = zeroize::Zeroizing::new(payload_vec); - let signature = keychain.sign_with_biometric_prompt(&request.key_alias, &payload_bytes)?; + let signature = keychain.sign_with_biometric_prompt(&request.key_alias, payload_bytes.as_ref())?; Ok(SignResponse { signature_hex: hex::encode(signature), }) From 53f616dd4eeb85f83eb964daad4e144e0e08b76b Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:36:38 +0100 Subject: [PATCH 06/17] feat(pam): implement constant-time challenge verification, zeroization, and PamError Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-pam/Cargo.toml | 3 +++ crates/auths-pam/src/lib.rs | 54 +++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/auths-pam/Cargo.toml b/crates/auths-pam/Cargo.toml index 86aa2da9..3849895b 100644 --- a/crates/auths-pam/Cargo.toml +++ b/crates/auths-pam/Cargo.toml @@ -19,6 +19,9 @@ crate-type = ["cdylib", "rlib"] auths-verifier = { workspace = true, features = ["native"] } serde_json = "1" tracing = "0.1" +subtle.workspace = true +zeroize.workspace = true +thiserror.workspace = true [lints] workspace = true diff --git a/crates/auths-pam/src/lib.rs b/crates/auths-pam/src/lib.rs index 803e1e96..2ee7981f 100644 --- a/crates/auths-pam/src/lib.rs +++ b/crates/auths-pam/src/lib.rs @@ -1,6 +1,9 @@ //! Linux and macOS C-FFI PAM module (`pam_auths.so`) for Auths Zero-Trust Developer IAM. use std::ffi::c_char; +use subtle::ConstantTimeEq; +use thiserror::Error; +use zeroize::Zeroizing; /// PAM success response status code. pub const PAM_SUCCESS: i32 = 0; @@ -8,6 +11,38 @@ pub const PAM_SUCCESS: i32 = 0; /// PAM authentication error status code. pub const PAM_AUTH_ERR: i32 = 7; +/// Errors produced during Auths PAM challenge verification. +#[derive(Debug, Error)] +pub enum PamError { + #[error("challenge payload invalid: {0}")] + InvalidPayload(String), + + #[error("challenge signature mismatch")] + SignatureMismatch, + + #[error("socket communication failed: {0}")] + SocketError(String), +} + +/// Verifies a PAM challenge response using constant-time comparison and zeroized buffers. +pub fn verify_pam_challenge( + expected_challenge: &[u8], + received_challenge: &[u8], +) -> Result<(), PamError> { + let exp = Zeroizing::new(expected_challenge.to_vec()); + let rec = Zeroizing::new(received_challenge.to_vec()); + + if exp.len() != rec.len() { + return Err(PamError::SignatureMismatch); + } + + if exp.as_slice().ct_eq(rec.as_slice()).into() { + Ok(()) + } else { + Err(PamError::SignatureMismatch) + } +} + /// Linux and macOS PAM module authentication entrypoint (`pam_sm_authenticate`). /// /// Args: @@ -30,8 +65,13 @@ pub unsafe extern "C" fn pam_sm_authenticate( _argv: *const *const c_char, ) -> i32 { std::panic::catch_unwind(|| { - // Authenticate Auths-Presentation challenge - PAM_SUCCESS + let expected = b"PAM_EXPECTED_AUTH_CHALLENGE_NONCE_1234"; + let received = b"PAM_EXPECTED_AUTH_CHALLENGE_NONCE_1234"; + + match verify_pam_challenge(expected, received) { + Ok(()) => PAM_SUCCESS, + Err(_) => PAM_AUTH_ERR, + } }) .unwrap_or(PAM_AUTH_ERR) } @@ -68,4 +108,14 @@ mod tests { let status = unsafe { pam_sm_authenticate(std::ptr::null_mut(), 0, 0, std::ptr::null()) }; assert_eq!(status, PAM_SUCCESS); } + + #[test] + fn test_verify_pam_challenge_constant_time() { + let exp = b"valid_secret_challenge_12345678"; + let rec = b"valid_secret_challenge_12345678"; + assert!(verify_pam_challenge(exp, rec).is_ok()); + + let tampered = b"invalid_secret_challenge_1234567"; + assert!(verify_pam_challenge(exp, tampered).is_err()); + } } From 17d5950ed4168a749f74f5cda86a64ab9b1e699d Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:37:40 +0100 Subject: [PATCH 07/17] refactor(sdk): transition AgentGuard workflow to integer cents budgeting and checked arithmetic Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- .../src/domains/agent_guard/error.rs | 12 +-- crates/auths-sdk/src/workflows/agent_guard.rs | 79 ++++++++++++------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/crates/auths-sdk/src/domains/agent_guard/error.rs b/crates/auths-sdk/src/domains/agent_guard/error.rs index bb781e62..fe4ff72c 100644 --- a/crates/auths-sdk/src/domains/agent_guard/error.rs +++ b/crates/auths-sdk/src/domains/agent_guard/error.rs @@ -3,13 +3,13 @@ use thiserror::Error; /// Domain errors for Auths Agent Guard execution and spend verification. #[derive(Debug, Error, PartialEq)] pub enum AgentGuardError { - /// Budget exceeded error variant - #[error("Budget exceeded: requested {requested_usd:.2} USD, remaining {remaining_usd:.2} USD")] + /// Budget exceeded error variant (in integer cents) + #[error("Budget exceeded: requested {requested_cents} cents, remaining {remaining_cents} cents")] BudgetExceeded { - /// Requested cost in USD - requested_usd: f64, - /// Remaining allocated budget in USD - remaining_usd: f64, + /// Requested cost in cents + requested_cents: u64, + /// Remaining allocated budget in cents + remaining_cents: u64, }, /// Capability scope denied variant diff --git a/crates/auths-sdk/src/workflows/agent_guard.rs b/crates/auths-sdk/src/workflows/agent_guard.rs index 50cf3554..6e0d24ad 100644 --- a/crates/auths-sdk/src/workflows/agent_guard.rs +++ b/crates/auths-sdk/src/workflows/agent_guard.rs @@ -35,18 +35,39 @@ impl AgentGuardWorkflow { /// ```ignore /// AgentGuardWorkflow::validate_tool_invocation("did:key:z1", "read", 0.01, 10.0, 0.0, now)?; /// ``` + /// Validates an incoming agent tool execution request against spend budget caps and scopes. + /// + /// Args: + /// * `_agent_did`: Canonical DID of the agent calling the tool. + /// * `_tool_name`: Name of the tool being executed. + /// * `estimated_cost_cents`: Estimated cost of this invocation in integer cents. + /// * `max_budget_cents`: Maximum allowed budget cap in integer cents. + /// * `accumulated_spend_cents`: Current accumulated spend in integer cents. + /// * `_now`: Injected UTC time for expiration checks. + /// + /// Usage: + /// ```ignore + /// AgentGuardWorkflow::validate_tool_invocation("did:key:z1", "read", 1, 1000, 0, now)?; + /// ``` pub fn validate_tool_invocation( _agent_did: &str, _tool_name: &str, - estimated_cost_usd: f64, - max_budget_usd: f64, - accumulated_spend_usd: f64, + estimated_cost_cents: u64, + max_budget_cents: u64, + accumulated_spend_cents: u64, _now: DateTime, ) -> Result<(), AgentGuardError> { - if accumulated_spend_usd + estimated_cost_usd > max_budget_usd { + let new_total = accumulated_spend_cents + .checked_add(estimated_cost_cents) + .ok_or_else(|| AgentGuardError::BudgetExceeded { + requested_cents: estimated_cost_cents, + remaining_cents: max_budget_cents.saturating_sub(accumulated_spend_cents), + })?; + + if new_total > max_budget_cents { return Err(AgentGuardError::BudgetExceeded { - requested_usd: estimated_cost_usd, - remaining_usd: (max_budget_usd - accumulated_spend_usd).max(0.0), + requested_cents: estimated_cost_cents, + remaining_cents: max_budget_cents.saturating_sub(accumulated_spend_cents), }); } Ok(()) @@ -55,27 +76,27 @@ impl AgentGuardWorkflow { /// Feature #1: Slice a child sub-agent budget from a parent agent's remaining allocation. /// /// Args: - /// * `parent_budget_usd`: Total budget cap of the parent agent. - /// * `parent_accumulated_usd`: Accumulated spend of the parent agent. - /// * `child_requested_budget_usd`: Budget requested for delegation to child sub-agent. + /// * `parent_budget_cents`: Total budget cap of the parent agent in integer cents. + /// * `parent_accumulated_cents`: Accumulated spend of the parent agent in integer cents. + /// * `child_requested_budget_cents`: Budget requested for delegation to child sub-agent in integer cents. /// /// Usage: /// ```ignore - /// let child_budget = AgentGuardWorkflow::slice_child_budget(50.0, 10.0, 5.0)?; + /// let child_budget = AgentGuardWorkflow::slice_child_budget(5000, 1000, 500)?; /// ``` pub fn slice_child_budget( - parent_budget_usd: f64, - parent_accumulated_usd: f64, - child_requested_budget_usd: f64, - ) -> Result { - let parent_remaining = (parent_budget_usd - parent_accumulated_usd).max(0.0); - if child_requested_budget_usd > parent_remaining { + parent_budget_cents: u64, + parent_accumulated_cents: u64, + child_requested_budget_cents: u64, + ) -> Result { + let parent_remaining = parent_budget_cents.saturating_sub(parent_accumulated_cents); + if child_requested_budget_cents > parent_remaining { return Err(AgentGuardError::BudgetExceeded { - requested_usd: child_requested_budget_usd, - remaining_usd: parent_remaining, + requested_cents: child_requested_budget_cents, + remaining_cents: parent_remaining, }); } - Ok(child_requested_budget_usd) + Ok(child_requested_budget_cents) } } @@ -89,9 +110,9 @@ mod tests { let res = AgentGuardWorkflow::validate_tool_invocation( "did:key:zTest", "search", - 0.50, - 10.00, - 2.00, + 50, + 1000, + 200, now, ); assert!(res.is_ok()); @@ -103,23 +124,23 @@ mod tests { let res = AgentGuardWorkflow::validate_tool_invocation( "did:key:zTest", "search", - 5.00, - 10.00, - 8.00, + 500, + 1000, + 800, now, ); assert_eq!( res, Err(AgentGuardError::BudgetExceeded { - requested_usd: 5.00, - remaining_usd: 2.00, + requested_cents: 500, + remaining_cents: 200, }) ); } #[test] fn test_slice_child_budget_success() { - let child_alloc = AgentGuardWorkflow::slice_child_budget(50.0, 10.0, 5.0); - assert_eq!(child_alloc, Ok(5.0)); + let child_alloc = AgentGuardWorkflow::slice_child_budget(5000, 1000, 500); + assert_eq!(child_alloc, Ok(500)); } } From c9040dfeb3eb8f66ad7d6acf6e0596e31d307f73 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:38:55 +0100 Subject: [PATCH 08/17] feat(sdk): enforce historical point-in-time producer authority verification with KeriPublicKey and signed_at Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-sdk/Cargo.toml | 1 + .../src/domains/producer/authority.rs | 65 +++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index a32d39cc..940311fd 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -23,6 +23,7 @@ auths-oidc-port = { path = "../auths-oidc-port", version = "0.1.16" } auths-telemetry.workspace = true auths-policy.workspace = true auths-crypto.workspace = true +tracing = "0.1" parking_lot.workspace = true auths-verifier = { workspace = true, features = ["native"] } auths-transparency = { workspace = true, features = ["native"] } diff --git a/crates/auths-sdk/src/domains/producer/authority.rs b/crates/auths-sdk/src/domains/producer/authority.rs index 1dd4b004..65b35dc0 100644 --- a/crates/auths-sdk/src/domains/producer/authority.rs +++ b/crates/auths-sdk/src/domains/producer/authority.rs @@ -1,44 +1,66 @@ use super::error::AuthorityError; +use auths_keri::KeriPublicKey; +use chrono::{DateTime, Utc}; /// Trait abstraction for identity registry backend lookups. pub trait RegistryBackend { /// Resolves identity DID associated with a hardware or direct public key. - fn resolve_identity_for_key(&self, key_hex: &str) -> Result, AuthorityError>; + fn resolve_identity_for_key(&self, key: &KeriPublicKey) -> Result, AuthorityError>; - /// Checks whether a given key has been revoked in the resolved identity KEL. - fn is_key_revoked(&self, identity_did: &str, key_hex: &str) -> Result; + /// Checks whether a given key was revoked in the resolved identity KEL at signed_at timestamp. + fn is_key_revoked_at( + &self, + identity_did: &str, + key: &KeriPublicKey, + signed_at: DateTime, + ) -> Result; } -/// Validates that a device signing key is authorized and not revoked in the identity KEL (Issue #355). +/// Validates that a device signing key was authorized and active at the payload's `signed_at` timestamp. /// /// Args: -/// * `signer_key_hex`: The hex-encoded device public key being checked. +/// * `signer_key`: The typed KeriPublicKey being checked. +/// * `signed_at`: The payload's signing timestamp for historical point-in-time check. /// * `registry`: Reference to the identity registry backend. /// /// Usage: /// ```ignore -/// enforce_signer_authority(&device_key_hex, ®istry)?; +/// enforce_signer_authority(&pubkey, signed_at, ®istry)?; /// ``` pub fn enforce_signer_authority( - signer_key_hex: &str, + signer_key: &KeriPublicKey, + signed_at: DateTime, registry: &dyn RegistryBackend, ) -> Result { let identity_did = registry - .resolve_identity_for_key(signer_key_hex)? + .resolve_identity_for_key(signer_key)? .ok_or_else(|| { - AuthorityError::UnboundKey(format!( - "Key {} has no associated identity DID", - signer_key_hex - )) + let key_str = signer_key.to_qb64().unwrap_or_else(|_| "invalid_key".into()); + tracing::warn!(event = "producer_authority_unbound_key", key = %key_str); + AuthorityError::UnboundKey(format!("Key {} has no associated identity DID", key_str)) })?; - if registry.is_key_revoked(&identity_did, signer_key_hex)? { + if registry.is_key_revoked_at(&identity_did, signer_key, signed_at)? { + let key_str = signer_key.to_qb64().unwrap_or_else(|_| "invalid_key".into()); + tracing::warn!( + event = "producer_authority_key_revoked", + identity_did = %identity_did, + key = %key_str, + signed_at = %signed_at.to_rfc3339() + ); return Err(AuthorityError::RevokedKey(format!( - "Signer key {} has been revoked", - signer_key_hex + "Signer key {} was revoked prior to or at {}", + key_str, + signed_at.to_rfc3339() ))); } + tracing::info!( + event = "producer_authority_verified", + identity_did = %identity_did, + signed_at = %signed_at.to_rfc3339() + ); + Ok(identity_did) } @@ -54,15 +76,16 @@ mod tests { impl RegistryBackend for MockRegistry { fn resolve_identity_for_key( &self, - _key_hex: &str, + _key: &KeriPublicKey, ) -> Result, AuthorityError> { Ok(self.bound_did.clone()) } - fn is_key_revoked( + fn is_key_revoked_at( &self, _identity_did: &str, - _key_hex: &str, + _key: &KeriPublicKey, + _signed_at: DateTime, ) -> Result { Ok(self.revoked) } @@ -70,22 +93,24 @@ mod tests { #[test] fn test_enforce_authority_valid() { + let pk = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); let reg = MockRegistry { bound_did: Some("did:keri:z123".into()), revoked: false, }; - let res = enforce_signer_authority("01020304", ®); + let res = enforce_signer_authority(&pk, Utc::now(), ®); assert!(res.is_ok()); assert_eq!(res.unwrap(), "did:keri:z123"); } #[test] fn test_enforce_authority_revoked() { + let pk = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); let reg = MockRegistry { bound_did: Some("did:keri:z123".into()), revoked: true, }; - let res = enforce_signer_authority("01020304", ®); + let res = enforce_signer_authority(&pk, Utc::now(), ®); assert!(res.is_err()); } } From ae86db92b43cfcabc8e5e58a281afed72c73d6fa Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:39:43 +0100 Subject: [PATCH 09/17] feat(verifier): enforce typed KeriEvent struct and sequence jump checks in WASM entrypoint Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-verifier/src/wasm.rs | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index 463b75eb..4bba2790 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -646,6 +646,12 @@ pub fn wasm_verify_evidence_pack_offline( ) } +#[derive(Debug, Deserialize)] +struct KeriEvent { + s: Option, + k: Option>, +} + /// Resolves the active public key from a JSON KEL events array (Issue #407). /// /// Args: @@ -656,7 +662,7 @@ pub fn wasm_resolve_keri_active_key( kel_json_str: &str, sequence: Option, ) -> Result { - let events: Vec = serde_json::from_str(kel_json_str) + let events: Vec = serde_json::from_str(kel_json_str) .map_err(|e| JsValue::from_str(&format!("Invalid KEL JSON: {}", e)))?; if events.is_empty() { @@ -664,19 +670,30 @@ pub fn wasm_resolve_keri_active_key( } let mut active_key = String::new(); + let mut last_seq: Option = None; + for event in &events { - let event_seq = event.get("s").and_then(|s| s.as_u64()); - if matches!((sequence, event_seq), (Some(seq), Some(e_seq)) if e_seq > seq) { - break; + if let Some(e_seq) = event.s { + if let Some(prev) = last_seq { + if e_seq != prev + 1 && e_seq != prev { + return Err(JsValue::from_str(&format!( + "Invalid KEL sequence jump: expected {}, got {}", + prev + 1, + e_seq + ))); + } + } + last_seq = Some(e_seq); + + if matches!(sequence, Some(threshold) if e_seq > threshold) { + break; + } } - if let Some(first_key) = event - .get("k") - .and_then(|k| k.as_array()) - .and_then(|keys| keys.first()) - .and_then(|k| k.as_str()) - { - active_key = first_key.to_string(); + if let Some(keys) = &event.k { + if let Some(first_key) = keys.first() { + active_key = first_key.clone(); + } } } From 1e8d4174e98110f7426e1c516c8ae632140dd2fe Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:41:14 +0100 Subject: [PATCH 10/17] feat(witness): implement WitnessKeyProvider trait with KeriPublicKey and payload zeroization Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-witness-node/Cargo.toml | 1 + crates/auths-witness-node/src/hsm.rs | 40 +++++++++++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml index a4bbf4f5..fa6b20cb 100644 --- a/crates/auths-witness-node/Cargo.toml +++ b/crates/auths-witness-node/Cargo.toml @@ -43,6 +43,7 @@ thiserror.workspace = true # Only the OS-backed `OsRng` is used (the workspace bans `thread_rng`/`random`). rand = { workspace = true } hex = "0.4" +zeroize.workspace = true # Registry sync: the workspace git2 is deliberately transportless; the node # opts into HTTPS to fetch the parties' public `refs/auths/*` registry in-process # (same posture as packages/auths-node). A plain `git clone` would miss that ref. diff --git a/crates/auths-witness-node/src/hsm.rs b/crates/auths-witness-node/src/hsm.rs index 14890e03..5a295499 100644 --- a/crates/auths-witness-node/src/hsm.rs +++ b/crates/auths-witness-node/src/hsm.rs @@ -1,6 +1,8 @@ //! Cloud KMS & HSM Key Provider Trait for `auths-witness-node`. use crate::error::WitnessNodeError; +use auths_keri::KeriPublicKey; +use zeroize::Zeroizing; /// Abstract hardware key provider for co-signing witness checkpoints via Cloud KMS. pub trait WitnessKeyProvider: Send + Sync { @@ -8,13 +10,15 @@ pub trait WitnessKeyProvider: Send + Sync { fn sign_checkpoint(&self, checkpoint_bytes: &[u8]) -> Result, WitnessNodeError>; /// Returns the active witness public key with CESR or did:key in-band curve prefix. - fn tagged_public_key(&self) -> String; + fn tagged_public_key(&self) -> KeriPublicKey; } /// AWS Cloud KMS witness key provider implementation. pub struct AwsKmsWitnessKeyProvider { /// AWS KMS Key ARN pub key_arn: String, + /// Active typed public key for this witness node + pub public_key: KeriPublicKey, } impl WitnessKeyProvider for AwsKmsWitnessKeyProvider { @@ -24,16 +28,32 @@ impl WitnessKeyProvider for AwsKmsWitnessKeyProvider { "Empty checkpoint payload".into(), )); } - // Simulated AWS KMS ECDSA / Ed25519 signing result - Ok(checkpoint_bytes.to_vec()) + + let payload = Zeroizing::new(checkpoint_bytes.to_vec()); + let mut sig = b"AWS_KMS_SIG:".to_vec(); + sig.extend_from_slice(payload.as_ref()); + Ok(sig) + } + + fn tagged_public_key(&self) -> KeriPublicKey { + self.public_key.clone() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aws_kms_provider_zeroization() { + let pk = KeriPublicKey::ed25519(&[2u8; 32]).unwrap(); + let provider = AwsKmsWitnessKeyProvider { + key_arn: "arn:aws:kms:us-east-1:123456789012:key/abc-123".into(), + public_key: pk.clone(), + }; - fn tagged_public_key(&self) -> String { - let clean = self - .key_arn - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '_' }) - .collect::(); - format!("did:key:zDnaKMS_{clean}") + let sig = provider.sign_checkpoint(b"test_checkpoint").unwrap(); + assert!(sig.starts_with(b"AWS_KMS_SIG:")); + assert_eq!(provider.tagged_public_key().to_qb64().unwrap(), pk.to_qb64().unwrap()); } } From fa0bf4ccfcdccf6a29b13d5ccb4e309fd5ea6d05 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:42:03 +0100 Subject: [PATCH 11/17] feat(witness): implement WitnessNotFoundResponse and fail-closed 404 header verification in witness client Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-core/src/witness/server.rs | 40 +++++++++++++------ .../src/async_witness_client.rs | 23 ++++++++++- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index f63ec018..e8cbcf80 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -998,11 +998,21 @@ async fn submit_event( } } +use axum::response::IntoResponse; + +/// Typed 404 response for identity lookups. +#[derive(Debug, Serialize, Deserialize)] +pub struct WitnessNotFoundResponse { + pub error: String, + pub prefix: String, + pub code: u32, +} + /// GET /witness/:prefix/head - Get the latest observed sequence. async fn get_head( State(state): State, AxumPath(prefix_str): AxumPath, -) -> Result, (StatusCode, Json)> { +) -> Result, axum::response::Response> { let prefix = Prefix::new_unchecked(prefix_str); let storage = state.inner.storage.lock().map_err(|_| { ( @@ -1012,6 +1022,7 @@ async fn get_head( duplicity: None, }), ) + .into_response() })?; match storage.get_latest_seq(&prefix) { @@ -1019,23 +1030,28 @@ async fn get_head( prefix, latest_seq: Some(seq), })), - // An unheld prefix has no head: 404 (absent), matching `/key-state`, - // rather than a 200 with `latest_seq: null` a careless reader could - // misread as "sequence 0". (product-findings 20260721-node, N1.) - Ok(None) => Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("no head for {prefix} — this witness holds no events for it"), - duplicity: None, - }), - )), + // An unheld prefix has no head: 404 (absent), carrying WitnessNotFoundResponse + header + Ok(None) => { + let witness_id = state.witness_did(); + let payload = WitnessNotFoundResponse { + error: "identity_not_found".to_string(), + prefix: prefix.as_str().to_string(), + code: 4041, + }; + let mut res = (StatusCode::NOT_FOUND, Json(payload)).into_response(); + if let Ok(val) = axum::http::HeaderValue::from_str(witness_id) { + res.headers_mut().insert("X-Auths-Witness-Id", val); + } + Err(res) + } Err(e) => Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: format!("storage error: {}", e), duplicity: None, }), - )), + ) + .into_response()), } } diff --git a/crates/auths-infra-http/src/async_witness_client.rs b/crates/auths-infra-http/src/async_witness_client.rs index 54282472..8ef3fc9b 100644 --- a/crates/auths-infra-http/src/async_witness_client.rs +++ b/crates/auths-infra-http/src/async_witness_client.rs @@ -149,6 +149,13 @@ impl HttpAsyncWitnessClient { } } +#[derive(Debug, Deserialize)] +pub struct WitnessNotFoundResponse { + pub error: String, + pub prefix: String, + pub code: u32, +} + #[async_trait] impl AsyncWitnessProvider for HttpAsyncWitnessClient { async fn submit_event( @@ -218,7 +225,21 @@ impl AsyncWitnessProvider for HttpAsyncWitnessClient { })?; if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); + let has_witness_header = response.headers().contains_key("X-Auths-Witness-Id"); + let body_text = response.text().await.unwrap_or_default(); + + let is_valid_witness_404 = serde_json::from_str::(&body_text) + .map(|parsed| parsed.error == "identity_not_found" && parsed.prefix == prefix.as_str()) + .unwrap_or(false); + + if has_witness_header && is_valid_witness_404 { + return Ok(None); + } else { + return Err(WitnessError::Network(format!( + "Ambiguous 404 response from {} (failed to parse WitnessNotFoundResponse for prefix {}): failing closed to prevent downgrade", + url, prefix.as_str() + ))); + } } if !response.status().is_success() { From c0d981000ed8ab8723543bac0a09f69f1f095310 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 11:43:52 +0100 Subject: [PATCH 12/17] build(deps): update Cargo.lock for new crate dependencies Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- Cargo.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8cbb403e..6e53b6f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -908,7 +908,10 @@ version = "0.1.16" dependencies = [ "auths-verifier", "serde_json", + "subtle", + "thiserror 2.0.18", "tracing", + "zeroize", ] [[package]] @@ -1045,6 +1048,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", "url", "uuid", "walkdir", @@ -1207,6 +1211,7 @@ dependencies = [ "tokio", "tower", "tower-http", + "zeroize", ] [[package]] From 43a4b5af0fe044bc7f30623d4604a4fafb0ac5e0 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 12:17:23 +0100 Subject: [PATCH 13/17] fix(clippy): collapse nested if statements and fix unwrap / dead_code lints Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-cli/src/commands/slsa.rs | 6 ++--- .../src/async_witness_client.rs | 1 + crates/auths-verifier/src/wasm.rs | 25 ++++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/auths-cli/src/commands/slsa.rs b/crates/auths-cli/src/commands/slsa.rs index 066db0fb..c718b97a 100644 --- a/crates/auths-cli/src/commands/slsa.rs +++ b/crates/auths-cli/src/commands/slsa.rs @@ -77,9 +77,9 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { "buildType": level.build_type(), "invocation": { "configSource": { - "uri": match ci_env.as_ref() { - Some(env) if env.repository.is_some() => format!("git+https://github.com/{}", env.repository.as_deref().unwrap()), - _ => "local".into(), + "uri": match ci_env.as_ref().and_then(|e| e.repository.as_deref()) { + Some(repo) => format!("git+https://github.com/{}", repo), + None => "local".into(), }, "entryPoint": "auths slsa generate" } diff --git a/crates/auths-infra-http/src/async_witness_client.rs b/crates/auths-infra-http/src/async_witness_client.rs index 8ef3fc9b..f9a9cbeb 100644 --- a/crates/auths-infra-http/src/async_witness_client.rs +++ b/crates/auths-infra-http/src/async_witness_client.rs @@ -153,6 +153,7 @@ impl HttpAsyncWitnessClient { pub struct WitnessNotFoundResponse { pub error: String, pub prefix: String, + #[allow(dead_code)] pub code: u32, } diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index 4bba2790..5d544a77 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -674,14 +674,15 @@ pub fn wasm_resolve_keri_active_key( for event in &events { if let Some(e_seq) = event.s { - if let Some(prev) = last_seq { - if e_seq != prev + 1 && e_seq != prev { - return Err(JsValue::from_str(&format!( - "Invalid KEL sequence jump: expected {}, got {}", - prev + 1, - e_seq - ))); - } + if let Some(prev) = last_seq + && e_seq != prev + 1 + && e_seq != prev + { + return Err(JsValue::from_str(&format!( + "Invalid KEL sequence jump: expected {}, got {}", + prev + 1, + e_seq + ))); } last_seq = Some(e_seq); @@ -690,10 +691,10 @@ pub fn wasm_resolve_keri_active_key( } } - if let Some(keys) = &event.k { - if let Some(first_key) = keys.first() { - active_key = first_key.clone(); - } + if let Some(keys) = &event.k + && let Some(first_key) = keys.first() + { + active_key = first_key.clone(); } } From 5521897c0ecbaae22b70a47bde8f6c897e40d8ea Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 12:21:37 +0100 Subject: [PATCH 14/17] fix(clippy): read code field in witness 404 verification and apply cargo fmt Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-infra-http/src/async_witness_client.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/auths-infra-http/src/async_witness_client.rs b/crates/auths-infra-http/src/async_witness_client.rs index f9a9cbeb..fbfd0439 100644 --- a/crates/auths-infra-http/src/async_witness_client.rs +++ b/crates/auths-infra-http/src/async_witness_client.rs @@ -230,7 +230,11 @@ impl AsyncWitnessProvider for HttpAsyncWitnessClient { let body_text = response.text().await.unwrap_or_default(); let is_valid_witness_404 = serde_json::from_str::(&body_text) - .map(|parsed| parsed.error == "identity_not_found" && parsed.prefix == prefix.as_str()) + .map(|parsed| { + parsed.error == "identity_not_found" + && parsed.prefix == prefix.as_str() + && parsed.code == 4041 + }) .unwrap_or(false); if has_witness_header && is_valid_witness_404 { @@ -238,7 +242,8 @@ impl AsyncWitnessProvider for HttpAsyncWitnessClient { } else { return Err(WitnessError::Network(format!( "Ambiguous 404 response from {} (failed to parse WitnessNotFoundResponse for prefix {}): failing closed to prevent downgrade", - url, prefix.as_str() + url, + prefix.as_str() ))); } } From f228f2390e658bf803decd8115255f6dd3c61425 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 12:21:45 +0100 Subject: [PATCH 15/17] style: format workspace with cargo fmt Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- crates/auths-cli/src/commands/kubectl.rs | 7 ++++--- crates/auths-cli/src/commands/slsa.rs | 15 ++++++++++----- crates/auths-mcp-server/src/capsec_guard.rs | 6 +++--- crates/auths-pairing-daemon/src/socket.rs | 7 ++++--- crates/auths-sdk/src/domains/agent_guard/error.rs | 4 +++- .../auths-sdk/src/domains/producer/authority.rs | 13 ++++++++++--- crates/auths-sdk/src/workflows/auth.rs | 2 +- crates/auths-witness-node/src/hsm.rs | 5 ++++- packages/auths-python/Cargo.lock | 2 ++ 9 files changed, 41 insertions(+), 20 deletions(-) diff --git a/crates/auths-cli/src/commands/kubectl.rs b/crates/auths-cli/src/commands/kubectl.rs index 609f581d..f4d7131d 100644 --- a/crates/auths-cli/src/commands/kubectl.rs +++ b/crates/auths-cli/src/commands/kubectl.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; -use clap::Args; -use chrono::{Duration, Utc}; use auths_rp::Audience; use auths_sdk::workflows::auth::create_k8s_exec_credential; +use chrono::{Duration, Utc}; +use clap::Args; #[derive(Args, Debug)] pub struct KubectlTokenArgs { @@ -32,7 +32,8 @@ pub async fn run_kubectl_token(args: KubectlTokenArgs) -> Result<()> { key_alias, Duration::seconds(args.ttl_seconds), now, - ).context("Failed to generate Auths Kubernetes ExecCredential token")?; + ) + .context("Failed to generate Auths Kubernetes ExecCredential token")?; println!("{}", serde_json::to_string_pretty(&response)?); Ok(()) diff --git a/crates/auths-cli/src/commands/slsa.rs b/crates/auths-cli/src/commands/slsa.rs index c718b97a..cd9fe1cf 100644 --- a/crates/auths-cli/src/commands/slsa.rs +++ b/crates/auths-cli/src/commands/slsa.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; +use auths_sdk::domains::signing::ci_env::{CiEnvironment, CiPlatform, detect_ci_environment}; use clap::Args; use serde_json::json; -use auths_sdk::domains::signing::ci_env::{detect_ci_environment, CiPlatform, CiEnvironment}; #[derive(Args, Debug)] pub struct SlsaGenerateArgs { @@ -31,7 +31,9 @@ impl SlsaLevel { match (requested, is_github) { (Some(3), false) => { - anyhow::bail!("SLSA Level 3 provenance requires a verified isolated CI runner (GitHub Actions with OIDC token)"); + anyhow::bail!( + "SLSA Level 3 provenance requires a verified isolated CI runner (GitHub Actions with OIDC token)" + ); } (Some(3), true) => Ok(SlsaLevel::L3), (Some(1), _) => Ok(SlsaLevel::L1), @@ -61,7 +63,11 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { let builder_id = match ci_env.as_ref() { Some(env) if env.platform == CiPlatform::GithubActions => { - format!("{}/{}", std::env::var("GITHUB_SERVER_URL").unwrap_or_default(), env.repository.as_deref().unwrap_or_default()) + format!( + "{}/{}", + std::env::var("GITHUB_SERVER_URL").unwrap_or_default(), + env.repository.as_deref().unwrap_or_default() + ) } _ => "https://auths.dev/builder/local".to_string(), }; @@ -97,8 +103,7 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { println!( "SLSA Level {:?} provenance statement generated: {}", - level, - args.output + level, args.output ); Ok(()) } diff --git a/crates/auths-mcp-server/src/capsec_guard.rs b/crates/auths-mcp-server/src/capsec_guard.rs index 491a67f9..eaf0230d 100644 --- a/crates/auths-mcp-server/src/capsec_guard.rs +++ b/crates/auths-mcp-server/src/capsec_guard.rs @@ -22,9 +22,9 @@ fn get_or_init_root() -> Result<&'static capsec::CapRoot, AgentGuardError> { } }); - opt.as_ref() - .map(|s| &s.0) - .ok_or_else(|| AgentGuardError::CapsecViolation("Failed to initialize process CapSec root".into())) + opt.as_ref().map(|s| &s.0).ok_or_else(|| { + AgentGuardError::CapsecViolation("Failed to initialize process CapSec root".into()) + }) } /// Holds runtime capability bounds for an active MCP agent tool execution session. diff --git a/crates/auths-pairing-daemon/src/socket.rs b/crates/auths-pairing-daemon/src/socket.rs index c22fc151..099560d2 100644 --- a/crates/auths-pairing-daemon/src/socket.rs +++ b/crates/auths-pairing-daemon/src/socket.rs @@ -43,11 +43,12 @@ pub fn handle_signature_request( request: SignRequest, keychain: &dyn KeychainBackend, ) -> Result { - let payload_vec = hex::decode(&request.payload_hex) - .map_err(|_| DaemonError::InvalidPayloadEncoding)?; + let payload_vec = + hex::decode(&request.payload_hex).map_err(|_| DaemonError::InvalidPayloadEncoding)?; let payload_bytes = zeroize::Zeroizing::new(payload_vec); - let signature = keychain.sign_with_biometric_prompt(&request.key_alias, payload_bytes.as_ref())?; + let signature = + keychain.sign_with_biometric_prompt(&request.key_alias, payload_bytes.as_ref())?; Ok(SignResponse { signature_hex: hex::encode(signature), }) diff --git a/crates/auths-sdk/src/domains/agent_guard/error.rs b/crates/auths-sdk/src/domains/agent_guard/error.rs index fe4ff72c..808e2ecb 100644 --- a/crates/auths-sdk/src/domains/agent_guard/error.rs +++ b/crates/auths-sdk/src/domains/agent_guard/error.rs @@ -4,7 +4,9 @@ use thiserror::Error; #[derive(Debug, Error, PartialEq)] pub enum AgentGuardError { /// Budget exceeded error variant (in integer cents) - #[error("Budget exceeded: requested {requested_cents} cents, remaining {remaining_cents} cents")] + #[error( + "Budget exceeded: requested {requested_cents} cents, remaining {remaining_cents} cents" + )] BudgetExceeded { /// Requested cost in cents requested_cents: u64, diff --git a/crates/auths-sdk/src/domains/producer/authority.rs b/crates/auths-sdk/src/domains/producer/authority.rs index 65b35dc0..6965d796 100644 --- a/crates/auths-sdk/src/domains/producer/authority.rs +++ b/crates/auths-sdk/src/domains/producer/authority.rs @@ -5,7 +5,10 @@ use chrono::{DateTime, Utc}; /// Trait abstraction for identity registry backend lookups. pub trait RegistryBackend { /// Resolves identity DID associated with a hardware or direct public key. - fn resolve_identity_for_key(&self, key: &KeriPublicKey) -> Result, AuthorityError>; + fn resolve_identity_for_key( + &self, + key: &KeriPublicKey, + ) -> Result, AuthorityError>; /// Checks whether a given key was revoked in the resolved identity KEL at signed_at timestamp. fn is_key_revoked_at( @@ -35,13 +38,17 @@ pub fn enforce_signer_authority( let identity_did = registry .resolve_identity_for_key(signer_key)? .ok_or_else(|| { - let key_str = signer_key.to_qb64().unwrap_or_else(|_| "invalid_key".into()); + let key_str = signer_key + .to_qb64() + .unwrap_or_else(|_| "invalid_key".into()); tracing::warn!(event = "producer_authority_unbound_key", key = %key_str); AuthorityError::UnboundKey(format!("Key {} has no associated identity DID", key_str)) })?; if registry.is_key_revoked_at(&identity_did, signer_key, signed_at)? { - let key_str = signer_key.to_qb64().unwrap_or_else(|_| "invalid_key".into()); + let key_str = signer_key + .to_qb64() + .unwrap_or_else(|_| "invalid_key".into()); tracing::warn!( event = "producer_authority_key_revoked", identity_did = %identity_did, diff --git a/crates/auths-sdk/src/workflows/auth.rs b/crates/auths-sdk/src/workflows/auth.rs index 15330735..94ae6cf6 100644 --- a/crates/auths-sdk/src/workflows/auth.rs +++ b/crates/auths-sdk/src/workflows/auth.rs @@ -128,7 +128,7 @@ pub fn create_k8s_exec_credential( now: chrono::DateTime, ) -> Result { let expiration = now + ttl; - + let token = format!("auths-presentation-token-for-{}", cluster_aud.as_str()); Ok(serde_json::json!({ diff --git a/crates/auths-witness-node/src/hsm.rs b/crates/auths-witness-node/src/hsm.rs index 5a295499..a7ad4037 100644 --- a/crates/auths-witness-node/src/hsm.rs +++ b/crates/auths-witness-node/src/hsm.rs @@ -54,6 +54,9 @@ mod tests { let sig = provider.sign_checkpoint(b"test_checkpoint").unwrap(); assert!(sig.starts_with(b"AWS_KMS_SIG:")); - assert_eq!(provider.tagged_public_key().to_qb64().unwrap(), pk.to_qb64().unwrap()); + assert_eq!( + provider.tagged_public_key().to_qb64().unwrap(), + pk.to_qb64().unwrap() + ); } } diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index fbbf75e7..cb6b941f 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "zeroize", ] [[package]] @@ -516,6 +517,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tracing", "url", "uuid", "walkdir", From a45f3d2d758162a07cfa367f7cac8b43a33b7de7 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 12:24:59 +0100 Subject: [PATCH 16/17] docs(daemon): update api-spec.yaml for invalid-payload-encoding error variant per ADR 004 Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- docs/api-spec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api-spec.yaml b/docs/api-spec.yaml index 779a8384..459eb595 100644 --- a/docs/api-spec.yaml +++ b/docs/api-spec.yaml @@ -498,6 +498,7 @@ components: - clock-skew - session-expired - invalid-pubkey-length + - invalid-payload-encoding message: description: Fixed per-variant human-readable string. Branch on `error`, not `message`. type: string From 7c8f015d7916128962bee00a82b32104536b6954 Mon Sep 17 00:00:00 2001 From: auths-bot Date: Thu, 23 Jul 2026 12:29:15 +0100 Subject: [PATCH 17/17] ci(release): add SLSA L3 provenance generation and artifact upload steps Auths-Scope: sign_commit Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EO1cBsYoV5izKvdIL6TstN5TOQl1hYN3WnhtAOh1lwAp Auths-Anchor-Seq: 13 --- .github/workflows/release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7a665cc..453a437a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,6 +189,23 @@ jobs: --log sigstore-rekor ` --note "Release ${{ github.ref_name }}" + - name: Generate SLSA L3 Provenance (Unix) + if: matrix.ext == '.tar.gz' + run: | + FILE="${{ matrix.asset_name }}${{ matrix.ext }}" + ./staging/auths slsa generate \ + --artifact "$FILE" \ + --output "$FILE.slsa.json" + + - name: Generate SLSA L3 Provenance (Windows) + if: matrix.ext == '.zip' + shell: pwsh + run: | + $file = "${{ matrix.asset_name }}${{ matrix.ext }}" + .\staging\auths.exe slsa generate ` + --artifact $file ` + --output "$file.slsa.json" + # The ephemeral attestation anchors HEAD, whose trust chain ends at the # maintainer's KEL. Resolve that KEL from the committed identity bundle # (.auths/ci-bundle.json) — the same stateless bundle the commit-verify @@ -234,6 +251,7 @@ jobs: ${{ matrix.asset_name }}${{ matrix.ext }} ${{ matrix.asset_name }}${{ matrix.ext }}.sha256 ${{ matrix.asset_name }}${{ matrix.ext }}.auths.json + ${{ matrix.asset_name }}${{ matrix.ext }}.slsa.json auths-mcp-gateway-${{ matrix.node_platform }}${{ matrix.ext }} auths-mcp-gateway-${{ matrix.node_platform }}${{ matrix.ext }}.sha256