From 0f686f58e54b1efad087f622c15ad66f4f9d17f1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 03:42:47 +0100 Subject: [PATCH 1/6] fix(trust): honor --repo so pin/list/remove/show use the scoped store The trust subcommands built the pinned-identity store from the default ~/.auths path and ignored the global --repo flag, so a repo-scoped trust operation silently read, wrote, or deleted the global store. Resolve the store path through resolve_repo_path like init and device do; with --repo absent the path is identical to the previous default. --- crates/auths-cli/src/commands/trust.rs | 50 ++++++++++++------- crates/auths-cli/tests/cases/mod.rs | 1 + crates/auths-cli/tests/cases/trust_repo.rs | 57 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 crates/auths-cli/tests/cases/trust_repo.rs diff --git a/crates/auths-cli/src/commands/trust.rs b/crates/auths-cli/src/commands/trust.rs index 3b938c3a..1e23d46b 100644 --- a/crates/auths-cli/src/commands/trust.rs +++ b/crates/auths-cli/src/commands/trust.rs @@ -9,6 +9,7 @@ use auths_verifier::PublicKeyHex; use chrono::{DateTime, Utc}; use clap::{Parser, Subcommand}; use serde::Serialize; +use std::path::PathBuf; /// Manage trusted identity roots. #[derive(Parser, Debug, Clone)] @@ -132,20 +133,39 @@ struct PinDetails { kel_sequence: Option, } +/// Resolve the pinned-identity store for the active registry, honoring `--repo`. +/// +/// Args: +/// * `repo`: The optional `--repo` override; `None` selects the default `~/.auths` registry. +/// +/// Usage: +/// ```ignore +/// let store = pinned_store(ctx.repo_path.clone())?; +/// ``` +fn pinned_store(repo: Option) -> Result { + let registry = auths_sdk::storage_layout::resolve_repo_path(repo) + .context("Failed to resolve the repository path for the trust store")?; + let default = PinnedIdentityStore::default_path(); + let file_name = default + .file_name() + .ok_or_else(|| anyhow!("pin store path has no file name"))?; + Ok(PinnedIdentityStore::new(registry.join(file_name))) +} + /// Handle trust subcommands. #[allow(clippy::disallowed_methods)] -pub fn handle_trust(cmd: TrustCommand) -> Result<()> { +pub fn handle_trust(cmd: TrustCommand, repo: Option) -> Result<()> { + let store = pinned_store(repo)?; let now = Utc::now(); match cmd.command { - TrustSubcommand::List(list_cmd) => handle_list(list_cmd), - TrustSubcommand::Pin(pin_cmd) => handle_pin(pin_cmd, now), - TrustSubcommand::Remove(remove_cmd) => handle_remove(remove_cmd), - TrustSubcommand::Show(show_cmd) => handle_show(show_cmd), + TrustSubcommand::List(list_cmd) => handle_list(list_cmd, &store), + TrustSubcommand::Pin(pin_cmd) => handle_pin(pin_cmd, &store, now), + TrustSubcommand::Remove(remove_cmd) => handle_remove(remove_cmd, &store), + TrustSubcommand::Show(show_cmd) => handle_show(show_cmd, &store), } } -fn handle_list(_cmd: TrustListCommand) -> Result<()> { - let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path()); +fn handle_list(_cmd: TrustListCommand, store: &PinnedIdentityStore) -> Result<()> { let pins = store.list()?; if is_json_mode() { @@ -228,11 +248,9 @@ fn resolve_pin_key(cmd: &TrustPinCommand) -> Result<(PublicKeyHex, auths_crypto: Ok((PublicKeyHex::new_unchecked(hex::encode(pk)), curve)) } -fn handle_pin(cmd: TrustPinCommand, now: DateTime) -> Result<()> { +fn handle_pin(cmd: TrustPinCommand, store: &PinnedIdentityStore, now: DateTime) -> Result<()> { let (public_key_hex, curve) = resolve_pin_key(&cmd)?; - let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path()); - // Check if already pinned if let Some(existing) = store.lookup(&cmd.did)? { anyhow::bail!( @@ -276,9 +294,7 @@ fn handle_pin(cmd: TrustPinCommand, now: DateTime) -> Result<()> { Ok(()) } -fn handle_remove(cmd: TrustRemoveCommand) -> Result<()> { - let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path()); - +fn handle_remove(cmd: TrustRemoveCommand, store: &PinnedIdentityStore) -> Result<()> { // Check if exists if store.lookup(&cmd.did)?.is_none() { anyhow::bail!( @@ -310,9 +326,7 @@ fn handle_remove(cmd: TrustRemoveCommand) -> Result<()> { Ok(()) } -fn handle_show(cmd: TrustShowCommand) -> Result<()> { - let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path()); - +fn handle_show(cmd: TrustShowCommand, store: &PinnedIdentityStore) -> Result<()> { let pin = store.lookup(&cmd.did)?.ok_or_else(|| { anyhow!( "Identity {} is not pinned. Pin it first with: auths trust pin {}", @@ -360,7 +374,7 @@ use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; impl ExecutableCommand for TrustCommand { - fn execute(&self, _ctx: &CliConfig) -> Result<()> { - handle_trust(self.clone()) + fn execute(&self, ctx: &CliConfig) -> Result<()> { + handle_trust(self.clone(), ctx.repo_path.clone()) } } diff --git a/crates/auths-cli/tests/cases/mod.rs b/crates/auths-cli/tests/cases/mod.rs index 43e2f3be..b8214ffe 100644 --- a/crates/auths-cli/tests/cases/mod.rs +++ b/crates/auths-cli/tests/cases/mod.rs @@ -12,6 +12,7 @@ mod revocation; mod sign_commit_binding; mod sign_verify; mod sign_verify_roundtrip; +mod trust_repo; mod verify; mod verify_commit; mod verify_identity_bundle; diff --git a/crates/auths-cli/tests/cases/trust_repo.rs b/crates/auths-cli/tests/cases/trust_repo.rs new file mode 100644 index 00000000..366a60e6 --- /dev/null +++ b/crates/auths-cli/tests/cases/trust_repo.rs @@ -0,0 +1,57 @@ +use super::helpers::TestEnv; +use std::fs; + +/// Extract the `did:keri:` identity DID from `auths whoami --json` output. +fn extract_did(json: &str) -> String { + let start = json.find("did:keri:").expect("a did:keri: identity in whoami json"); + let rest = &json[start..]; + let end = rest + .find(|c: char| c == '"' || c == ',' || c.is_whitespace()) + .unwrap_or(rest.len()); + rest[..end].to_string() +} + +/// `trust pin --repo ` must write the pin into that registry, not the +/// default `~/.auths` store. Guards the confused-deputy bug where `--repo` was +/// parsed but silently ignored, so a repo-scoped pin mutated global trust. +#[test] +fn trust_pin_honors_repo_override() { + let env = TestEnv::new(); + env.init_identity(); + + let whoami = env.cmd("auths").args(["whoami", "--json"]).output().unwrap(); + assert!(whoami.status.success()); + let did = extract_did(&String::from_utf8_lossy(&whoami.stdout)); + + let alt = env.home.path().join("alt-registry"); + let out = env + .cmd("auths") + .args([ + "trust", + "pin", + "--did", + &did, + "--repo", + alt.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "trust pin failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let alt_store = alt.join("known_identities.json"); + assert!( + alt_store.exists(), + "pin did not land in the --repo store; --repo was ignored" + ); + assert!(fs::read_to_string(&alt_store).unwrap().contains(&did)); + + let default_store = env.auths_home.join("known_identities.json"); + let leaked = default_store.exists() + && fs::read_to_string(&default_store).unwrap().contains(&did); + assert!(!leaked, "pin leaked into the default store despite --repo"); +} From 812adf75b7ff386b285e532ae6b4d3f849ab0c70 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 03:47:14 +0100 Subject: [PATCH 2/6] fix(sign): reject control characters in --scope to prevent trailer injection A newline in a --scope value was emitted verbatim into the single-line Auths-Scope commit trailer, splitting it into an attacker-chosen extra trailer (e.g. a second Auths-Id) that a verifier resolved instead of the true signer. Validate scope tokens and reject control characters before building the commit trailers. --- crates/auths-cli/src/commands/sign.rs | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 45353264..00d25605 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -62,6 +62,31 @@ const ARTIFACT_EXTENSIONS: &[&str] = &[ ".pkg", ".nupkg", ]; +/// Reject capability scope values that carry control characters. +/// +/// A scope value rides in a single-line `Auths-Scope` commit trailer; a newline +/// (or other control character) would split it into an attacker-chosen extra +/// trailer — for example a second `Auths-Id` — which a verifier would then +/// resolve instead of the real signer. +/// +/// Args: +/// * `scope`: The capability tokens supplied via `--scope`. +/// +/// Usage: +/// ```ignore +/// validate_scope(&scope)?; +/// ``` +fn validate_scope(scope: &[String]) -> Result<()> { + for value in scope { + if value.chars().any(char::is_control) { + anyhow::bail!( + "Invalid --scope value {value:?}: control characters (including newlines) are not allowed" + ); + } + } + Ok(()) +} + /// Build the in-band signer trailers for the local machine's signing identity: /// `Auths-Id` = root identity, `Auths-Device` = signing device, and (when the root /// KEL tip is known) `Auths-Anchor-Seq` = the delegator-anchoring position at @@ -242,6 +267,7 @@ fn short_sha(sha: &str) -> &str { /// * `scope` - Capabilities this commit claims (emitted as an `Auths-Scope` trailer). fn sign_commit_range(range: &str, signer: &LocalSigner, scope: &[String]) -> Result<()> { ensure_repo_root_pin(signer); + validate_scope(scope)?; let trailers = commit_trailer_args(signer, scope); let is_range = range.contains(".."); if is_range { @@ -466,6 +492,17 @@ mod tests { ); } + #[test] + fn validate_scope_rejects_control_chars() { + // A newline would split the single-line Auths-Scope trailer, injecting an + // attacker-chosen trailer (e.g. a forged Auths-Id) into the signed body. + assert!(validate_scope(&["legit\nAuths-Id: did:keri:Eattacker".to_string()]).is_err()); + assert!(validate_scope(&["carriage\rreturn".to_string()]).is_err()); + assert!(validate_scope(&["tab\there".to_string()]).is_err()); + assert!(validate_scope(&["sign_commit".to_string(), "open-PR".to_string()]).is_ok()); + assert!(validate_scope(&[]).is_ok()); + } + #[test] fn commit_trailer_args_root_machine_signs_directly() { // On the root machine signer == root → both trailers carry the same DID. From 16d86bf2a81cd18d9080dce4aa2fb4f7fee0b1b4 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 04:13:10 +0100 Subject: [PATCH 3/6] style: apply rustfmt to trust --repo test --- crates/auths-cli/tests/cases/trust_repo.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/auths-cli/tests/cases/trust_repo.rs b/crates/auths-cli/tests/cases/trust_repo.rs index 366a60e6..19bed6ce 100644 --- a/crates/auths-cli/tests/cases/trust_repo.rs +++ b/crates/auths-cli/tests/cases/trust_repo.rs @@ -3,7 +3,9 @@ use std::fs; /// Extract the `did:keri:` identity DID from `auths whoami --json` output. fn extract_did(json: &str) -> String { - let start = json.find("did:keri:").expect("a did:keri: identity in whoami json"); + let start = json + .find("did:keri:") + .expect("a did:keri: identity in whoami json"); let rest = &json[start..]; let end = rest .find(|c: char| c == '"' || c == ',' || c.is_whitespace()) @@ -19,7 +21,11 @@ fn trust_pin_honors_repo_override() { let env = TestEnv::new(); env.init_identity(); - let whoami = env.cmd("auths").args(["whoami", "--json"]).output().unwrap(); + let whoami = env + .cmd("auths") + .args(["whoami", "--json"]) + .output() + .unwrap(); assert!(whoami.status.success()); let did = extract_did(&String::from_utf8_lossy(&whoami.stdout)); @@ -51,7 +57,7 @@ fn trust_pin_honors_repo_override() { assert!(fs::read_to_string(&alt_store).unwrap().contains(&did)); let default_store = env.auths_home.join("known_identities.json"); - let leaked = default_store.exists() - && fs::read_to_string(&default_store).unwrap().contains(&did); + let leaked = + default_store.exists() && fs::read_to_string(&default_store).unwrap().contains(&did); assert!(!leaked, "pin leaked into the default store despite --repo"); } From 265fb97efe07a8c4d44489f55992122ed89635dd Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 21:49:08 +0100 Subject: [PATCH 4/6] fix(cli): thread --repo through storage commands, guard registry SSRF, gate destructive/overwrite ops, and lock it in with --repo guardrail + forgery-resistance tests Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/commands/agent/mod.rs | 91 ++++-- .../auths-cli/src/commands/agent/service.rs | 55 +++- crates/auths-cli/src/commands/artifact/mod.rs | 9 + .../auths-cli/src/commands/artifact/sign.rs | 61 ++++ crates/auths-cli/src/commands/config.rs | 74 +++-- crates/auths-cli/src/commands/device/mod.rs | 23 +- crates/auths-cli/src/commands/key.rs | 12 +- crates/auths-cli/src/commands/publish.rs | 1 + crates/auths-cli/src/commands/reset.rs | 15 +- crates/auths-cli/src/commands/sign.rs | 5 + crates/auths-cli/src/commands/trust.rs | 76 ++++- crates/auths-cli/tests/cases/config_repo.rs | 52 ++++ .../tests/cases/forgery_resistance.rs | 264 ++++++++++++++++++ crates/auths-cli/tests/cases/key_repo.rs | 29 ++ crates/auths-cli/tests/cases/mod.rs | 7 + crates/auths-cli/tests/cases/pair_repo.rs | 56 ++++ .../auths-cli/tests/cases/repo_consistency.rs | 74 +++++ crates/auths-cli/tests/cases/reset_repo.rs | 55 ++++ .../tests/cases/trust_pin_control.rs | 154 ++++++++++ crates/auths-infra-http/src/lib.rs | 1 + crates/auths-infra-http/src/pairing_client.rs | 24 ++ .../auths-infra-http/src/registry_client.rs | 14 + crates/auths-infra-http/src/ssrf.rs | 148 ++++++++++ 23 files changed, 1225 insertions(+), 75 deletions(-) create mode 100644 crates/auths-cli/tests/cases/config_repo.rs create mode 100644 crates/auths-cli/tests/cases/forgery_resistance.rs create mode 100644 crates/auths-cli/tests/cases/key_repo.rs create mode 100644 crates/auths-cli/tests/cases/pair_repo.rs create mode 100644 crates/auths-cli/tests/cases/repo_consistency.rs create mode 100644 crates/auths-cli/tests/cases/reset_repo.rs create mode 100644 crates/auths-cli/tests/cases/trust_pin_control.rs create mode 100644 crates/auths-infra-http/src/ssrf.rs diff --git a/crates/auths-cli/src/commands/agent/mod.rs b/crates/auths-cli/src/commands/agent/mod.rs index 421bf0d1..a9a1f179 100644 --- a/crates/auths-cli/src/commands/agent/mod.rs +++ b/crates/auths-cli/src/commands/agent/mod.rs @@ -162,7 +162,35 @@ pub fn ensure_agent_running(quiet: bool) -> Result { Err(anyhow!("Failed to start agent within 2 seconds")) } -pub fn handle_agent(cmd: AgentCommand) -> Result<()> { +/// The name of a per-machine daemon operation, or `None` for a store operation. +/// +/// Daemon operations (start/stop/status/install-service/uninstall-service) act +/// on the per-machine agent and cannot be scoped by `--repo`. Store operations +/// (env/lock/unlock) read repo-scoped paths. +fn daemon_op_name(cmd: &AgentSubcommand) -> Option<&'static str> { + match cmd { + AgentSubcommand::Start { .. } => Some("start"), + AgentSubcommand::Stop => Some("stop"), + AgentSubcommand::Status => Some("status"), + AgentSubcommand::InstallService { .. } => Some("install-service"), + AgentSubcommand::UninstallService => Some("uninstall-service"), + AgentSubcommand::Env { .. } | AgentSubcommand::Lock | AgentSubcommand::Unlock { .. } => None, + } +} + +pub fn handle_agent(cmd: AgentCommand, repo: Option) -> Result<()> { + // Daemon operations are per-machine; `--repo` cannot scope them, so reject + // it rather than silently operate on the global agent. + if let Some(op) = daemon_op_name(&cmd.command) + && repo.is_some() + { + anyhow::bail!( + "`--repo` is not supported for `auths agent {}`; the agent daemon is per-machine \ + and is selected by AUTHS_HOME, not by repository.", + op + ); + } + match cmd.command { AgentSubcommand::Start { socket, @@ -171,15 +199,16 @@ pub fn handle_agent(cmd: AgentCommand) -> Result<()> { } => start_agent(socket, foreground, &timeout, false), AgentSubcommand::Stop => stop_agent(), AgentSubcommand::Status => show_status(), - AgentSubcommand::Env { shell } => output_env(shell), - AgentSubcommand::Lock => lock_agent(), - AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias), AgentSubcommand::InstallService { dry_run, force, manager, } => service::install_service(dry_run, force, manager), AgentSubcommand::UninstallService => service::uninstall_service(), + // Store operations read repo-scoped paths; thread `--repo` through. + AgentSubcommand::Env { shell } => output_env(shell, repo), + AgentSubcommand::Lock => lock_agent(repo), + AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias, repo), } } @@ -219,6 +248,34 @@ fn get_auths_dir() -> Result { auths_sdk::paths::auths_home().map_err(|e| anyhow!(e)) } +/// Resolve the agent's storage directory, honoring a `--repo` override. +/// +/// `None` preserves the default (`AUTHS_HOME` / `~/.auths`); `Some(path)` +/// scopes the agent's socket/pid/env files to that registry. +/// +/// Args: +/// * `repo`: The optional `--repo` override. +/// +/// Usage: +/// ```ignore +/// let dir = get_auths_dir_for_repo(ctx.repo_path.clone())?; +/// ``` +fn get_auths_dir_for_repo(repo: Option) -> Result { + match repo { + Some(_) => auths_sdk::storage_layout::resolve_repo_path(repo) + .context("Failed to resolve the repository path for the agent store"), + None => get_auths_dir(), + } +} + +fn get_socket_path_for_repo(repo: Option) -> Result { + Ok(get_auths_dir_for_repo(repo)?.join(DEFAULT_SOCKET_NAME)) +} + +fn get_pid_file_path_for_repo(repo: Option) -> Result { + Ok(get_auths_dir_for_repo(repo)?.join(PID_FILE_NAME)) +} + /// Get the default socket path. pub fn get_default_socket_path() -> Result { Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME)) @@ -465,9 +522,9 @@ fn show_status() -> Result<()> { Ok(()) } -fn output_env(shell: ShellFormat) -> Result<()> { - let socket_path = get_default_socket_path()?; - let pid_path = get_pid_file_path()?; +fn output_env(shell: ShellFormat, repo: Option) -> Result<()> { + let socket_path = get_socket_path_for_repo(repo.clone())?; + let pid_path = get_pid_file_path_for_repo(repo)?; let pid = read_pid_file(&pid_path)?; let running = pid.map(is_process_running).unwrap_or(false); @@ -501,8 +558,8 @@ fn output_env(shell: ShellFormat) -> Result<()> { } #[cfg(unix)] -fn lock_agent() -> Result<()> { - let pid_path = get_pid_file_path()?; +fn lock_agent(repo: Option) -> Result<()> { + let pid_path = get_pid_file_path_for_repo(repo.clone())?; let pid = read_pid_file(&pid_path)?; let running = pid.map(is_process_running).unwrap_or(false); @@ -510,7 +567,7 @@ fn lock_agent() -> Result<()> { return Err(anyhow!("Agent is not running")); } - let socket_path = get_default_socket_path()?; + let socket_path = get_socket_path_for_repo(repo)?; auths_sdk::agent_core::remove_all_identities(&socket_path) .map_err(|e| anyhow!("Failed to lock agent: {}", e))?; @@ -521,15 +578,15 @@ fn lock_agent() -> Result<()> { } #[cfg(not(unix))] -fn lock_agent() -> Result<()> { +fn lock_agent(_repo: Option) -> Result<()> { Err(anyhow!( "Agent lock is not supported on this platform (requires Unix domain sockets)" )) } #[cfg(unix)] -fn unlock_agent(key_alias: &str) -> Result<()> { - let pid_path = get_pid_file_path()?; +fn unlock_agent(key_alias: &str, repo: Option) -> Result<()> { + let pid_path = get_pid_file_path_for_repo(repo.clone())?; let pid = read_pid_file(&pid_path)?; let running = pid.map(is_process_running).unwrap_or(false); @@ -537,7 +594,7 @@ fn unlock_agent(key_alias: &str) -> Result<()> { return Err(anyhow!("Agent is not running")); } - let socket_path = get_default_socket_path()?; + let socket_path = get_socket_path_for_repo(repo)?; let keychain = auths_sdk::keychain::get_platform_keychain() .map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?; @@ -571,15 +628,15 @@ fn unlock_agent(key_alias: &str) -> Result<()> { } #[cfg(not(unix))] -fn unlock_agent(_key_alias: &str) -> Result<()> { +fn unlock_agent(_key_alias: &str, _repo: Option) -> Result<()> { Err(anyhow!( "Agent unlock is not supported on this platform (requires Unix domain sockets)" )) } impl crate::commands::executable::ExecutableCommand for AgentCommand { - fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> { - handle_agent(self.clone()) + fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { + handle_agent(self.clone(), ctx.repo_path.clone()) } } diff --git a/crates/auths-cli/src/commands/agent/service.rs b/crates/auths-cli/src/commands/agent/service.rs index b2e2a587..0bc1e624 100644 --- a/crates/auths-cli/src/commands/agent/service.rs +++ b/crates/auths-cli/src/commands/agent/service.rs @@ -3,10 +3,49 @@ use anyhow::{Context, Result, anyhow}; use clap::ValueEnum; use std::fs; -use std::path::PathBuf; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; use super::{get_default_socket_path, get_log_file_path}; +/// Gate overwriting an already-installed service unit. +/// +/// `--force` is explicit consent and proceeds. Otherwise this confirms interactively (a service unit +/// controls what runs as the agent, so replacing it is a privileged change) and refuses when there is no +/// terminal to confirm on. +/// +/// Args: +/// * `path`: The service unit that already exists. +/// * `force`: Whether `--force` was passed. +/// +/// Usage: +/// ```ignore +/// if unit_path.exists() { confirm_overwrite(&unit_path, force)?; } +/// ``` +fn confirm_overwrite(path: &Path, force: bool) -> Result<()> { + if force { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + return Err(anyhow!( + "Service already installed at {}. Use --force to overwrite.", + path.display() + )); + } + let confirmed = dialoguer::Confirm::new() + .with_prompt(format!( + "A service is already installed at {}. Overwrite it? This replaces what runs as your agent.", + path.display() + )) + .default(false) + .interact() + .context("Failed to read overwrite confirmation")?; + if !confirmed { + return Err(anyhow!("Aborted: the existing service was left unchanged.")); + } + Ok(()) +} + /// Service manager type for platform-specific service installation. #[derive(ValueEnum, Clone, Debug, PartialEq)] pub enum ServiceManager { @@ -168,11 +207,8 @@ fn install_launchd_service(dry_run: bool, force: bool) -> Result<()> { return Ok(()); } - if plist_path.exists() && !force { - return Err(anyhow!( - "Service already installed at {}. Use --force to overwrite.", - plist_path.display() - )); + if plist_path.exists() { + confirm_overwrite(&plist_path, force)?; } if let Some(parent) = plist_path.parent() { @@ -204,11 +240,8 @@ fn install_systemd_service(dry_run: bool, force: bool) -> Result<()> { return Ok(()); } - if unit_path.exists() && !force { - return Err(anyhow!( - "Service already installed at {}. Use --force to overwrite.", - unit_path.display() - )); + if unit_path.exists() { + confirm_overwrite(&unit_path, force)?; } if let Some(parent) = unit_path.parent() { diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index b20eecf5..9ece0908 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -54,6 +54,10 @@ pub enum ArtifactSubcommand { #[arg(long = "sig-output", value_name = "PATH")] sig_output: Option, + /// Overwrite the --sig-output file if it already exists. + #[arg(long)] + force: bool, + /// Local alias of the identity key (used for signing). Omit for CI device-only signing. #[arg( long, @@ -427,6 +431,7 @@ pub fn handle_artifact( ArtifactSubcommand::Sign { file, sig_output, + force, key, device_key, expires_in, @@ -561,6 +566,8 @@ pub fn handle_artifact( p }); + sign::ensure_writable(&output_path, force)?; + std::fs::write(&output_path, &final_json) .with_context(|| format!("Failed to write signature to {:?}", output_path))?; @@ -596,6 +603,7 @@ pub fn handle_artifact( env_config, &log, allow_unlogged, + force, ) } } @@ -639,6 +647,7 @@ pub fn handle_artifact( env_config, &None, false, + false, )?; default_sig } diff --git a/crates/auths-cli/src/commands/artifact/sign.rs b/crates/auths-cli/src/commands/artifact/sign.rs index 8b35dbb4..3f4001bf 100644 --- a/crates/auths-cli/src/commands/artifact/sign.rs +++ b/crates/auths-cli/src/commands/artifact/sign.rs @@ -13,6 +13,30 @@ use super::file::FileArtifact; use super::{dsse_pae, merge_transparency, submit_to_log}; use crate::factories::storage::build_auths_context; +/// Refuse to write over an existing file unless the caller opts in. +/// +/// The signature output path is taken verbatim from the user (or auto-derived next to the +/// artifact), so an unguarded write would silently replace whatever already lives there. +/// +/// Args: +/// * `path`: the path the signature would be written to. +/// * `force`: when true, an existing file is allowed to be overwritten. +/// +/// Usage: +/// ```ignore +/// ensure_writable(&output_path, force)?; +/// std::fs::write(&output_path, &final_json)?; +/// ``` +pub(super) fn ensure_writable(path: &Path, force: bool) -> Result<()> { + if !force && path.exists() { + anyhow::bail!( + "refusing to overwrite existing file {:?}; pass --force to overwrite", + path + ); + } + Ok(()) +} + /// Execute the `artifact sign` command. #[allow(clippy::too_many_arguments)] pub fn handle_sign( @@ -28,6 +52,7 @@ pub fn handle_sign( env_config: &EnvironmentConfig, log: &Option, allow_unlogged: bool, + force: bool, ) -> Result<()> { let repo_path = auths_sdk::storage_layout::resolve_repo_path(repo_opt)?; @@ -87,6 +112,8 @@ pub fn handle_sign( p }); + ensure_writable(&output_path, force)?; + std::fs::write(&output_path, &final_json) .with_context(|| format!("Failed to write signature to {:?}", output_path))?; @@ -122,3 +149,37 @@ fn print_authorization_scope(attestation_json: &str) { } } } + +#[cfg(test)] +mod tests { + use super::ensure_writable; + use std::fs; + use tempfile::tempdir; + + #[test] + fn refuses_existing_file_without_force() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sig.auths.json"); + fs::write(&path, b"existing").unwrap(); + + let err = ensure_writable(&path, false).unwrap_err(); + assert!(err.to_string().contains("refusing to overwrite")); + } + + #[test] + fn allows_existing_file_with_force() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sig.auths.json"); + fs::write(&path, b"existing").unwrap(); + + assert!(ensure_writable(&path, true).is_ok()); + } + + #[test] + fn allows_new_path_without_force() { + let dir = tempdir().unwrap(); + let path = dir.path().join("new.auths.json"); + + assert!(ensure_writable(&path, false).is_ok()); + } +} diff --git a/crates/auths-cli/src/commands/config.rs b/crates/auths-cli/src/commands/config.rs index ac8a08e4..a84b2386 100644 --- a/crates/auths-cli/src/commands/config.rs +++ b/crates/auths-cli/src/commands/config.rs @@ -2,11 +2,13 @@ use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; -use anyhow::{Result, bail}; -use auths_sdk::core_config::{AuthsConfig, PassphraseCachePolicy, load_config, save_config}; +use anyhow::{Context, Result, bail}; +use auths_sdk::core_config::{AuthsConfig, PassphraseCachePolicy}; +use auths_sdk::ports::ConfigStore; use crate::adapters::config_store::FileConfigStore; use clap::{Parser, Subcommand}; +use std::path::{Path, PathBuf}; /// Manage Auths configuration. #[derive(Parser, Debug, Clone)] @@ -53,18 +55,55 @@ pub enum ConfigAction { } impl ExecutableCommand for ConfigCommand { - fn execute(&self, _ctx: &CliConfig) -> Result<()> { + fn execute(&self, ctx: &CliConfig) -> Result<()> { + let path = config_path(ctx.repo_path.clone())?; match &self.action { - ConfigAction::Set { key, value } => execute_set(key, value), - ConfigAction::Get { key } => execute_get(key), - ConfigAction::Show => execute_show(), + ConfigAction::Set { key, value } => execute_set(key, value, &path), + ConfigAction::Get { key } => execute_get(key, &path), + ConfigAction::Show => execute_show(&path), } } } -fn execute_set(key: &str, value: &str) -> Result<()> { - let store = FileConfigStore; - let mut config = load_config(&store); +/// Resolves the `config.toml` path for the active registry, honoring `--repo`. +/// +/// Args: +/// * `repo`: The optional `--repo` override; `None` selects the default +/// `~/.auths` registry, matching the path used when `--repo` is absent. +/// +/// Usage: +/// ```ignore +/// let path = config_path(ctx.repo_path.clone())?; +/// ``` +fn config_path(repo: Option) -> Result { + let dir = match repo { + Some(_) => auths_sdk::storage_layout::resolve_repo_path(repo) + .context("Failed to resolve the repository path for the config file")?, + None => auths_sdk::paths::auths_home() + .map_err(|e| anyhow::anyhow!("Failed to resolve the Auths home directory: {e}"))?, + }; + Ok(dir.join("config.toml")) +} + +/// Reads the config at `path`, returning defaults when the file is absent. +fn read_config(path: &Path) -> AuthsConfig { + match FileConfigStore.read(path) { + Ok(Some(contents)) => toml::from_str(&contents).unwrap_or_default(), + _ => AuthsConfig::default(), + } +} + +/// Writes the config to `path`, creating parent directories as needed. +fn write_config(config: &AuthsConfig, path: &Path) -> Result<()> { + let contents = toml::to_string_pretty(config) + .map_err(|e| anyhow::anyhow!("Failed to serialize config: {e}"))?; + FileConfigStore + .write(path, &contents) + .map_err(|e| anyhow::anyhow!("{e}")) +} + +fn execute_set(key: &str, value: &str, path: &Path) -> Result<()> { + let mut config = read_config(path); match key { "passphrase.cache" => { @@ -88,13 +127,13 @@ fn execute_set(key: &str, value: &str) -> Result<()> { ), } - save_config(&config, &store)?; + write_config(&config, path)?; println!("Set {} = {}", key, value); Ok(()) } -fn execute_get(key: &str) -> Result<()> { - let config = load_config(&FileConfigStore); +fn execute_get(key: &str, path: &Path) -> Result<()> { + let config = read_config(path); match key { "passphrase.cache" => { @@ -119,8 +158,8 @@ fn execute_get(key: &str) -> Result<()> { Ok(()) } -fn execute_show() -> Result<()> { - let config = load_config(&FileConfigStore); +fn execute_show(path: &Path) -> Result<()> { + let config = read_config(path); if crate::ux::format::is_json_mode() { let json = serde_json::to_string_pretty(&config) .map_err(|e| anyhow::anyhow!("Failed to serialize config as JSON: {}", e))?; @@ -162,10 +201,3 @@ fn parse_bool(s: &str) -> Result { _ => bail!("Invalid boolean '{}'. Use true/false, yes/no, or 1/0", s), } } - -fn _ensure_default_config_exists() -> Result { - let store = FileConfigStore; - let config = load_config(&store); - save_config(&config, &store)?; - Ok(config) -} diff --git a/crates/auths-cli/src/commands/device/mod.rs b/crates/auths-cli/src/commands/device/mod.rs index d05bdf99..f39d3a03 100644 --- a/crates/auths-cli/src/commands/device/mod.rs +++ b/crates/auths-cli/src/commands/device/mod.rs @@ -8,15 +8,26 @@ pub use verify_attestation::{VerifyCommand, handle_verify}; use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; -use anyhow::Result; +use anyhow::{Context, Result}; impl ExecutableCommand for PairCommand { fn execute(&self, ctx: &CliConfig) -> Result<()> { - handle_pair( - self.clone(), - ctx.passphrase_provider.clone(), - &ctx.env_config, - ) + // Pairing resolves its store from `env_config.auths_home`. When `--repo` + // is given, fold the resolved registry path into a derived config so the + // pairing store is scoped to that repo. Absent `--repo`, the unchanged + // config preserves the AUTHS_HOME / `~/.auths` default. + let env_config = match &ctx.repo_path { + Some(_) => { + let registry = auths_sdk::storage_layout::resolve_repo_path(ctx.repo_path.clone()) + .context("Failed to resolve the repository path for the pairing store")?; + let mut scoped = ctx.env_config.clone(); + scoped.auths_home = Some(registry); + scoped + } + None => ctx.env_config.clone(), + }; + + handle_pair(self.clone(), ctx.passphrase_provider.clone(), &env_config) } } diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index 097f760c..6ec0c3fb 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -509,7 +509,17 @@ use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; impl ExecutableCommand for KeyCommand { - fn execute(&self, _ctx: &CliConfig) -> Result<()> { + fn execute(&self, ctx: &CliConfig) -> Result<()> { + // Every `key` subcommand operates on the per-machine keychain selected by + // AUTHS_KEYCHAIN_* (backend, file, passphrase) — never on a repo-scoped + // registry. `--repo` cannot point at the keychain, so accepting it would + // silently act on the global store. Reject it instead of ignoring it. + if ctx.repo_path.is_some() { + anyhow::bail!( + "`--repo` is not supported for `auths key`; the keychain is selected by \ + AUTHS_KEYCHAIN_BACKEND / AUTHS_KEYCHAIN_FILE, not by repository." + ); + } handle_key(self.clone()) } } diff --git a/crates/auths-cli/src/commands/publish.rs b/crates/auths-cli/src/commands/publish.rs index 0004bea0..a90c9b94 100644 --- a/crates/auths-cli/src/commands/publish.rs +++ b/crates/auths-cli/src/commands/publish.rs @@ -66,6 +66,7 @@ impl ExecutableCommand for PublishCommand { &ctx.env_config, &None, false, + false, )?; } p diff --git a/crates/auths-cli/src/commands/reset.rs b/crates/auths-cli/src/commands/reset.rs index 5e265976..7c2390b9 100644 --- a/crates/auths-cli/src/commands/reset.rs +++ b/crates/auths-cli/src/commands/reset.rs @@ -60,7 +60,7 @@ impl ExecutableCommand for ResetCommand { out.print_heading("Resetting Auths..."); out.newline(); - remove_auths_directory(&out)?; + remove_auths_directory(&out, ctx.repo_path.clone())?; clear_keychain_keys(&out, &ctx.env_config); unset_git_signing_config(&out)?; @@ -71,10 +71,15 @@ impl ExecutableCommand for ResetCommand { } } -fn remove_auths_directory(out: &Output) -> Result<()> { - let auths_dir = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))? - .join(".auths"); +/// Removes the resolved Auths storage directory, honoring `--repo`. +/// +/// Args: +/// * `out`: Output sink for progress messages. +/// * `repo`: The optional `--repo` override; `None` selects the default +/// `~/.auths` directory, matching the path used when `--repo` is absent. +fn remove_auths_directory(out: &Output, repo: Option) -> Result<()> { + let auths_dir = auths_sdk::storage_layout::resolve_repo_path(repo) + .context("Failed to resolve the repository path to reset")?; if auths_dir.exists() { std::fs::remove_dir_all(&auths_dir) diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 00d25605..c98a9fc2 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -347,6 +347,10 @@ pub struct SignCommand { #[arg(long = "sig-output", value_name = "PATH")] pub sig_output: Option, + /// Overwrite the signature output file if it already exists. + #[arg(long)] + pub force: bool, + /// Local alias of the identity key (for artifact signing). #[arg(long)] pub key: Option, @@ -406,6 +410,7 @@ pub fn handle_sign_unified( env_config, &None, false, + cmd.force, ) } SignTarget::CommitRange(range) => { diff --git a/crates/auths-cli/src/commands/trust.rs b/crates/auths-cli/src/commands/trust.rs index 1e23d46b..d80f35b2 100644 --- a/crates/auths-cli/src/commands/trust.rs +++ b/crates/auths-cli/src/commands/trust.rs @@ -207,15 +207,69 @@ fn handle_list(_cmd: TrustListCommand, store: &PinnedIdentityStore) -> Result<() Ok(()) } +/// Resolve the current signing key for `did` from the local registry. +/// +/// Returns `Ok(Some(..))` when the DID's KEL is locally held and replays to a +/// current key, `Ok(None)` when no KEL for the DID exists locally (the +/// air-gapped case), and `Err(..)` for any other resolution failure (malformed +/// DID, corrupt KEL, backend fault) — those are not treated as "absent". +fn resolve_local_current_key(did: &str) -> Result> { + let auths_home = auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))?; + let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked( + auths_sdk::storage::RegistryConfig::single_tenant(&auths_home), + ); + match auths_sdk::keri::resolve_current_public_key(®istry, did) { + Ok((pk, curve)) => { + #[allow(clippy::disallowed_methods)] // INVARIANT: hex::encode always produces valid hex + let hex_key = PublicKeyHex::new_unchecked(hex::encode(pk)); + Ok(Some((hex_key, curve))) + } + Err(err) if is_kel_not_found(&err) => Ok(None), + Err(e) => Err(anyhow!(e)).with_context(|| { + format!("Could not resolve the current key for {did} from the local registry") + }), + } +} + +/// Whether a current-key resolution failure means "no local KEL for this DID" +/// as opposed to a real fault (malformed DID, corrupt KEL, backend error). +/// +/// The not-found case carries `KelResolveError::NotFound`, which renders as +/// `"KEL not found for "` from both the local-registry collector and the +/// resolver chain. The rendered message is matched here because the inner error +/// type is not re-exported on the CLI's dependency path; every other failure +/// renders differently and is treated as a real fault, so the explicit `--key` +/// air-gap allowance is taken only for a genuine absence. +fn is_kel_not_found(err: &auths_sdk::keri::CurrentKeyError) -> bool { + err.to_string().contains("KEL not found for") +} + /// Resolve the key material for a pin: explicit `--key` hex, a `--bundle` /// file, or the identity's locally-replayed KEL — in that order. Humans never /// have to produce raw hex on the happy path. +/// +/// An explicit `--key` is cross-checked against the DID's current key in the +/// local key history (KEL) when that history is available: pinning a key the +/// identity does not control is refused. The explicit key is honored without a +/// cross-check only when no local KEL for the DID exists, which is the +/// air-gapped ceremony case. fn resolve_pin_key(cmd: &TrustPinCommand) -> Result<(PublicKeyHex, auths_crypto::CurveType)> { if let Some(ref key_hex) = cmd.key { let public_key_hex = PublicKeyHex::parse(key_hex).context("Invalid public key hex")?; let curve = auths_crypto::did_key_decode(&cmd.did) .map(|d| d.curve()) .unwrap_or_default(); + if let Some((kel_key, kel_curve)) = resolve_local_current_key(&cmd.did)? { + if kel_key != public_key_hex { + anyhow::bail!( + "the supplied --key does not match the current key in {}'s key history \ + (KEL); refusing to pin a key the identity does not control. Omit --key to \ + pin the KEL-resolved key, or use --bundle.", + cmd.did + ); + } + return Ok((kel_key, kel_curve)); + } return Ok((public_key_hex, curve)); } if let Some(ref bundle_path) = cmd.bundle { @@ -232,20 +286,14 @@ fn resolve_pin_key(cmd: &TrustPinCommand) -> Result<(PublicKeyHex, auths_crypto: } return Ok((bundle.public_key_hex.clone(), bundle.curve)); } - let auths_home = auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))?; - let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked( - auths_sdk::storage::RegistryConfig::single_tenant(&auths_home), - ); - let (pk, curve) = auths_sdk::keri::resolve_current_public_key(®istry, &cmd.did) - .with_context(|| { - format!( - "Could not resolve {} from the local registry. Provide --bundle \ - (ask the identity owner for `auths id export-bundle`) or --key .", - cmd.did - ) - })?; - #[allow(clippy::disallowed_methods)] // INVARIANT: hex::encode always produces valid hex - Ok((PublicKeyHex::new_unchecked(hex::encode(pk)), curve)) + let (key, curve) = resolve_local_current_key(&cmd.did)?.ok_or_else(|| { + anyhow!( + "Could not resolve {} from the local registry. Provide --bundle \ + (ask the identity owner for `auths id export-bundle`) or --key .", + cmd.did + ) + })?; + Ok((key, curve)) } fn handle_pin(cmd: TrustPinCommand, store: &PinnedIdentityStore, now: DateTime) -> Result<()> { diff --git a/crates/auths-cli/tests/cases/config_repo.rs b/crates/auths-cli/tests/cases/config_repo.rs new file mode 100644 index 00000000..a2a925c5 --- /dev/null +++ b/crates/auths-cli/tests/cases/config_repo.rs @@ -0,0 +1,52 @@ +use super::helpers::TestEnv; +use std::fs; + +/// `config set --repo ` must write into that registry's `config.toml`, not +/// the default `~/.auths` config. Guards the confused-deputy bug where `--repo` +/// was parsed but silently ignored, so a repo-scoped change mutated the global +/// config. +#[test] +fn config_set_honors_repo_override() { + let env = TestEnv::new(); + env.init_identity(); + + let default_config = env.auths_home.join("config.toml"); + let default_before = fs::read_to_string(&default_config).ok(); + + let alt = env.home.path().join("alt-registry"); + let out = env + .cmd("auths") + .args([ + "config", + "set", + "passphrase.cache", + "always", + "--repo", + alt.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "config set failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let alt_config = alt.join("config.toml"); + assert!( + alt_config.exists(), + "config did not land in the --repo store; --repo was ignored" + ); + assert!( + fs::read_to_string(&alt_config) + .unwrap() + .contains("always"), + "the --repo config.toml does not reflect the new value" + ); + + let default_after = fs::read_to_string(&default_config).ok(); + assert_eq!( + default_before, default_after, + "config set --repo mutated the default ~/.auths config" + ); +} diff --git a/crates/auths-cli/tests/cases/forgery_resistance.rs b/crates/auths-cli/tests/cases/forgery_resistance.rs new file mode 100644 index 00000000..b8c26d6e --- /dev/null +++ b/crates/auths-cli/tests/cases/forgery_resistance.rs @@ -0,0 +1,264 @@ +// Integration coverage for the central guarantee of `auths verify`: a clean +// signature verifies true, and any tampering with either the signature or the +// signed artifact flips the verdict to false. Verification must fail closed on +// broken input rather than crash or report a positive result. +// +// Each test owns its own sandboxed `TestEnv` (temp HOME, temp keychain, temp +// git repo), so nothing here touches the real `~/.auths`. + +use super::helpers::TestEnv; + +/// Read the top-level `valid` flag out of a `--json` verify response. +/// +/// The verdict JSON carries a top-level `"valid"` boolean alongside a sibling +/// `"chain_valid"`, so we anchor on the exact `"valid"` key and read the literal +/// that follows it. Returns `Some(true)`/`Some(false)` when the flag is present, +/// or `None` when the output carries no parseable top-level verdict. +fn top_level_valid(json: &str) -> Option { + let mut search_from = 0; + while let Some(rel) = json[search_from..].find("\"valid\"") { + let key_start = search_from + rel; + let after_key = key_start + "\"valid\"".len(); + let rest = json[after_key..].trim_start(); + let rest = rest.strip_prefix(':').map(str::trim_start); + if let Some(rest) = rest { + if rest.starts_with("true") { + return Some(true); + } + if rest.starts_with("false") { + return Some(false); + } + } + // A `"valid"` substring that was not a JSON key/value pair (for example a + // word inside an error message); keep scanning for the real field. + search_from = after_key; + } + None +} + +/// Sign a binary artifact and return the path to its `.auths.json` sidecar. +/// +/// Uses a non-JSON extension so `auths verify ` routes through the artifact +/// path (sidecar lookup) rather than being read as an attestation document. +fn sign_artifact(env: &TestEnv) -> (std::path::PathBuf, std::path::PathBuf) { + let artifact = env.repo_path.join("release.bin"); + std::fs::write(&artifact, b"artifact payload bytes\n").unwrap(); + + let output = env + .cmd("auths") + .args(["sign", artifact.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + output.status.success(), + "auths sign failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let sidecar = env.repo_path.join("release.bin.auths.json"); + assert!( + sidecar.exists(), + "expected attestation sidecar at {sidecar:?}" + ); + (artifact, sidecar) +} + +/// Run `auths verify --json` and return stdout. +fn verify_artifact(env: &TestEnv, artifact: &std::path::Path) -> String { + let output = env + .cmd("auths") + .args(["verify", artifact.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + String::from_utf8_lossy(&output.stdout).to_string() +} + +/// Positive control: a clean, untampered signature must verify true. +/// +/// Without this anchor the tamper-rejection tests below could pass trivially by +/// always returning false, so we prove a genuine signature is accepted first. +#[test] +fn verify_accepts_clean_signature() { + let env = TestEnv::new(); + env.init_identity(); + + let (artifact, _sidecar) = sign_artifact(&env); + let stdout = verify_artifact(&env, &artifact); + + assert_eq!( + top_level_valid(&stdout), + Some(true), + "a clean signature must verify as valid; got: {stdout}" + ); +} + +/// A signature whose bytes were altered must be rejected. +/// +/// We corrupt the signed `identity_signature` field in the attestation sidecar. +/// The signature no longer matches the canonical attestation data, so the verdict +/// must be `valid:false` — never a crash, never a positive result. +#[test] +fn verify_rejects_tampered_signature() { + let env = TestEnv::new(); + env.init_identity(); + + let (artifact, sidecar) = sign_artifact(&env); + + let original = std::fs::read_to_string(&sidecar).unwrap(); + // Prefer the issuer signature; fall back to the device signature, which is + // always serialized. Both cover the canonical attestation data. + let mut tampered = corrupt_first_hex_run(&original, "\"identity_signature\""); + if tampered == original { + tampered = corrupt_first_hex_run(&original, "\"device_signature\""); + } + assert_ne!( + tampered, original, + "test setup: failed to alter a signature field in {sidecar:?}" + ); + std::fs::write(&sidecar, &tampered).unwrap(); + + let stdout = verify_artifact(&env, &artifact); + assert_eq!( + top_level_valid(&stdout), + Some(false), + "a tampered signature must fail verification; got: {stdout}" + ); +} + +/// Modifying the artifact body after signing must be rejected. +/// +/// The attestation binds the artifact's digest. Changing the file's bytes makes +/// the recomputed digest disagree with the signed digest, so the verdict must be +/// `valid:false`. +#[test] +fn verify_rejects_tampered_artifact_body() { + let env = TestEnv::new(); + env.init_identity(); + + let (artifact, _sidecar) = sign_artifact(&env); + + // Sanity: the signature is good before we touch the body. + let clean = verify_artifact(&env, &artifact); + assert_eq!( + top_level_valid(&clean), + Some(true), + "precondition: the signature must verify before tampering; got: {clean}" + ); + + std::fs::write(&artifact, b"artifact payload bytes -- altered\n").unwrap(); + + let stdout = verify_artifact(&env, &artifact); + assert_eq!( + top_level_valid(&stdout), + Some(false), + "a modified artifact body must fail verification; got: {stdout}" + ); +} + +/// Verification must fail closed on malformed or missing input. +/// +/// Empty files, binary garbage, truncated JSON, and a non-existent path must all +/// resolve to `valid:false` (or a clean error verdict) and must never report +/// `valid:true` or crash the process. +#[test] +fn verify_fails_closed_on_malformed_input() { + let env = TestEnv::new(); + env.init_identity(); + + // `.json` inputs route through attestation parsing, exercising the malformed + // attestation path directly. + let empty = env.repo_path.join("empty.json"); + std::fs::write(&empty, b"").unwrap(); + + let garbage = env.repo_path.join("garbage.json"); + std::fs::write(&garbage, [0x00u8, 0xff, 0x01, 0xfe, 0x7f, 0x80]).unwrap(); + + let truncated = env.repo_path.join("truncated.json"); + std::fs::write(&truncated, b"{\"version\":\"1\",\"issuer\":\"did:keri:E").unwrap(); + + let broken_inputs = [&empty, &garbage, &truncated]; + for input in broken_inputs { + let output = env + .cmd("auths") + .args(["verify", input.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + // The process must exit cleanly (no panic / signal kill), regardless of + // the non-zero verification exit code. + assert!( + output.status.code().is_some(), + "verify must not crash on malformed input {input:?}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_ne!( + top_level_valid(&stdout), + Some(true), + "malformed input must never verify as valid: {input:?} -> {stdout}" + ); + } + + // A path that does not exist must also fail closed without crashing. + let missing = env.repo_path.join("does-not-exist.json"); + let output = env + .cmd("auths") + .args(["verify", missing.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + output.status.code().is_some(), + "verify must not crash on a non-existent path" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_ne!( + top_level_valid(&stdout), + Some(true), + "a non-existent path must never verify as valid; got: {stdout}" + ); +} + +/// Replace the first run of hex characters that follows `field_marker` with a +/// different hex value, leaving the JSON structurally intact but the signed +/// material altered. Returns the input unchanged when no such run is found. +fn corrupt_first_hex_run(json: &str, field_marker: &str) -> String { + let Some(marker_at) = json.find(field_marker) else { + return json.to_string(); + }; + let tail = &json[marker_at..]; + // Find the opening quote of the field's value, then the hex run inside it. + let Some(value_quote_rel) = tail[field_marker.len()..].find('"') else { + return json.to_string(); + }; + let value_start = marker_at + field_marker.len() + value_quote_rel + 1; + let bytes = json.as_bytes(); + let mut i = value_start; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + if i == value_start { + return json.to_string(); + } + let mut out = String::with_capacity(json.len()); + out.push_str(&json[..value_start]); + for &b in &bytes[value_start..i] { + // Map each hex digit to a different one so the value stays valid hex of + // the same length but no longer matches the original signature bytes. + let c = b as char; + let flipped = match c { + '0'..='9' => { + if c == '9' { + '0' + } else { + (b + 1) as char + } + } + 'a' | 'A' => 'b', + 'b' | 'B' => 'a', + 'c'..='f' => 'a', + 'C'..='F' => 'a', + _ => '0', + }; + out.push(flipped); + } + out.push_str(&json[i..]); + out +} diff --git a/crates/auths-cli/tests/cases/key_repo.rs b/crates/auths-cli/tests/cases/key_repo.rs new file mode 100644 index 00000000..aa5ddc67 --- /dev/null +++ b/crates/auths-cli/tests/cases/key_repo.rs @@ -0,0 +1,29 @@ +use super::helpers::TestEnv; + +/// `auths key` operates only on the per-machine keychain selected by +/// `AUTHS_KEYCHAIN_*`; there is no repo-scoped store for it to act on. `--repo` +/// must therefore be rejected with a clear error rather than silently acting on +/// the global keychain. Guards the confused-deputy bug where `--repo` was parsed +/// but ignored. +#[test] +fn key_repo_is_honored_or_rejected() { + let env = TestEnv::new(); + + let alt = env.home.path().join("alt-registry"); + let out = env + .cmd("auths") + .args(["key", "list", "--repo", alt.to_str().unwrap()]) + .output() + .unwrap(); + + assert!( + !out.status.success(), + "key list --repo should be rejected, not silently run on the global keychain" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--repo") && stderr.contains("keychain"), + "expected a clear `--repo` rejection mentioning the keychain, got: {stderr}" + ); +} diff --git a/crates/auths-cli/tests/cases/mod.rs b/crates/auths-cli/tests/cases/mod.rs index b8214ffe..25db971d 100644 --- a/crates/auths-cli/tests/cases/mod.rs +++ b/crates/auths-cli/tests/cases/mod.rs @@ -1,17 +1,24 @@ mod clap_collision; +mod config_repo; mod doctor; mod expand_rotate; +mod forgery_resistance; mod golden_path; mod helpers; mod init; mod json_output; +mod key_repo; mod key_rotation; mod key_rotation_cli; +mod pair_repo; mod preset; +mod repo_consistency; +mod reset_repo; mod revocation; mod sign_commit_binding; mod sign_verify; mod sign_verify_roundtrip; +mod trust_pin_control; mod trust_repo; mod verify; mod verify_commit; diff --git a/crates/auths-cli/tests/cases/pair_repo.rs b/crates/auths-cli/tests/cases/pair_repo.rs new file mode 100644 index 00000000..db826a69 --- /dev/null +++ b/crates/auths-cli/tests/cases/pair_repo.rs @@ -0,0 +1,56 @@ +use super::helpers::TestEnv; + +/// `pair --repo ` must resolve the pairing store from that registry, not +/// the default `~/.auths`. Guards the confused-deputy bug where `PairCommand` +/// dropped `repo_path` and pairing silently operated on the global store. +/// +/// The default registry holds an identity (via `init_identity`); the `--repo` +/// override points at an empty registry. Online-initiate pairing loads the +/// controller identity before any network call, so: +/// * with `--repo ` it reads the empty store and fails "identity not +/// found" — proving it READ the alt registry, not the populated default; +/// * without `--repo` it reads the populated default, gets past identity +/// loading, and fails later (network), never with "identity not found". +#[test] +fn pair_honors_repo_override() { + let env = TestEnv::new(); + env.init_identity(); + + let alt = env.home.path().join("alt-registry"); + std::fs::create_dir_all(&alt).unwrap(); + + let scoped = env + .cmd("auths") + .args([ + "pair", + "--registry", + "http://127.0.0.1:0", + "--no-qr", + "--repo", + alt.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!( + !scoped.status.success(), + "pair against an empty --repo store should fail" + ); + let scoped_err = String::from_utf8_lossy(&scoped.stderr); + assert!( + scoped_err.contains("identity not found"), + "pair --repo did not read the alt store (expected 'identity not found'), got: {scoped_err}" + ); + + let default = env + .cmd("auths") + .args(["pair", "--registry", "http://127.0.0.1:0", "--no-qr"]) + .output() + .unwrap(); + + let default_err = String::from_utf8_lossy(&default.stderr); + assert!( + !default_err.contains("identity not found"), + "without --repo, pairing must read the populated default store, got: {default_err}" + ); +} diff --git a/crates/auths-cli/tests/cases/repo_consistency.rs b/crates/auths-cli/tests/cases/repo_consistency.rs new file mode 100644 index 00000000..00c3d9ea --- /dev/null +++ b/crates/auths-cli/tests/cases/repo_consistency.rs @@ -0,0 +1,74 @@ +//! Guardrail: a storage-touching command must not silently ignore the global `--repo` flag. +//! +//! The `--repo` confused-deputy class recurred because nothing enforced the rule — several commands +//! resolved storage from the default location and dropped `--repo`, so an operator's per-repo scoping was +//! silently ignored. This test walks the command sources and fails the build if a file that touches a +//! storage backend neither resolves `--repo` (`resolve_repo_path` / `repo_path`) nor explicitly rejects +//! it. New storage commands therefore cannot regress this class without a deliberate, reviewed exemption. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Markers that a file talks to a real storage backend (registry, keychain, or pin store). +const STORAGE_MARKERS: &[&str] = &[ + "PinnedIdentityStore::new", + "get_platform_keychain", + "build_auths_context", + "GitRegistryBackend::", + "resolve_registry_path", +]; + +/// Markers that a file is `--repo`-aware: it threads the override (directly, or via an `env_config` +/// whose `auths_home` the command entry already resolved from `--repo`) or deliberately rejects it. +const REPO_AWARE: &[&str] = &[ + "resolve_repo_path", "repo_path", "repo_opt", "ctx.repo", "--repo", "auths_home_with_config", +]; + +/// Storage-touching command sources with a known, reviewed reason for not threading `--repo`, matched by +/// path suffix under `commands/`. Adding an entry requires a reason — this list is the audit trail of +/// `--repo` exceptions, not a place to hide new ones. +const EXEMPT: &[(&str, &str)] = &[ + ("verify_commit.rs", "read-only verification; reads pinned roots from the resolved registry"), + ("artifact/verify.rs", "read-only artifact verification"), + ("device/verify_attestation.rs", "read-only device-attestation verification (pin lookup)"), + ("device/pair/common.rs", "pairing internals; the store is resolved by the command entry"), + ("multi_sig.rs", "multi-sig session storage; --repo applicability under review"), +]; + +fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + rs_files(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } +} + +#[test] +fn storage_commands_handle_repo_flag() { + let cmd_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands"); + let mut files = Vec::new(); + rs_files(&cmd_dir, &mut files); + assert!(!files.is_empty(), "no command sources found under {cmd_dir:?}"); + + let mut offenders = Vec::new(); + for f in &files { + let rel = f.strip_prefix(&cmd_dir).unwrap_or(f).to_string_lossy().replace('\\', "/"); + let body = fs::read_to_string(f).unwrap_or_default(); + let touches_storage = STORAGE_MARKERS.iter().any(|m| body.contains(m)); + let repo_aware = REPO_AWARE.iter().any(|m| body.contains(m)); + let exempt = EXEMPT.iter().any(|(suffix, _)| rel.ends_with(suffix)); + if touches_storage && !repo_aware && !exempt { + offenders.push(rel); + } + } + + assert!( + offenders.is_empty(), + "these commands touch storage but neither resolve nor reject `--repo` \ + (thread `resolve_repo_path`, or add a reviewed entry to EXEMPT): {offenders:?}" + ); +} diff --git a/crates/auths-cli/tests/cases/reset_repo.rs b/crates/auths-cli/tests/cases/reset_repo.rs new file mode 100644 index 00000000..a8e6d61a --- /dev/null +++ b/crates/auths-cli/tests/cases/reset_repo.rs @@ -0,0 +1,55 @@ +use super::helpers::TestEnv; +use std::fs; + +/// `reset --repo --force` must remove only that registry's directory, not +/// the default `~/.auths`. Guards the destructive confused-deputy bug where +/// `--repo` was parsed but ignored, so a repo-scoped reset wiped the global +/// `~/.auths` store. +#[test] +fn reset_honors_repo_override() { + let env = TestEnv::new(); + env.init_identity(); + + let default_marker = env.auths_home.join("reset-sentinel"); + fs::write(&default_marker, b"keep").unwrap(); + + let alt = env.home.path().join("alt-registry"); + let out = env + .cmd("auths") + .args([ + "init", + "--non-interactive", + "--profile", + "developer", + "--repo", + alt.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "init --repo failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(alt.exists(), "init --repo did not create the alt store"); + + let reset = env + .cmd("auths") + .args(["reset", "--force", "--repo", alt.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + reset.status.success(), + "reset --repo failed: {}", + String::from_utf8_lossy(&reset.stderr) + ); + + assert!( + !alt.exists(), + "reset --repo did not remove the alt store; --repo was ignored" + ); + assert!( + env.auths_home.exists() && default_marker.exists(), + "reset --repo wiped the default ~/.auths store" + ); +} diff --git a/crates/auths-cli/tests/cases/trust_pin_control.rs b/crates/auths-cli/tests/cases/trust_pin_control.rs new file mode 100644 index 00000000..54457ab5 --- /dev/null +++ b/crates/auths-cli/tests/cases/trust_pin_control.rs @@ -0,0 +1,154 @@ +use super::helpers::TestEnv; + +/// Extract the `did:keri:` identity DID from `auths whoami --json` output. +fn extract_did(json: &str) -> String { + let start = json + .find("did:keri:") + .expect("a did:keri: identity in whoami json"); + let rest = &json[start..]; + let end = rest + .find(|c: char| c == '"' || c == ',' || c.is_whitespace()) + .unwrap_or(rest.len()); + rest[..end].to_string() +} + +/// `trust pin --did A --key ` must be refused when A's key history +/// (KEL) is locally resolvable: the supplied key does not control A, so pinning +/// it would let an unrelated key verify as A. A `--key` that disagrees with the +/// KEL-resolved current key is rejected. +#[test] +fn trust_pin_rejects_key_not_in_kel() { + let env = TestEnv::new(); + env.init_identity(); + + let whoami = env + .cmd("auths") + .args(["whoami", "--json"]) + .output() + .unwrap(); + assert!(whoami.status.success()); + let did = extract_did(&String::from_utf8_lossy(&whoami.stdout)); + + let wrong_key = "ab".repeat(32); + let out = env + .cmd("auths") + .args(["trust", "pin", "--did", &did, "--key", &wrong_key]) + .output() + .unwrap(); + + assert!( + !out.status.success(), + "trust pin accepted a --key that does not match the identity's KEL" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("does not match") && stderr.contains("KEL"), + "expected a KEL-mismatch rejection, got: {stderr}" + ); + + let default_store = env.auths_home.join("known_identities.json"); + let pinned = default_store.exists() + && std::fs::read_to_string(&default_store) + .unwrap() + .contains(&did); + assert!(!pinned, "a rejected pin must not be written to the store"); +} + +/// Pinning A with no `--key` resolves the current key from A's local KEL and +/// succeeds. This is the happy path the cross-check must leave intact. +#[test] +fn trust_pin_without_key_resolves_from_kel() { + let env = TestEnv::new(); + env.init_identity(); + + let whoami = env + .cmd("auths") + .args(["whoami", "--json"]) + .output() + .unwrap(); + assert!(whoami.status.success()); + let did = extract_did(&String::from_utf8_lossy(&whoami.stdout)); + + let out = env + .cmd("auths") + .args(["trust", "pin", "--did", &did]) + .output() + .unwrap(); + assert!( + out.status.success(), + "trust pin without --key failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let default_store = env.auths_home.join("known_identities.json"); + assert!( + default_store.exists() + && std::fs::read_to_string(&default_store) + .unwrap() + .contains(&did), + "KEL-resolved pin did not land in the store" + ); +} + +/// Pinning A with the correct `--key` (the current key already in A's KEL) is +/// allowed: the cross-check finds a match and proceeds. +#[test] +fn trust_pin_accepts_matching_key() { + let env = TestEnv::new(); + env.init_identity(); + + let whoami = env + .cmd("auths") + .args(["whoami", "--json"]) + .output() + .unwrap(); + assert!(whoami.status.success()); + let whoami_json = String::from_utf8_lossy(&whoami.stdout); + let did = extract_did(&whoami_json); + + let show = env + .cmd("auths") + .args(["trust", "pin", "--did", &did, "--json"]) + .output() + .unwrap(); + assert!(show.status.success()); + + let details = env + .cmd("auths") + .args(["trust", "show", &did, "--json"]) + .output() + .unwrap(); + assert!(details.status.success()); + let details_json = String::from_utf8_lossy(&details.stdout); + let key = extract_public_key_hex(&details_json); + + env.cmd("auths") + .args(["trust", "remove", &did]) + .output() + .unwrap(); + + let out = env + .cmd("auths") + .args(["trust", "pin", "--did", &did, "--key", &key]) + .output() + .unwrap(); + assert!( + out.status.success(), + "trust pin with the KEL-matching --key was refused: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// Pull `public_key_hex` out of `trust show --json` output. +fn extract_public_key_hex(json: &str) -> String { + let marker = "\"public_key_hex\""; + let start = json + .find(marker) + .map(|i| i + marker.len()) + .expect("public_key_hex in trust show json"); + let rest = &json[start..]; + let first_quote = rest.find('"').expect("opening quote") + 1; + let after = &rest[first_quote..]; + let end = after.find('"').expect("closing quote"); + after[..end].to_string() +} diff --git a/crates/auths-infra-http/src/lib.rs b/crates/auths-infra-http/src/lib.rs index 84ebf4c2..0fe01bbd 100644 --- a/crates/auths-infra-http/src/lib.rs +++ b/crates/auths-infra-http/src/lib.rs @@ -30,6 +30,7 @@ mod pairing_client; mod platform_context; mod registry_client; mod request; +mod ssrf; mod witness_client; pub use async_witness_client::HttpAsyncWitnessClient; diff --git a/crates/auths-infra-http/src/pairing_client.rs b/crates/auths-infra-http/src/pairing_client.rs index fcec322a..634cd068 100644 --- a/crates/auths-infra-http/src/pairing_client.rs +++ b/crates/auths-infra-http/src/pairing_client.rs @@ -10,9 +10,17 @@ use auths_core::ports::pairing::PairingRelayClient; use crate::default_http_client; use crate::error::{map_reqwest_error, map_status_error}; +use crate::ssrf::{SsrfBlocked, guard_registry_url}; const POLL_INTERVAL: Duration = Duration::from_secs(2); +/// Translate a refused registry URL into a `NetworkError` for the network port. +fn map_ssrf_error(err: SsrfBlocked) -> NetworkError { + NetworkError::InvalidResponse { + detail: err.to_string(), + } +} + /// HTTP-backed implementation of [`PairingRelayClient`]. /// /// Uses WebSocket for real-time session updates with HTTP polling as a fallback @@ -50,12 +58,14 @@ impl PairingRelayClient for HttpPairingRelayClient { registry_url: &str, request: &CreateSessionRequest, ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!("{}/v1/pairing/sessions", registry_url.trim_end_matches('/')); let endpoint = registry_url.to_string(); // Serialize JSON at call time so the future owns the request bytes. let req = self.client.post(&url).json(request); async move { + guard?; let resp = req .send() .await @@ -76,6 +86,7 @@ impl PairingRelayClient for HttpPairingRelayClient { registry_url: &str, session_id: &str, ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!( "{}/v1/pairing/sessions/{}", registry_url.trim_end_matches('/'), @@ -85,6 +96,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let req = self.client.get(&url); async move { + guard?; let resp = req .send() .await @@ -105,6 +117,7 @@ impl PairingRelayClient for HttpPairingRelayClient { registry_url: &str, code: &str, ) -> impl Future> + Send { + 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('/'), @@ -114,6 +127,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let req = self.client.get(&url); async move { + guard?; let resp = req .send() .await @@ -135,6 +149,7 @@ impl PairingRelayClient for HttpPairingRelayClient { session_id: &str, response: &SubmitResponseRequest, ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!( "{}/v1/pairing/sessions/{}/response", registry_url.trim_end_matches('/'), @@ -144,6 +159,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let req = self.client.post(&url).json(response); async move { + guard?; let resp = req .send() .await @@ -161,6 +177,7 @@ impl PairingRelayClient for HttpPairingRelayClient { session_id: &str, request: &SubmitConfirmationRequest, ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!( "{}/v1/pairing/sessions/{}/confirm", registry_url.trim_end_matches('/'), @@ -170,6 +187,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let req = self.client.post(&url).json(request); async move { + guard?; let resp = req .send() .await @@ -186,6 +204,7 @@ impl PairingRelayClient for HttpPairingRelayClient { registry_url: &str, session_id: &str, ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!( "{}/v1/pairing/sessions/{}/confirmation", registry_url.trim_end_matches('/'), @@ -195,6 +214,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let req = self.client.get(&url); async move { + guard?; let resp = req .send() .await @@ -216,6 +236,7 @@ impl PairingRelayClient for HttpPairingRelayClient { session_id: &str, timeout: Duration, ) -> impl Future, NetworkError>> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!( "{}/v1/pairing/sessions/{}/confirmation", registry_url.trim_end_matches('/'), @@ -225,6 +246,7 @@ impl PairingRelayClient for HttpPairingRelayClient { let client = self.client.clone(); async move { + guard?; let deadline = tokio::time::Instant::now() + timeout; loop { let resp = client @@ -271,10 +293,12 @@ impl PairingRelayClient for HttpPairingRelayClient { session_id ); let endpoint = registry_url.to_string(); + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); // Clone the client so the future owns it without borrowing &self. let client = self.client.clone(); async move { + guard?; let deadline = tokio::time::Instant::now() + timeout; if let Ok((ws_stream, _)) = tokio_tungstenite::connect_async(&ws_url).await { diff --git a/crates/auths-infra-http/src/registry_client.rs b/crates/auths-infra-http/src/registry_client.rs index 9f7f23d0..7ab907f9 100644 --- a/crates/auths-infra-http/src/registry_client.rs +++ b/crates/auths-infra-http/src/registry_client.rs @@ -6,8 +6,16 @@ use crate::error::map_reqwest_error; use crate::request::{ build_get_request, build_post_request, execute_request, parse_response_bytes, }; +use crate::ssrf::{SsrfBlocked, guard_registry_url}; use crate::{default_client_builder, default_http_client}; +/// Translate a refused registry URL into a `NetworkError` for the network port. +fn map_ssrf_error(err: SsrfBlocked) -> NetworkError { + NetworkError::InvalidResponse { + detail: err.to_string(), + } +} + /// HTTP-backed implementation of `RegistryClient`. /// /// Fetches and pushes data to a remote registry service for identity @@ -68,10 +76,12 @@ impl RegistryClient for HttpRegistryClient { registry_url: &str, path: &str, ) -> impl Future, NetworkError>> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!("{}/{}", registry_url.trim_end_matches('/'), path); let request = build_get_request(&self.client, &url); async move { + guard?; let response = execute_request(request, registry_url).await?; parse_response_bytes(response, path).await } @@ -83,10 +93,12 @@ impl RegistryClient for HttpRegistryClient { path: &str, data: &[u8], ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!("{}/{}", registry_url.trim_end_matches('/'), path); let request = build_post_request(&self.client, &url, data.to_vec()); async move { + guard?; let response = execute_request(request, registry_url).await?; let _ = parse_response_bytes(response, path).await?; Ok(()) @@ -99,6 +111,7 @@ impl RegistryClient for HttpRegistryClient { path: &str, json_body: &[u8], ) -> impl Future> + Send { + let guard = guard_registry_url(registry_url).map_err(map_ssrf_error); let url = format!("{}/{}", registry_url.trim_end_matches('/'), path); let request = self .client @@ -108,6 +121,7 @@ impl RegistryClient for HttpRegistryClient { let endpoint = registry_url.to_string(); async move { + guard?; let response = request .send() .await diff --git a/crates/auths-infra-http/src/ssrf.rs b/crates/auths-infra-http/src/ssrf.rs new file mode 100644 index 00000000..14df5c0c --- /dev/null +++ b/crates/auths-infra-http/src/ssrf.rs @@ -0,0 +1,148 @@ +//! Connection guard for user-supplied registry and pairing-relay URLs. +//! +//! `init --register`, `pair --registry`, and `artifact publish --registry` all +//! dial a URL the caller controls. Without a check, that URL can point at cloud +//! metadata endpoints (`http://169.254.169.254`), loopback, or other private +//! hosts the process can reach but the caller should not. This module enforces +//! HTTPS-only, public-host-only dialing, mirroring the OOBI client. +//! +//! The `AUTHS_ALLOW_PRIVATE_REGISTRY` environment variable disables the check so +//! intranet registries and local/CI servers (`http://127.0.0.1`) keep working. + +use std::net::IpAddr; + +use url::Url; + +/// Environment variable that, when set to any value, allows plain `http` and +/// private/loopback registry hosts. +const ALLOW_PRIVATE_ENV: &str = "AUTHS_ALLOW_PRIVATE_REGISTRY"; + +/// Reason a registry URL was refused. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum SsrfBlocked { + /// The URL could not be parsed. + #[error("invalid registry URL: {0}")] + InvalidUrl(String), + + /// The URL scheme is not `https`. + #[error("registry URL scheme must be https, got '{0}'")] + InsecureScheme(String), + + /// The URL has no host component. + #[error("registry URL has no host")] + MissingHost, + + /// The target host is loopback / private / link-local / unspecified. + #[error("refusing to dial a private or loopback registry host: {0}")] + BlockedHost(String), +} + +/// Enforce the connection policy on a user-supplied registry URL. +/// +/// Reads `AUTHS_ALLOW_PRIVATE_REGISTRY` to decide whether private hosts are +/// permitted, then delegates to [`evaluate`]. +/// +/// Args: +/// * `url`: The registry or pairing-relay base URL the caller asked to dial. +/// +/// Usage: +/// ```ignore +/// guard_registry_url("https://registry.example.com")?; +/// ``` +pub(crate) fn guard_registry_url(url: &str) -> Result<(), SsrfBlocked> { + let allow_private = std::env::var_os(ALLOW_PRIVATE_ENV).is_some(); + evaluate(url, allow_private) +} + +/// Decide whether `url` may be dialed. +/// +/// When `allow_private` is true the URL is accepted as long as it parses and has +/// a host. Otherwise the scheme must be `https` and the host must not be +/// loopback, private, link-local, or unspecified. +/// +/// Args: +/// * `url`: The registry or pairing-relay base URL to check. +/// * `allow_private`: When true, permit plain `http` and private/loopback hosts. +fn evaluate(url: &str, allow_private: bool) -> Result<(), SsrfBlocked> { + let parsed = Url::parse(url).map_err(|e| SsrfBlocked::InvalidUrl(e.to_string()))?; + let host = parsed + .host_str() + .ok_or(SsrfBlocked::MissingHost)? + .to_string(); + + if allow_private { + return Ok(()); + } + + if parsed.scheme() != "https" { + return Err(SsrfBlocked::InsecureScheme(parsed.scheme().to_string())); + } + + if is_blocked_host(&host) { + return Err(SsrfBlocked::BlockedHost(host)); + } + + Ok(()) +} + +/// Whether a host should be refused. IP literals are classified directly; the +/// obvious loopback names are blocked. DNS names that resolve to private space +/// are mitigated by the HTTPS-only requirement. +pub(crate) fn is_blocked_host(host: &str) -> bool { + if let Ok(ip) = host.parse::() { + return is_blocked_ip(ip); + } + matches!(host, "localhost" | "localhost.localdomain") +} + +/// Whether an IP is loopback / private / link-local / unspecified. +pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + } + IpAddr::V6(v6) => { + let first = v6.segments()[0]; + v6.is_loopback() + || v6.is_unspecified() + || (first & 0xfe00) == 0xfc00 // unique-local fc00::/7 + || (first & 0xffc0) == 0xfe80 // link-local fe80::/10 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluate_blocks_metadata_and_loopback_allows_public() { + assert_eq!( + evaluate("http://169.254.169.254/x", false), + Err(SsrfBlocked::InsecureScheme("http".to_string())) + ); + assert_eq!( + evaluate("https://169.254.169.254/x", false), + Err(SsrfBlocked::BlockedHost("169.254.169.254".to_string())) + ); + assert_eq!( + evaluate("http://127.0.0.1:8080", false), + Err(SsrfBlocked::InsecureScheme("http".to_string())) + ); + assert_eq!( + evaluate("https://127.0.0.1:8080", false), + Err(SsrfBlocked::BlockedHost("127.0.0.1".to_string())) + ); + assert_eq!(evaluate("https://registry.example.com", false), Ok(())); + } + + #[test] + fn evaluate_allow_private_permits_loopback() { + assert_eq!(evaluate("http://127.0.0.1:8080", true), Ok(())); + assert_eq!(evaluate("http://169.254.169.254/x", true), Ok(())); + } +} From c0f1c444010b82f39c3c2fce88adfa0d2a55c508 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 21:54:27 +0100 Subject: [PATCH 5/6] formatting Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .auths/ci-bundle.json | 2 +- .pre-commit-config.yaml | 5 +++ crates/auths-cli/src/commands/agent/mod.rs | 4 +- crates/auths-cli/tests/cases/config_repo.rs | 4 +- .../auths-cli/tests/cases/repo_consistency.rs | 42 +++++++++++++++---- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.auths/ci-bundle.json b/.auths/ci-bundle.json index 03c38fe9..fea7116f 100644 --- a/.auths/ci-bundle.json +++ b/.auths/ci-bundle.json @@ -58,4 +58,4 @@ ], "bundle_timestamp": "2026-06-19T14:19:08.262292Z", "max_valid_for_secs": 31536000 -} \ No newline at end of file +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 553a75bc..f5cb8fe3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,8 @@ +# Exclude the .recurve/ working area from every hook (commit and push stages). +# It is a local scratch/notes area (shell, markdown, JSON dumps), not workspace +# source, so file hooks (whitespace, EOF, yaml/toml) must not touch it. +exclude: '^\.recurve/' + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 diff --git a/crates/auths-cli/src/commands/agent/mod.rs b/crates/auths-cli/src/commands/agent/mod.rs index a9a1f179..d2d827df 100644 --- a/crates/auths-cli/src/commands/agent/mod.rs +++ b/crates/auths-cli/src/commands/agent/mod.rs @@ -174,7 +174,9 @@ fn daemon_op_name(cmd: &AgentSubcommand) -> Option<&'static str> { AgentSubcommand::Status => Some("status"), AgentSubcommand::InstallService { .. } => Some("install-service"), AgentSubcommand::UninstallService => Some("uninstall-service"), - AgentSubcommand::Env { .. } | AgentSubcommand::Lock | AgentSubcommand::Unlock { .. } => None, + AgentSubcommand::Env { .. } | AgentSubcommand::Lock | AgentSubcommand::Unlock { .. } => { + None + } } } diff --git a/crates/auths-cli/tests/cases/config_repo.rs b/crates/auths-cli/tests/cases/config_repo.rs index a2a925c5..a742e4d8 100644 --- a/crates/auths-cli/tests/cases/config_repo.rs +++ b/crates/auths-cli/tests/cases/config_repo.rs @@ -38,9 +38,7 @@ fn config_set_honors_repo_override() { "config did not land in the --repo store; --repo was ignored" ); assert!( - fs::read_to_string(&alt_config) - .unwrap() - .contains("always"), + fs::read_to_string(&alt_config).unwrap().contains("always"), "the --repo config.toml does not reflect the new value" ); diff --git a/crates/auths-cli/tests/cases/repo_consistency.rs b/crates/auths-cli/tests/cases/repo_consistency.rs index 00c3d9ea..bdd70c9f 100644 --- a/crates/auths-cli/tests/cases/repo_consistency.rs +++ b/crates/auths-cli/tests/cases/repo_consistency.rs @@ -21,22 +21,41 @@ const STORAGE_MARKERS: &[&str] = &[ /// Markers that a file is `--repo`-aware: it threads the override (directly, or via an `env_config` /// whose `auths_home` the command entry already resolved from `--repo`) or deliberately rejects it. const REPO_AWARE: &[&str] = &[ - "resolve_repo_path", "repo_path", "repo_opt", "ctx.repo", "--repo", "auths_home_with_config", + "resolve_repo_path", + "repo_path", + "repo_opt", + "ctx.repo", + "--repo", + "auths_home_with_config", ]; /// Storage-touching command sources with a known, reviewed reason for not threading `--repo`, matched by /// path suffix under `commands/`. Adding an entry requires a reason — this list is the audit trail of /// `--repo` exceptions, not a place to hide new ones. const EXEMPT: &[(&str, &str)] = &[ - ("verify_commit.rs", "read-only verification; reads pinned roots from the resolved registry"), + ( + "verify_commit.rs", + "read-only verification; reads pinned roots from the resolved registry", + ), ("artifact/verify.rs", "read-only artifact verification"), - ("device/verify_attestation.rs", "read-only device-attestation verification (pin lookup)"), - ("device/pair/common.rs", "pairing internals; the store is resolved by the command entry"), - ("multi_sig.rs", "multi-sig session storage; --repo applicability under review"), + ( + "device/verify_attestation.rs", + "read-only device-attestation verification (pin lookup)", + ), + ( + "device/pair/common.rs", + "pairing internals; the store is resolved by the command entry", + ), + ( + "multi_sig.rs", + "multi-sig session storage; --repo applicability under review", + ), ]; fn rs_files(dir: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { return }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; for e in entries.flatten() { let p = e.path(); if p.is_dir() { @@ -52,11 +71,18 @@ fn storage_commands_handle_repo_flag() { let cmd_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands"); let mut files = Vec::new(); rs_files(&cmd_dir, &mut files); - assert!(!files.is_empty(), "no command sources found under {cmd_dir:?}"); + assert!( + !files.is_empty(), + "no command sources found under {cmd_dir:?}" + ); let mut offenders = Vec::new(); for f in &files { - let rel = f.strip_prefix(&cmd_dir).unwrap_or(f).to_string_lossy().replace('\\', "/"); + let rel = f + .strip_prefix(&cmd_dir) + .unwrap_or(f) + .to_string_lossy() + .replace('\\', "/"); let body = fs::read_to_string(f).unwrap_or_default(); let touches_storage = STORAGE_MARKERS.iter().any(|m| body.contains(m)); let repo_aware = REPO_AWARE.iter().any(|m| body.contains(m)); From d430fb12228cd1d273f4c6d0c05b02a6ed38719a Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 22:10:06 +0100 Subject: [PATCH 6/6] fix: correct forgery tests Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/tests/cases/forgery_resistance.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/auths-cli/tests/cases/forgery_resistance.rs b/crates/auths-cli/tests/cases/forgery_resistance.rs index b8c26d6e..f398f8f2 100644 --- a/crates/auths-cli/tests/cases/forgery_resistance.rs +++ b/crates/auths-cli/tests/cases/forgery_resistance.rs @@ -44,9 +44,19 @@ fn sign_artifact(env: &TestEnv) -> (std::path::PathBuf, std::path::PathBuf) { let artifact = env.repo_path.join("release.bin"); std::fs::write(&artifact, b"artifact payload bytes\n").unwrap(); + // `--key` attaches the issuer (identity) signature; without it the artifact carries only the + // device signature and verification fails closed on the missing issuer signature. A clean, + // fully dual-signed attestation is the positive control the tamper tests build on. let output = env .cmd("auths") - .args(["sign", artifact.to_str().unwrap()]) + .args([ + "sign", + artifact.to_str().unwrap(), + "--key", + "main", + "--device-key", + "main", + ]) .output() .unwrap(); assert!(