diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349bff64..33fb6ada 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,8 @@ jobs: run: | bash scripts/check-arch.sh bash scripts/check_sdk_boundary.sh + - name: Crypto primitive inventory is current + run: python3 scripts/check_primitive_inventory.py smoke: name: Golden-path smoke test diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 60edfdbb..83fc5682 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -164,16 +164,6 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ | `CryptoDiagnosticProvider` | `diagnostics.rs` | `auths-sdk` | `PosixDiagnosticAdapter` | `FakeCryptoDiagnosticProvider` | | `ArtifactSource` | `artifact.rs` | `auths-cli` | `StdinArtifactSource` | inline fakes | -### auths-registry-server ports (`crates/auths-registry-server/src/ports/`) - -| Trait | File | Adapter Crate | Production Adapter | Test Double | -|---|---|---|---|---| -| `HttpAdapter` | `http.rs` | `auths-infra-http` | `ReqwestHttpAdapter` | `MockHttpAdapter` (mockall) | -| `PairingStore` | `pairing_store.rs` | `auths-registry-server` | `PostgresPairingStore` | `InMemoryPairingStore` | -| `SubscriptionStore` | `subscription_store.rs` | `auths-registry-server` | `PostgresSubscriptionStore` | — | -| `TenantMetadataStore` | `tenant_metadata_store.rs` | `auths-registry-server` | `PostgresTenantMetadataStore` | — | -| `TenantResolver` | `tenant_resolver.rs` | `auths-registry-server` | `FilesystemTenantResolver` | `SingleTenantResolver` | - ## Bounded Context Guide ### auths-core @@ -200,12 +190,12 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ **Responsibility:** User-facing terminal commands, interactive prompts, JSON output mode. **Must NOT:** contain business logic. All domain operations are delegated to auths-sdk workflows. -### auths-registry-server -**Responsibility:** HTTP API for KERI identity registration, KEL management, platform claims, multi-tenant routing. +### auths-api +**Responsibility:** Thin HTTP presentation layer over the SDK — currently a minimal skeleton (health check + agent-passport control-plane handlers); domain routes are (re)mounted over SDK workflows as an HTTP surface is needed. Single-org self-host, no multi-tenant control plane. **Must NOT:** contain domain logic beyond input validation and error translation to HTTP status codes. -### auths-auth-server -**Responsibility:** "Login with Auths" challenge-response authentication. Issues challenges, verifies signatures by resolving identity keys. +### auths-rp +**Responsibility:** Relying-party wire boundary — turns an `Authorization: Auths-Presentation` header into a parsed `PresentationEnvelope` for verification (authenticate a request by proof-of-control of a delegated credential, not a bearer key). **Must NOT:** store private keys or implement key derivation logic. ### auths-infra-http @@ -216,10 +206,6 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ **Responsibility:** Production git2-backed implementations of the storage ports defined in auths-core (`GitEventLogWriter`, `GitEventLogReader`, `GitBlobReader`, `GitBlobWriter`, `GitRefReader`, `GitRefWriter`) and git introspection ports from auths-sdk (`SystemGitLogProvider`, `SystemGitConfigProvider`). **Must NOT:** contain business logic or be referenced by core/domain crates. -### auths-cache -**Responsibility:** Redis-backed tiered identity resolver with write-through archival to Git. -**Must NOT:** be referenced by core or domain crates. - ### auths-index **Responsibility:** SQLite-backed O(1) attestation index for fast lookups by device DID. **Must NOT:** be referenced by core or domain crates. diff --git a/Cargo.lock b/Cargo.lock index 9b26eb22..cb043da3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,6 +435,7 @@ dependencies = [ "toml", "url", "which", + "windows 0.58.0", "zeroize", ] @@ -623,6 +624,7 @@ dependencies = [ "auths-crypto", "auths-keri", "auths-oidc-port", + "auths-pairing-protocol", "auths-verifier", "axum", "chrono", @@ -843,6 +845,7 @@ dependencies = [ "chrono", "hex", "hkdf", + "hmac", "ml-kem", "p256 0.13.2", "rand 0.8.6", diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 819fb780..30f09b5f 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -104,6 +104,9 @@ witness-node = ["dep:auths-witness-node"] [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal", "process"] } +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = ["Win32_System_Threading", "Win32_Foundation"] } + [dev-dependencies] auths-crypto = { path = "../auths-crypto", features = ["test-utils"] } auths-verifier = { path = "../auths-verifier", features = ["test-utils"] } diff --git a/crates/auths-cli/src/bin/sign.rs b/crates/auths-cli/src/bin/sign.rs index 2d7909d2..746d9777 100644 --- a/crates/auths-cli/src/bin/sign.rs +++ b/crates/auths-cli/src/bin/sign.rs @@ -297,8 +297,29 @@ fn run_sign(args: &Args) -> Result<()> { #[allow(clippy::disallowed_methods)] let now = chrono::Utc::now(); - let signature_pem = - CommitSigningWorkflow::execute(&ctx, params, now).map_err(anyhow::Error::new)?; + + // Signing can block on a hardware biometric prompt or a passphrase entry. + // If it stalls past a couple of seconds, tell the user why instead of + // leaving the terminal frozen with no explanation. The notice goes to + // stderr only — stdout carries the gpg.ssh.program protocol. The channel + // wakes the notice thread the instant signing finishes, so a fast sign + // adds no latency. + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + let notice = std::thread::spawn(move || { + if done_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .is_err() + { + eprintln!( + "auths-sign: waiting for signing approval — touch your security key or enter your passphrase…" + ); + } + }); + let signing_result = + CommitSigningWorkflow::execute(&ctx, params, now).map_err(anyhow::Error::new); + let _ = done_tx.send(()); + let _ = notice.join(); + let signature_pem = signing_result?; let sig_path = format!("{}.sig", buffer_file.display()); fs::write(&sig_path, &signature_pem) diff --git a/crates/auths-cli/src/commands/status.rs b/crates/auths-cli/src/commands/status.rs index c667f75a..d8f5351f 100644 --- a/crates/auths-cli/src/commands/status.rs +++ b/crates/auths-cli/src/commands/status.rs @@ -571,7 +571,31 @@ fn is_process_running(pid: u32) -> bool { signal::kill(Pid::from_raw(pid as i32), None).is_ok() } -#[cfg(not(unix))] +/// Windows: a PID is running if we can open it and `GetExitCodeProcess` +/// reports `STILL_ACTIVE` (259). `OpenProcess` failing (no such PID, or access +/// denied) is treated as not-running, matching the Unix `kill(pid, 0)` check. +#[cfg(windows)] +fn is_process_running(pid: u32) -> bool { + use windows::Win32::Foundation::{CloseHandle, FALSE}; + use windows::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + const STILL_ACTIVE: u32 = 259; + // SAFETY: standard Win32 open/query/close of a process handle; the handle is + // always closed, and all pointers point to live stack locals. + unsafe { + let handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) { + Ok(h) => h, + Err(_) => return false, + }; + let mut code: u32 = 0; + let running = GetExitCodeProcess(handle, &mut code).is_ok() && code == STILL_ACTIVE; + let _ = CloseHandle(handle); + running + } +} + +#[cfg(not(any(unix, windows)))] fn is_process_running(_pid: u32) -> bool { false } diff --git a/crates/auths-crypto/src/provider.rs b/crates/auths-crypto/src/provider.rs index ab977686..83906030 100644 --- a/crates/auths-crypto/src/provider.rs +++ b/crates/auths-crypto/src/provider.rs @@ -528,7 +528,7 @@ compile_error!( /// Selection is compile-time: /// - default build → [`crate::ring_provider::RingCryptoProvider`] /// - `--features fips` → [`crate::aws_lc_provider::AwsLcProvider`] (AWS-LC-FIPS) -/// - `--features cnsa` → (fn-128.T4; TODO: returns Ring until CnsaProvider lands) +/// - `--features cnsa` → [`crate::cnsa_provider::CnsaProvider`] (P-384 / AES-256-GCM / SHA-384; rejects P-256/ChaCha/SHA-256) /// /// Domain code SHOULD route cryptographic operations through this function /// rather than constructing `p256::ecdsa::SigningKey` (or equivalent) directly — diff --git a/crates/auths-id/src/keri/anchor.rs b/crates/auths-id/src/keri/anchor.rs index c706dee1..d103efcc 100644 --- a/crates/auths-id/src/keri/anchor.rs +++ b/crates/auths-id/src/keri/anchor.rs @@ -130,6 +130,13 @@ fn canonicalize_and_said(data: &T) -> Result(attestation: &T) -> Result { + canonicalize_and_said(attestation) +} + fn sign_ixn( ixn: &IxnEvent, signer: &dyn SecureSigner, diff --git a/crates/auths-id/src/keri/delegation.rs b/crates/auths-id/src/keri/delegation.rs index af7b28f6..0fb82f59 100644 --- a/crates/auths-id/src/keri/delegation.rs +++ b/crates/auths-id/src/keri/delegation.rs @@ -386,6 +386,141 @@ pub fn author_root_anchor_ixn( Ok(ixn) } +/// One agent to incept in a bulk delegation batch (issue #255 bulk onboarding). +pub struct BulkAgentSpec { + /// Keychain alias to store the new agent key under. + pub device_alias: KeyAlias, + /// Curve for the new agent key. + pub device_curve: CurveType, +} + +/// Result of [`incept_delegated_agents_bulk`]: the devices plus the one anchoring `ixn`. +pub struct BulkDelegation { + /// The delegated identifiers, in spec order. + pub devices: Vec, + /// The single root `ixn` anchoring every dip in the batch. + pub anchor_ixn: IxnEvent, +} + +/// Incept a batch of agents as delegated identifiers with ONE root anchoring `ixn` +/// and ONE atomic commit — the bulk-onboarding write path (issue #255 / PRD KL-9). +/// +/// The per-agent path spreads three anchors over three root events (the dip +/// `Seal::KeyEvent`, the `agent:{prefix}` role marker, the attestation digest) and +/// three commits; here the same three seals per agent ride in one shared `ixn`, and +/// every dip's `-G` source seal points at that one anchoring event — the same +/// whole-set-at-one-KEL-position semantics [`revoke_delegated_devices_batch`] +/// already uses on the way out. +/// +/// `extras` runs once per agent AFTER its keys are stored in the keychain: it may +/// stage additional writes into the shared batch (e.g. the delegation-attestation +/// blob) and returns extra seals to carry in the anchoring `ixn` (e.g. the +/// attestation digest). Witness receipting is the caller's concern — one receipt +/// round per batch instead of per agent; this function does not contact witnesses. +#[allow(clippy::too_many_arguments)] +pub fn incept_delegated_agents_bulk( + backend: &(dyn RegistryBackend + Send + Sync), + root_prefix: &Prefix, + root_alias: &KeyAlias, + root_curve: CurveType, + specs: &[BulkAgentSpec], + passphrase_provider: &dyn PassphraseProvider, + keychain: &(dyn KeyStorage + Send + Sync), + mut extras: impl FnMut( + &DeviceDipBundle, + &mut crate::storage::registry::backend::AtomicWriteBatch, + ) -> Result, InitError>, +) -> Result { + let mut batch = crate::storage::registry::backend::AtomicWriteBatch::new(); + let mut bundles = Vec::with_capacity(specs.len()); + let mut seals = Vec::with_capacity(specs.len() * 3); + + for spec in specs { + let bundle = build_device_dip(root_prefix, spec.device_curve)?; + + // Store the agent's keys first so `extras` (attestation building) can read + // the public key back through the keychain exactly like the per-agent path. + let pass = passphrase_provider.get_passphrase(&format!( + "Create passphrase for device key '{}':", + spec.device_alias + ))?; + let enc_cur = encrypt_keypair(bundle.current_pkcs8.as_ref(), &pass)?; + keychain.store_key( + &spec.device_alias, + &bundle.device_did, + KeyRole::Primary, + &enc_cur, + )?; + let next_alias = KeyAlias::new_unchecked(format!("{}--next-0", spec.device_alias)); + let enc_next = encrypt_keypair(bundle.next_pkcs8.as_ref(), &pass)?; + keychain.store_key( + &next_alias, + &bundle.device_did, + KeyRole::NextRotation, + &enc_next, + )?; + + seals.push(Seal::KeyEvent { + i: bundle.device_prefix.clone(), + s: KeriSequence::new(0), + d: bundle.dip.d.clone(), + }); + seals.push(Seal::Digest { + d: Said::new_unchecked(agent_role_marker(&bundle.device_prefix)), + }); + seals.extend(extras(&bundle, &mut batch)?); + + bundles.push(bundle); + } + + // One anchoring ixn for the whole batch, staged BEFORE the dips so the dips + // staged after it see the anchor through the batch's state/tip overlays. + let anchor_ixn = stage_root_anchor_ixn( + backend, + root_prefix, + root_alias, + root_curve, + seals, + passphrase_provider, + keychain, + &mut batch, + )?; + + let source_seal = SourceSeal { + s: anchor_ixn.s, + d: anchor_ixn.d.clone(), + }; + let mut devices = Vec::with_capacity(bundles.len()); + for (spec, bundle) in specs.iter().zip(bundles) { + let mut anchored_dip = bundle.dip; + anchored_dip.source_seal = Some(source_seal.clone()); + let mut attachment = bundle.attachment; + attachment.extend_from_slice( + &serialize_source_seal_couples(&[source_seal.clone()]) + .map_err(|e| InitError::Keri(format!("source seal serialization: {e}")))?, + ); + batch.stage_event( + bundle.device_prefix.clone(), + Event::Dip(anchored_dip), + attachment, + ); + devices.push(DelegatedDevice { + device_did: bundle.device_did, + device_prefix: bundle.device_prefix, + device_alias: spec.device_alias.clone(), + }); + } + + backend + .commit_batch(&batch) + .map_err(|e| InitError::Registry(e.to_string()))?; + + Ok(BulkDelegation { + devices, + anchor_ixn, + }) +} + /// The role a delegated identifier plays. /// /// Agents and devices share the exact same `dip`/`drt` delegation mechanism; the diff --git a/crates/auths-infra-http/Cargo.toml b/crates/auths-infra-http/Cargo.toml index feb4d373..e7876153 100644 --- a/crates/auths-infra-http/Cargo.toml +++ b/crates/auths-infra-http/Cargo.toml @@ -16,6 +16,7 @@ async-trait = "0.1" auths-core = { workspace = true } auths-crypto = { workspace = true } auths-oidc-port = { path = "../auths-oidc-port", version = "0.1.3" } +auths-pairing-protocol = { workspace = true } auths-verifier = { workspace = true, features = ["native"] } futures-util = "0.3" # rust_crypto: the validation backend. Declared HERE because this crate is the diff --git a/crates/auths-infra-http/src/github_ssh_keys.rs b/crates/auths-infra-http/src/github_ssh_keys.rs index 8b8d0c22..f9aa3e23 100644 --- a/crates/auths-infra-http/src/github_ssh_keys.rs +++ b/crates/auths-infra-http/src/github_ssh_keys.rs @@ -194,16 +194,27 @@ async fn post_ssh_key_with_retry( let status = resp.status().as_u16(); - // Success: key created + // Success: key created. Return the real key id — never a placeholder, + // since callers store it as the key's metadata handle. if status == 201 { - match resp.json::().await { - Ok(key) => return Ok(key.id.to_string()), - Err(_e) => { - // If deserialization fails but we got 201, the key was created. - // Return a placeholder - metadata storage will verify it worked. - return Ok("created".to_string()); - } + let body = resp.text().await.unwrap_or_default(); + // Primary: typed parse. + if let Ok(key) = serde_json::from_str::(&body) { + return Ok(key.id.to_string()); + } + // Fallback: response shape changed but the id is still present — pull it directly. + if let Some(id) = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("id").and_then(serde_json::Value::as_u64)) + { + return Ok(id.to_string()); } + // 201 but no extractable id: surface it, don't fabricate a handle. + return Err(PlatformError::Platform { + message: format!( + "GitHub returned 201 Created but the SSH-key id could not be parsed: {body}" + ), + }); } // 422: Unprocessable Entity - likely duplicate, treat as success diff --git a/crates/auths-infra-http/src/pairing_client.rs b/crates/auths-infra-http/src/pairing_client.rs index 634cd068..5a13067c 100644 --- a/crates/auths-infra-http/src/pairing_client.rs +++ b/crates/auths-infra-http/src/pairing_client.rs @@ -117,14 +117,29 @@ impl PairingRelayClient for HttpPairingRelayClient { registry_url: &str, code: &str, ) -> impl Future> + Send { + use auths_pairing_protocol::lookup_auth::{LOOKUP_PATH, build_lookup_authorization}; let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); - let url = format!( - "{}/v1/pairing/sessions/by-code/{}", - registry_url.trim_end_matches('/'), - code - ); + let url = format!("{}{}", registry_url.trim_end_matches('/'), LOOKUP_PATH); let endpoint = registry_url.to_string(); - let req = self.client.get(&url); + + // Prove knowledge of the short code without sending it in the clear: HMAC + // a canonical GET with a key derived from the code. The timestamp and a + // fresh random nonce bind the request against replay. + #[allow(clippy::disallowed_methods)] // wire boundary: no clock is injected into the relay client + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut nonce = [0u8; 16]; + { + use rand::RngCore; + let mut rng = rand::rngs::OsRng; + rng.fill_bytes(&mut nonce); + } + let req = self + .client + .get(&url) + .header("Authorization", build_lookup_authorization(code, ts, &nonce)); async move { guard?; diff --git a/crates/auths-keri/docs/spec_compliance_audit.md b/crates/auths-keri/docs/spec_compliance_audit.md index c2a4a31d..3d5a263d 100644 --- a/crates/auths-keri/docs/spec_compliance_audit.md +++ b/crates/auths-keri/docs/spec_compliance_audit.md @@ -1,3 +1,11 @@ +> ⚠️ **STALE (as of 2026-07-04) — historical document.** Many deviations listed +> below have since been implemented: decimal/hex and weighted/fractional +> thresholds, delegated inception/rotation (`dip`/`drt`), non-transferable and +> abandonment enforcement, witness-receipt quorum gating, and dual-index ("Big") +> CESR sigers. Do not treat this as the current gap list. **`SPEC.md` is the +> authoritative wire-format contract**; consult it and the live `validate.rs` +> tests, not this file. + # KERI Spec Compliance Audit: `auths-keri` **Spec reference:** [Trust over IP KSWG KERI Specification v1.1](https://trustoverip.github.io/kswg-keri-specification/) diff --git a/crates/auths-pairing-daemon/src/auth.rs b/crates/auths-pairing-daemon/src/auth.rs index fbbd29be..ab35905d 100644 --- a/crates/auths-pairing-daemon/src/auth.rs +++ b/crates/auths-pairing-daemon/src/auth.rs @@ -405,6 +405,23 @@ fn keri_pubkeys_equal(a: &auths_keri::KeriPublicKey, b: &auths_keri::KeriPublicK mod tests { use super::*; + /// Byte-compat guard: the client builds its `Auths-HMAC` lookup header via + /// the shared `auths-pairing-protocol::lookup_auth` builder, and this + /// daemon's `verify_hmac` must accept it. If the two copies of the + /// canonicalization ever drift, this fails — which is what makes keeping the + /// pure builders on both sides safe. + #[test] + fn hmac_lookup_client_header_is_accepted() { + use auths_pairing_protocol::lookup_auth::{LOOKUP_PATH, build_lookup_authorization}; + let short_code = "ABC123"; + let ts = 1_700_000_000_i64; + let nonce = [7u8; 16]; + let header = build_lookup_authorization(short_code, ts, &nonce); + let parsed = parse_authorization(&header).expect("client header parses"); + verify_hmac(&parsed, "GET", LOOKUP_PATH, b"", short_code, ts) + .expect("daemon verify_hmac must accept the client-built lookup header"); + } + #[test] fn parse_authorization_happy_path() { let auth = diff --git a/crates/auths-pairing-protocol/Cargo.toml b/crates/auths-pairing-protocol/Cargo.toml index 24466dda..05f4935d 100644 --- a/crates/auths-pairing-protocol/Cargo.toml +++ b/crates/auths-pairing-protocol/Cargo.toml @@ -12,6 +12,7 @@ categories = ["cryptography"] [dependencies] auths-crypto = { workspace = true, features = ["native"] } +hmac.workspace = true auths-keri.workspace = true ring.workspace = true p256 = { version = "=0.13.2", features = ["ecdh", "ecdsa", "pkcs8"] } @@ -33,7 +34,7 @@ schemars = { version = "0.8", optional = true } # fn-129.T10: optional hybrid post-quantum KEM slot. Pinned exactly — the # `ml-kem` crate is UNAUDITED as of 2026; see `src/pq_hybrid.rs`. Default # disabled; this dep is only compiled in when `pq-hybrid` is enabled. -ml-kem = { version = "0.2", default-features = false, features = ["zeroize"], optional = true } +ml-kem = { version = "=0.2.3", default-features = false, features = ["zeroize"], optional = true } [features] default = ["subkey-chain-v1"] diff --git a/crates/auths-pairing-protocol/src/lib.rs b/crates/auths-pairing-protocol/src/lib.rs index 06609a95..c2057497 100644 --- a/crates/auths-pairing-protocol/src/lib.rs +++ b/crates/auths-pairing-protocol/src/lib.rs @@ -37,6 +37,7 @@ pub mod channel_binding; pub mod domain_separation; pub mod envelope; +pub mod lookup_auth; mod error; #[cfg(feature = "pq-hybrid")] pub mod pq_hybrid; diff --git a/crates/auths-pairing-protocol/src/lookup_auth.rs b/crates/auths-pairing-protocol/src/lookup_auth.rs new file mode 100644 index 00000000..f77f004d --- /dev/null +++ b/crates/auths-pairing-protocol/src/lookup_auth.rs @@ -0,0 +1,110 @@ +//! Client-side `Auths-HMAC` lookup-auth for the pairing `/lookup` endpoint. +//! +//! The joining device proves it holds the 6-character short code without ever +//! sending it in the clear: it HMACs a canonical request string with a key +//! derived from the short code. The client (`auths-infra-http`) builds the +//! `Authorization` header with [`build_lookup_authorization`]; the daemon +//! (`auths-pairing-daemon::auth`) verifies it. +//! +//! **These functions MUST stay byte-identical to `auths-pairing-daemon::auth`** +//! (`derive_hmac_key` / `derive_hmac_kid` / `canonical_string` / the +//! `DAEMON_HMAC_INFO` context). That invariant is enforced, not assumed: the +//! daemon's `hmac_lookup_client_header_is_accepted` test signs a request via +//! [`build_lookup_authorization`] and asserts the daemon's `verify_hmac` accepts +//! it, so any drift between the two copies fails the build. (The daemon owns the +//! verify path; duplicating only these pure builders here keeps the client off a +//! dependency on the server crate.) +//! +//! Canonical signing string (newline-separated): +//! `\n\n\n\n\n` + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; + +/// Domain-separation context for the HMAC lookup scheme. Included in the signed +/// bytes so a lookup HMAC can never be replayed as a different scheme's message. +pub const DAEMON_HMAC_INFO: &[u8] = b"auths-daemon-hmac-v1"; + +/// The path the short-code lookup is served at (also part of the signed bytes). +pub const LOOKUP_PATH: &str = "/v1/pairing/sessions/lookup"; + +/// Build the canonical signing input for a request. +/// +/// `context` is [`DAEMON_HMAC_INFO`] for the HMAC scheme. Binding method, path, +/// a hash of the body, the timestamp, and the nonce prevents cross-request and +/// cross-endpoint replay. +pub fn canonical_string( + context: &[u8], + method: &str, + path: &str, + body: &[u8], + ts: i64, + nonce: &[u8], +) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(body); + let body_hash_hex = hex::encode(hasher.finalize()); + let nonce_b64 = URL_SAFE_NO_PAD.encode(nonce); + + let mut out = Vec::with_capacity(context.len() + method.len() + path.len() + 200); + out.extend_from_slice(context); + out.push(b'\n'); + out.extend_from_slice(method.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(path.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(body_hash_hex.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(nonce_b64.as_bytes()); + out +} + +/// Derive the 32-byte HMAC key from the pairing short code. +pub fn derive_hmac_key(short_code: &str) -> [u8; 32] { + let hk = Hkdf::::new(None, short_code.as_bytes()); + let mut key = [0u8; 32]; + let _ = hk.expand(DAEMON_HMAC_INFO, &mut key); + key +} + +/// First 16 bytes of `SHA-256(short_code)` — the `kid` on the `Auths-HMAC` +/// scheme. Lets the server look up which short code a request claims without the +/// code appearing anywhere in the clear. +pub fn derive_hmac_kid(short_code: &str) -> [u8; 16] { + let mut h = Sha256::new(); + h.update(short_code.as_bytes()); + let full = h.finalize(); + let mut out = [0u8; 16]; + out.copy_from_slice(&full[..16]); + out +} + +/// Build the `Authorization: Auths-HMAC …` header value for a `GET` on +/// [`LOOKUP_PATH`] with an empty body — the client half of the scheme. +/// +/// The caller supplies `ts` (current unix seconds) and a fresh random `nonce` +/// (the daemon rejects replays), keeping this a pure, testable function. +pub fn build_lookup_authorization(short_code: &str, ts: i64, nonce: &[u8]) -> String { + let kid = derive_hmac_kid(short_code); + let key = derive_hmac_key(short_code); + let canonical = canonical_string(DAEMON_HMAC_INFO, "GET", LOOKUP_PATH, b"", ts, nonce); + + let Ok(mut mac) = Hmac::::new_from_slice(&key) else { + unreachable!("HMAC-SHA256 accepts a key of any length") + }; + mac.update(&canonical); + let sig = mac.finalize().into_bytes(); + + format!( + "Auths-HMAC kid={},ts={},nonce={},sig={}", + URL_SAFE_NO_PAD.encode(kid), + ts, + URL_SAFE_NO_PAD.encode(nonce), + URL_SAFE_NO_PAD.encode(sig), + ) +} diff --git a/crates/auths-sdk/src/domains/agents/delegation.rs b/crates/auths-sdk/src/domains/agents/delegation.rs index c783a770..21f5a0db 100644 --- a/crates/auths-sdk/src/domains/agents/delegation.rs +++ b/crates/auths-sdk/src/domains/agents/delegation.rs @@ -13,9 +13,9 @@ use auths_core::signing::StorageSigner; use auths_core::storage::keychain::{IdentityDID, KeyAlias, extract_public_key_bytes}; use auths_id::attestation::create::{AttestationInput, create_signed_attestation}; use auths_id::keri::delegation::{ - DelegatedRole, incept_delegated_device, list_delegated_devices, mark_agent_scope, - mark_delegated_agent, read_agent_scope, revoke_delegated_device, - revoke_delegated_devices_batch, rotate_delegated_device, + BulkAgentSpec, DelegatedRole, incept_delegated_agents_bulk, incept_delegated_device, + list_delegated_devices, mark_agent_scope, mark_delegated_agent, read_agent_scope, + revoke_delegated_device, revoke_delegated_devices_batch, rotate_delegated_device, }; use auths_id::keri::{Event, anchor_and_persist_via_backend, parse_did_keri}; use auths_id::storage::git_refs::AttestationMetadata; @@ -189,6 +189,127 @@ pub fn add_scoped( }) } +/// Bulk-onboard agents: each batch of `batch_size` is incepted with ONE root +/// anchoring `ixn` and ONE atomic commit (issue #255 / PRD KL-9), instead of the +/// three root events and three-plus commits per agent the per-agent path costs. +/// +/// Per-agent semantics match [`add`]: a device-signed `dip`, the dip anchor seal, +/// the `agent:{prefix}` role marker, and the signed delegation attestation — the +/// anchors are simply co-located in the shared batch `ixn`. Unscoped only: +/// scope/expiry seals stay on the per-agent [`add_scoped`] path. Witness receipting +/// happens per batch, not per agent (none is attempted here). +pub fn add_bulk( + ctx: &AuthsContext, + root_alias: &KeyAlias, + agent_aliases: &[KeyAlias], + agent_curve: auths_crypto::CurveType, + batch_size: usize, +) -> Result, AgentError> { + for alias in agent_aliases { + if ctx.key_storage.load_key(alias).is_ok() { + return Err(AgentError::AlreadyDelegated { + alias: alias.as_str().to_string(), + }); + } + } + let managed = + ctx.identity_storage + .load_identity() + .map_err(|e| AgentError::IdentityNotFound { + did: format!("identity load failed: {e}"), + })?; + let root_prefix = parse_did_keri(managed.controller_did.as_str()).map_err(|e| { + AgentError::IdentityNotFound { + did: format!("invalid root did:keri: {e}"), + } + })?; + let (_pk, root_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + root_alias, + ctx.passphrase_provider.as_ref(), + ) + .map_err(AgentError::CryptoError)?; + + let signer = StorageSigner::new(Arc::clone(&ctx.key_storage)); + let issuer_canonical = CanonicalDid::from(managed.controller_did.clone()); + let mut out = Vec::with_capacity(agent_aliases.len()); + + for chunk in agent_aliases.chunks(batch_size.max(1)) { + let specs: Vec = chunk + .iter() + .map(|alias| BulkAgentSpec { + device_alias: alias.clone(), + device_curve: agent_curve, + }) + .collect(); + + // Per agent: build + sign the delegation attestation (mirroring + // record_delegation_attestation), stage its blob into the shared batch, + // and hand its digest seal back to join the batch ixn. + let mut idx = 0usize; + let bulk = incept_delegated_agents_bulk( + ctx.registry.as_ref(), + &root_prefix, + root_alias, + root_curve, + &specs, + ctx.passphrase_provider.as_ref(), + ctx.key_storage.as_ref(), + |bundle, batch| { + let agent_alias = &chunk[idx]; + idx += 1; + let (agent_pk, agent_pk_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + agent_alias, + ctx.passphrase_provider.as_ref(), + ) + .map_err(|e| auths_id::error::InitError::Crypto(e.to_string()))?; + let now = ctx.clock.now(); + let meta = AttestationMetadata { + timestamp: Some(now), + expires_at: None, + note: None, + }; + let subject = CanonicalDid::from(bundle.device_did.clone()); + let attestation = create_signed_attestation( + now, + AttestationInput { + rid: &managed.storage_id, + issuer: &issuer_canonical, + subject: &subject, + device_public_key: &agent_pk, + device_curve: agent_pk_curve, + payload: None, + meta: &meta, + identity_alias: Some(root_alias), + device_alias: Some(agent_alias), + delegated_by: None, + commit_sha: None, + signer_type: Some(SignerType::Agent), + oidc_binding: None, + }, + &signer, + ctx.passphrase_provider.as_ref(), + ) + .map_err(|e| auths_id::error::InitError::Keri(e.to_string()))?; + let said = auths_id::keri::anchor::attestation_said(&attestation) + .map_err(|e| auths_id::error::InitError::Keri(e.to_string()))?; + batch.stage_attestation(attestation); + Ok(vec![auths_id::keri::Seal::digest(said.as_str())]) + }, + ) + .map_err(AgentError::DelegationError)?; + + for device in bulk.devices { + out.push(AgentDelegationResult { + agent_did: device.device_did.as_str().to_string(), + agent_prefix: device.device_prefix.as_str().to_string(), + }); + } + } + Ok(out) +} + /// Sign and anchor the attestation for a freshly delegated agent. /// /// The delegating root issues (and the agent's key co-signs) an attestation over diff --git a/crates/auths-sdk/src/domains/agents/mod.rs b/crates/auths-sdk/src/domains/agents/mod.rs index 3cb0b8a0..01d3c64e 100644 --- a/crates/auths-sdk/src/domains/agents/mod.rs +++ b/crates/auths-sdk/src/domains/agents/mod.rs @@ -16,8 +16,8 @@ pub mod error; pub mod scope; pub use delegation::{ - AgentDelegationResult, AgentInfo, BatchRevocation, add, add_scoped, list, revoke, revoke_batch, - rotate, + AgentDelegationResult, AgentInfo, BatchRevocation, add, add_bulk, add_scoped, list, revoke, + revoke_batch, rotate, }; pub use error::AgentError; pub use scope::{DelegationError, DelegatorScope, RequestedScope, validate_delegation_constraints}; diff --git a/crates/auths-sdk/src/domains/diagnostics/types.rs b/crates/auths-sdk/src/domains/diagnostics/types.rs index d2248a7c..94f01a12 100644 --- a/crates/auths-sdk/src/domains/diagnostics/types.rs +++ b/crates/auths-sdk/src/domains/diagnostics/types.rs @@ -32,15 +32,9 @@ pub struct NextStep { /// Full status report combining identity, devices, and agent state. /// -/// Usage: -/// ```ignore -/// let report = StatusWorkflow::query(&ctx, now)?; -/// println!("Identity: {}", report.identity.controller_did); -/// println!("Devices: {} linked", report.devices.len()); -/// for step in report.next_steps { -/// println!("Try: {}", step.command); -/// } -/// ``` +/// Assembled by the `auths status` command, which loads identity/devices/agent +/// and derives `next_steps` via [`crate::workflows::status::StatusWorkflow`]'s +/// `compute_readiness` + `next_steps_from_readiness`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatusReport { /// Current identity status, if initialized. diff --git a/crates/auths-sdk/src/workflows/status.rs b/crates/auths-sdk/src/workflows/status.rs index 68c85ba5..961e3bf2 100644 --- a/crates/auths-sdk/src/workflows/status.rs +++ b/crates/auths-sdk/src/workflows/status.rs @@ -1,71 +1,17 @@ //! Status workflow — aggregates identity, device, and agent state for user-friendly reporting. -use crate::result::{ - AgentStatus, DeviceReadiness, DeviceStatus, IdentityStatus, NextStep, StatusReport, -}; +use crate::result::{DeviceReadiness, NextStep}; use chrono::{DateTime, Duration, Utc}; -use std::path::Path; -/// Status workflow for reporting Auths state. +/// Status rules for reporting Auths state. /// -/// This workflow aggregates information from identity storage, device attestations, -/// and agent status to produce a unified StatusReport suitable for CLI display. -/// -/// Usage: -/// ```ignore -/// let report = StatusWorkflow::query(&ctx, Utc::now())?; -/// println!("Identity: {}", report.identity.controller_did); -/// ``` +/// The consumed surface is [`StatusWorkflow::compute_readiness`] (per-device +/// readiness from expiry/revocation) and [`StatusWorkflow::next_steps_from_readiness`] +/// (the next-step rules). The CLI (`auths status`) loads identity/devices/agent +/// itself and calls these — they are the single source of truth for the rules. pub struct StatusWorkflow; impl StatusWorkflow { - /// Query the current status of the Auths system. - /// - /// Args: - /// * `repo_path` - Path to the Auths repository. - /// * `now` - Current time for expiry calculations. - /// - /// Returns a StatusReport with identity, device, and agent state. - /// - /// This is a placeholder implementation; the real version will integrate - /// with IdentityStorage, AttestationSource, and agent discovery ports. - pub fn query(repo_path: &Path, _now: DateTime) -> Result { - let _ = repo_path; // Placeholder to avoid unused warning - // TODO: In full implementation, load identity from IdentityStorage - let identity = None; // Placeholder - - // TODO: In full implementation, load attestations from AttestationSource - // and aggregate by device with expiry checking - let devices = Vec::new(); // Placeholder - - // TODO: In full implementation, check agent socket and PID - let agent = AgentStatus { - running: false, - pid: None, - socket_path: None, - }; - - // Compute next steps based on current state - let next_steps = Self::compute_next_steps(&identity, &devices, &agent); - - Ok(StatusReport { - identity, - devices, - agent, - next_steps, - }) - } - - /// Compute suggested next steps from the aggregated state. - fn compute_next_steps( - identity: &Option, - devices: &[DeviceStatus], - agent: &AgentStatus, - ) -> Vec { - let readinesses: Vec = devices.iter().map(|d| d.readiness).collect(); - Self::next_steps_from_readiness(identity.is_some(), &readinesses, agent.running) - } - /// The next-step rules, keyed on the minimal facts they need — identity presence, each device's /// readiness, and whether the signing agent is live. The single source of truth for these rules, /// including the recovery single-point-of-failure signpost, so any presentation layer (the CLI) @@ -216,15 +162,7 @@ mod tests { #[test] fn test_next_steps_no_identity() { - let steps = StatusWorkflow::compute_next_steps( - &None, - &[], - &AgentStatus { - running: false, - pid: None, - socket_path: None, - }, - ); + let steps = StatusWorkflow::next_steps_from_readiness(false, &[], false); assert!(!steps.is_empty()); assert!(steps[0].command.contains("init")); } diff --git a/crates/auths-verifier/src/action.rs b/crates/auths-verifier/src/action.rs index af24918a..700b545b 100644 --- a/crates/auths-verifier/src/action.rs +++ b/crates/auths-verifier/src/action.rs @@ -97,6 +97,48 @@ impl ActionEnvelope { mod tests { use super::*; + /// The raw-seed `sign_action` → `verify_action_envelope` roundtrip the + /// Python bindings wrap, reproduced at the crate level: sign canonical bytes + /// with a raw Ed25519 seed and verify with the seed's RFC-8032 public key + /// after a JSON wire round-trip. + #[test] + fn raw_seed_action_sign_verify_roundtrip() { + use auths_crypto::{RingCryptoProvider, TypedSeed, typed_public_key, typed_sign}; + + let seed = [0xaau8; 32]; // Python test's TEST_SEED_HEX = "a" * 64 + let ts = TypedSeed::Ed25519(seed); + let pk = typed_public_key(&ts).expect("derive Ed25519 public key from seed"); + + // sign_action + let mut env = ActionEnvelope { + version: "1.0".into(), + action_type: "tool_call".into(), + identity: "did:keri:ETest123".into(), + payload: serde_json::json!({"tool": "read_file", "path": "/etc/config.json"}), + timestamp: "2026-07-04T00:00:00Z".into(), + signature: String::new(), + attestation_chain: None, + environment: None, + }; + let canonical = env.canonical_bytes().expect("canonical bytes at sign time"); + let sig = typed_sign(&ts, &canonical).expect("sign canonical bytes"); + env.signature = hex::encode(&sig); + + // wire round-trip (serialize → reparse), as the envelope crosses the boundary + let wire = serde_json::to_string(&env).expect("serialize envelope"); + let parsed: ActionEnvelope = serde_json::from_str(&wire).expect("reparse envelope"); + + // verify_action_envelope + let canonical2 = parsed.canonical_bytes().expect("canonical bytes at verify time"); + assert_eq!( + canonical, canonical2, + "canonical signing bytes changed across the JSON wire round-trip" + ); + let sig2 = hex::decode(&parsed.signature).expect("decode signature hex"); + RingCryptoProvider::ed25519_verify(&pk, &canonical2, &sig2) + .expect("raw-seed sign_action -> verify_action_envelope must round-trip"); + } + #[test] fn roundtrip_serialization() { let envelope = ActionEnvelope { diff --git a/docs/security/primitive-inventory.md b/docs/security/primitive-inventory.md new file mode 100644 index 00000000..6242ad3e --- /dev/null +++ b/docs/security/primitive-inventory.md @@ -0,0 +1,93 @@ +# Cryptographic primitive inventory + +**Authoritative table of every cryptographic primitive in the Auths workspace, +with the exact crate and pinned version providing it.** Referenced by +`SECURITY.md`. This file is drift-guarded: `scripts/check_primitive_inventory.py` +asserts every version in the pin table below matches the corresponding +`Cargo.toml` pin, and CI runs it (see `.github/workflows/`). A primitive added +or a pin bumped without updating this table fails the check. + +Format-normative crypto crates are **exact-pinned** (`=x.y.z`) because a silent +minor bump can change wire bytes and invalidate every existing signature; the +bump procedure is in `docs/security/dependency-policy.md`. + +## Providers + +Concrete backends selected at compile time (`auths-crypto`): + +| Provider | Feature | Primitives | +|---|---|---| +| `RingCryptoProvider` | default | Ed25519, SHA-2, HKDF/HMAC, system RNG | +| `AwsLcProvider` | `fips` | FIPS 140-3 backend (aws-lc-rs) | +| `CnsaProvider` | `cnsa` | P-384, AES-256-GCM, SHA-384 (rejects P-256/ChaCha/SHA-256) | +| `WebCryptoProvider` | wasm | browser SubtleCrypto + pure-Rust verify | + +## Pin table (machine-checked) + +Each row is ` = ` as pinned in `Cargo.toml`. The checker parses +this table; keep the `crate` / `version` columns exact. + +### Signatures + +| Primitive | crate | version | +|---|---|---| +| Ed25519 (RFC 8032), native | ring | 0.17.14 | +| Ed25519, pure-Rust verify (WASM leaf, pairing) | ed25519-dalek | 2 | +| ECDSA P-256 (RFC 6979 deterministic) | p256 | 0.13.2 | +| secp256k1 Schnorr (BIP-340, optional) | k256 | 0.13 | + +Post-quantum signatures (ML-DSA / FIPS 204) are **not yet in the tree**. Update +this section when they land. + +### Hashing + +| Primitive | crate | version | +|---|---|---| +| SHA-256 / SHA-384 / SHA-512 | sha2 | 0.10.9 | +| BLAKE3 (SAIDs, pre-rotation commitments, policy hashing) | blake3 | 1.8.4 | + +### Key derivation & MAC + +| Primitive | crate | version | +|---|---|---| +| Argon2id (at-rest key encryption) | argon2 | 0.5.3 | +| HKDF-SHA256/384 | hkdf | 0.12.4 | +| HMAC-SHA256/384 | hmac | 0.12.1 | + +### AEAD + +| Primitive | crate | version | +|---|---|---| +| ChaCha20-Poly1305 / XChaCha20-Poly1305 | chacha20poly1305 | 0.10.1 | +| AES-256-GCM | aes-gcm | 0.10.3 | + +### Key agreement + +| Primitive | crate | version | +|---|---|---| +| X25519 ECDH (pairing) | x25519-dalek | 2 | +| ML-KEM-768 (PQ-hybrid pairing, optional, **UNAUDITED**, off by default) | ml-kem | 0.2 | + +### Encoding & hygiene + +| Primitive | crate | version | +|---|---|---| +| CESR qb64 key encoding | cesride | 0.6 | +| JSON canonicalization (signing input) | json-canon | 0.1.3 | +| Constant-time comparison | subtle | 2.6.1 | +| Zeroization of secrets | zeroize | 1.8.2 | +| X.509 / TLS cert generation (optional) | rcgen | 0.14 | +| SSH key formats | ssh-key | 0.6.7 | +| PKCS#8 key encoding | pkcs8 | 0.10 | + +### Randomness + +CSPRNG only — `ring::rand::SystemRandom` (native), `p256`/`ed25519-dalek` over +`OsRng`, `getrandom` 0.4.1 (WASM). `thread_rng`/`rand::random` are lint-banned +workspace-wide (`clippy.toml`). See `docs/security/rng-policy.md`. + +### FIPS backend + +| Primitive | crate | version | +|---|---|---| +| FIPS 140-3 validated crypto (optional) | aws-lc-rs | 1.16 | diff --git a/packages/auths-python/tests/test_sign.py b/packages/auths-python/tests/test_sign.py index 0180cb09..ef6d01c2 100644 --- a/packages/auths-python/tests/test_sign.py +++ b/packages/auths-python/tests/test_sign.py @@ -72,12 +72,10 @@ def _get_public_key_hex(self): except ImportError: pytest.skip("cryptography package not installed") - @pytest.mark.xfail( - strict=False, - reason="auths#258: raw-seed sign_action/verify_action_envelope payload " - "mismatch (pre-existing on 0.1.2; keychain-backed envelope paths verify)", - ) def test_sign_and_verify_roundtrip(self): + # Raw-seed sign_action -> verify_action_envelope round-trips. The + # crate-level regression guard is + # auths-verifier action::tests::raw_seed_action_sign_verify_roundtrip. pub_hex = self._get_public_key_hex() envelope_json = sign_action( TEST_SEED_HEX, "tool_call", diff --git a/scripts/check_primitive_inventory.py b/scripts/check_primitive_inventory.py new file mode 100755 index 00000000..e6478992 --- /dev/null +++ b/scripts/check_primitive_inventory.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Drift guard for docs/security/primitive-inventory.md. + +Parses the pin table in the inventory doc and asserts every ` = ` +row matches a real pin in some Cargo.toml in the workspace. Fails (exit 1) if a +documented pin no longer matches the tree — so the inventory cannot silently +drift from what actually compiles. + +Run: python3 scripts/check_primitive_inventory.py (CI runs this) +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DOC = ROOT / "docs" / "security" / "primitive-inventory.md" + +# A pin-table row: | | | | +ROW = re.compile(r"^\|[^|]+\|\s*([a-z0-9][a-z0-9-]*)\s*\|\s*([0-9][0-9.]*)\s*\|\s*$") + + +def documented_pins(): + pins = {} # crate -> version (last wins; same crate always same version) + for line in DOC.read_text().splitlines(): + m = ROW.match(line) + if m: + pins[m.group(1)] = m.group(2) + return pins + + +def declared_versions(crate): + """Every version string the tree declares for `crate` (leading '=' stripped).""" + pat = re.compile(rf'^{re.escape(crate)}\s*=\s*(?:"([^"]+)"|\{{[^}}]*\bversion\s*=\s*"([^"]+)")') + found = set() + for toml in [ROOT / "Cargo.toml", *ROOT.glob("crates/*/Cargo.toml")]: + for line in toml.read_text().splitlines(): + m = pat.match(line.strip()) + if m: + found.add((m.group(1) or m.group(2)).lstrip("=")) + return found + + +def main(): + if not DOC.exists(): + print(f"MISSING: {DOC} does not exist", file=sys.stderr) + return 1 + pins = documented_pins() + if not pins: + print("no pin-table rows parsed — is the table format intact?", file=sys.stderr) + return 1 + bad = [] + for crate, want in sorted(pins.items()): + have = declared_versions(crate) + if want not in have: + bad.append((crate, want, sorted(have) or [""])) + if bad: + print("primitive-inventory.md drift — documented pin != Cargo.toml:", file=sys.stderr) + for crate, want, have in bad: + print(f" {crate}: doc says {want}, tree has {', '.join(have)}", file=sys.stderr) + return 1 + print(f"primitive-inventory.md: {len(pins)} pins all match the tree") + return 0 + + +if __name__ == "__main__": + sys.exit(main())