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 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]] diff --git a/crates/auths-cli/src/commands/kubectl.rs b/crates/auths-cli/src/commands/kubectl.rs index c16dc97e..f4d7131d 100644 --- a/crates/auths-cli/src/commands/kubectl.rs +++ b/crates/auths-cli/src/commands/kubectl.rs @@ -1,24 +1,39 @@ -use anyhow::Result; +use anyhow::{Context, Result}; +use auths_rp::Audience; +use auths_sdk::workflows::auth::create_k8s_exec_credential; +use chrono::{Duration, Utc}; use clap::Args; -use serde_json::json; #[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-cli/src/commands/slsa.rs b/crates/auths-cli/src/commands/slsa.rs index 5d81fd73..cd9fe1cf 100644 --- a/crates/auths-cli/src/commands/slsa.rs +++ b/crates/auths-cli/src/commands/slsa.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use auths_sdk::domains::signing::ci_env::{CiEnvironment, CiPlatform, detect_ci_environment}; use clap::Args; use serde_json::json; @@ -11,15 +12,66 @@ 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 +79,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().and_then(|e| e.repository.as_deref()) { + Some(repo) => format!("git+https://github.com/{}", repo), + None => "local".into(), + }, "entryPoint": "auths slsa generate" } } @@ -49,8 +102,8 @@ pub async fn run_slsa_generate(args: SlsaGenerateArgs) -> Result<()> { )?; println!( - "SLSA Level 3 provenance statement generated: {}", - args.output + "SLSA Level {:?} provenance statement generated: {}", + level, args.output ); Ok(()) } 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..fbfd0439 100644 --- a/crates/auths-infra-http/src/async_witness_client.rs +++ b/crates/auths-infra-http/src/async_witness_client.rs @@ -149,6 +149,14 @@ impl HttpAsyncWitnessClient { } } +#[derive(Debug, Deserialize)] +pub struct WitnessNotFoundResponse { + pub error: String, + pub prefix: String, + #[allow(dead_code)] + pub code: u32, +} + #[async_trait] impl AsyncWitnessProvider for HttpAsyncWitnessClient { async fn submit_event( @@ -218,7 +226,26 @@ 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() + && parsed.code == 4041 + }) + .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() { 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> = 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::(); 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..099560d2 100644 --- a/crates/auths-pairing-daemon/src/socket.rs +++ b/crates/auths-pairing-daemon/src/socket.rs @@ -43,10 +43,12 @@ 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), }) 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()); + } } 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/agent_guard/error.rs b/crates/auths-sdk/src/domains/agent_guard/error.rs index bb781e62..808e2ecb 100644 --- a/crates/auths-sdk/src/domains/agent_guard/error.rs +++ b/crates/auths-sdk/src/domains/agent_guard/error.rs @@ -3,13 +3,15 @@ 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/domains/producer/authority.rs b/crates/auths-sdk/src/domains/producer/authority.rs index 1dd4b004..6965d796 100644 --- a/crates/auths-sdk/src/domains/producer/authority.rs +++ b/crates/auths-sdk/src/domains/producer/authority.rs @@ -1,44 +1,73 @@ 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 +83,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 +100,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()); } } 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)); } } diff --git a/crates/auths-sdk/src/workflows/auth.rs b/crates/auths-sdk/src/workflows/auth.rs index f672217e..94ae6cf6 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 diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index 463b75eb..5d544a77 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,31 @@ 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 + && 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()) + if let Some(keys) = &event.k + && let Some(first_key) = keys.first() { - active_key = first_key.to_string(); + active_key = first_key.clone(); } } 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..a7ad4037 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,35 @@ 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() + ); } } 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 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",