From 3de37ad6c99ba7ac78da47294e964923b9b56301 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 23:01:58 +0100 Subject: [PATCH 01/10] agent: restrict the listener socket and its directory to the owner The agent listener bound its Unix socket without setting any permissions, so under a default umask both the socket and its parent directory were reachable by other local users. Create the socket directory as 0o700 and set the bound socket to 0o600, failing closed if either permission cannot be applied. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/src/api/runtime.rs | 95 ++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 154b288a..85cc0bbf 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -788,6 +788,39 @@ fn register_keys_with_macos_agent_internal( Ok(results) } +/// Creates the directory that holds the agent socket and restricts it to owner-only +/// access (`0o700`) so no other user can traverse into it and reach the socket. +/// +/// Args: +/// * `dir`: The directory to create (with parents) and lock down. +/// +/// Usage: +/// ```ignore +/// harden_socket_dir(socket_path.parent().expect("socket has a parent"))?; +/// ``` +#[cfg(unix)] +fn harden_socket_dir(dir: &std::path::Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::create_dir_all(dir)?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) +} + +/// Restricts a bound agent socket to owner-only read/write (`0o600`) so only the +/// owning user can connect and request signatures. +/// +/// Args: +/// * `socket_path`: The path of the already-bound Unix-domain socket. +/// +/// Usage: +/// ```ignore +/// harden_socket_file(socket_path)?; +/// ``` +#[cfg(unix)] +fn harden_socket_file(socket_path: &std::path::Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600)) +} + /// Starts the SSH agent listener using the provided `AgentHandle`. /// /// Binds to the socket path from the handle, cleans up any old socket file if present, @@ -808,13 +841,15 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul let socket_path = handle.socket_path(); info!("Attempting to start agent listener at {:?}", socket_path); - // --- Ensure parent directory exists --- + // --- Ensure parent directory exists and is owner-only --- if let Some(parent) = socket_path.parent() - && !parent.exists() + && !parent.as_os_str().is_empty() { - debug!("Creating parent directory for socket: {:?}", parent); - if let Err(e) = std::fs::create_dir_all(parent) { - error!("Failed to create parent directory {:?}: {}", parent, e); + if let Err(e) = harden_socket_dir(parent) { + error!( + "Failed to prepare owner-only socket directory {:?}: {}", + parent, e + ); return Err(AgentError::IO(e)); } } @@ -842,6 +877,15 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul AgentError::IO(e) })?; + // --- Restrict the socket to the owner before serving any request --- + if let Err(e) = harden_socket_file(socket_path) { + error!( + "Failed to restrict agent socket {:?} to owner-only: {}", + socket_path, e + ); + return Err(AgentError::IO(e)); + } + // --- Listener started successfully --- let actual_path = socket_path .canonicalize() @@ -896,3 +940,44 @@ pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentEr let handle = Arc::new(AgentHandle::new(PathBuf::from(&socket_path_str))); start_agent_listener_with_handle(handle).await } + +#[cfg(all(test, unix))] +mod hardening_tests { + use super::{harden_socket_dir, harden_socket_file}; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + fn mode_of(path: &std::path::Path) -> u32 { + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 + } + + #[test] + fn socket_dir_is_owner_only_after_harden() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("auths-agent"); + harden_socket_dir(&dir).expect("harden dir"); + assert!(dir.is_dir(), "directory must be created"); + assert_eq!( + mode_of(&dir), + 0o700, + "agent socket directory must be owner-only (0o700)" + ); + } + + #[test] + fn socket_file_is_owner_only_after_harden() { + let tmp = TempDir::new().unwrap(); + let sock = tmp.path().join("agent.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket"); + harden_socket_file(&sock).expect("harden socket"); + assert_eq!( + mode_of(&sock), + 0o600, + "agent socket must be owner-only (0o600) so no other process can connect" + ); + } +} From cfa3574dd8ac0a9d72325025a5757882c3d7e22d Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 23:15:07 +0100 Subject: [PATCH 02/10] agent: reject socket connections from other users The agent listener served every connection that reached its socket without checking who was on the other end. Authorize each connection by peer UID: the listener now reads the connecting process's credentials and serves only the user that owns the agent, refusing all requests (sign, list, add, remove) from any other user and failing closed when the peer's credentials cannot be read. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/src/agent/mod.rs | 2 + crates/auths-core/src/agent/session.rs | 199 +++++++++++++++++++++++++ crates/auths-core/src/api/runtime.rs | 16 +- 3 files changed, 213 insertions(+), 4 deletions(-) diff --git a/crates/auths-core/src/agent/mod.rs b/crates/auths-core/src/agent/mod.rs index e8a4bea8..e21f624a 100644 --- a/crates/auths-core/src/agent/mod.rs +++ b/crates/auths-core/src/agent/mod.rs @@ -17,3 +17,5 @@ pub use client::{ pub use core::AgentCore; pub use handle::{AgentHandle, DEFAULT_IDLE_TIMEOUT}; pub use session::AgentSession; +#[cfg(unix)] +pub use session::PeerAuthorizedAgent; diff --git a/crates/auths-core/src/agent/session.rs b/crates/auths-core/src/agent/session.rs index 10ad9168..5ccb4437 100644 --- a/crates/auths-core/src/agent/session.rs +++ b/crates/auths-core/src/agent/session.rs @@ -6,6 +6,8 @@ use crate::agent::AgentHandle; use crate::error::AgentError as AuthsAgentError; use log::{debug, error, warn}; +#[cfg(unix)] +use ssh_agent_lib::agent::Agent; use ssh_agent_lib::agent::Session; use ssh_agent_lib::error::AgentError as SSHAgentError; use ssh_agent_lib::proto::{AddIdentity, Credential, Identity, RemoveIdentity, SignRequest}; @@ -282,6 +284,127 @@ impl Session for AgentSession { } } +/// Returns whether a connecting peer is allowed to use the agent. +/// +/// Only the user that owns the running agent may request signatures; a connection +/// from any other user is refused. +/// +/// Args: +/// * `peer_uid`: The effective user id of the connecting process. +/// * `owner_uid`: The effective user id that owns the agent. +/// +/// Usage: +/// ```ignore +/// if peer_is_authorized(peer_uid, owner_uid) { /* serve the connection */ } +/// ``` +pub fn peer_is_authorized(peer_uid: u32, owner_uid: u32) -> bool { + peer_uid == owner_uid +} + +/// A session for a connection, gated on peer authorization. +/// +/// `Authorized` connections are served by an `AgentSession`; `Denied` connections +/// have every request refused, so an unauthorized peer can neither sign nor list keys. +#[cfg(unix)] +pub enum MaybeAuthorized { + /// The peer owns the agent and is served normally. + Authorized(AgentSession), + /// The peer is not the owner; every request is refused. + Denied, +} + +#[cfg(unix)] +#[ssh_agent_lib::async_trait] +impl Session for MaybeAuthorized { + async fn request_identities(&mut self) -> Result, SSHAgentError> { + match self { + Self::Authorized(session) => session.request_identities().await, + Self::Denied => Err(SSHAgentError::Failure), + } + } + + async fn sign(&mut self, request: SignRequest) -> Result { + match self { + Self::Authorized(session) => session.sign(request).await, + Self::Denied => Err(SSHAgentError::Failure), + } + } + + async fn add_identity(&mut self, identity: AddIdentity) -> Result<(), SSHAgentError> { + match self { + Self::Authorized(session) => session.add_identity(identity).await, + Self::Denied => Err(SSHAgentError::Failure), + } + } + + async fn remove_identity(&mut self, identity: RemoveIdentity) -> Result<(), SSHAgentError> { + match self { + Self::Authorized(session) => session.remove_identity(identity).await, + Self::Denied => Err(SSHAgentError::Failure), + } + } + + async fn remove_all_identities(&mut self) -> Result<(), SSHAgentError> { + match self { + Self::Authorized(session) => session.remove_all_identities().await, + Self::Denied => Err(SSHAgentError::Failure), + } + } +} + +/// Session factory that authorizes each incoming connection by peer UID before +/// handing it an `AgentSession`. +/// +/// `ssh_agent_lib` calls `new_session` once per accepted connection, giving access +/// to the connecting socket. We read the peer's credentials there and refuse any +/// connection that is not the owning user (failing closed if the credentials +/// cannot be read). +#[cfg(unix)] +pub struct PeerAuthorizedAgent { + handle: Arc, + owner_uid: u32, +} + +#[cfg(unix)] +impl PeerAuthorizedAgent { + /// Creates a factory that serves only connections from `owner_uid`. + /// + /// Args: + /// * `handle`: The agent handle that holds the unlocked keys. + /// * `owner_uid`: The effective user id permitted to use the agent. + /// + /// Usage: + /// ```ignore + /// let agent = PeerAuthorizedAgent::new(handle, owner_uid); + /// ``` + pub fn new(handle: Arc, owner_uid: u32) -> Self { + Self { handle, owner_uid } + } +} + +#[cfg(unix)] +impl Agent for PeerAuthorizedAgent { + fn new_session(&mut self, socket: &tokio::net::UnixStream) -> impl Session { + match socket.peer_cred() { + Ok(cred) if peer_is_authorized(cred.uid(), self.owner_uid) => { + MaybeAuthorized::Authorized(AgentSession::new(self.handle.clone())) + } + Ok(cred) => { + warn!( + "Refusing agent connection from uid {} (agent owner uid {})", + cred.uid(), + self.owner_uid + ); + MaybeAuthorized::Denied + } + Err(e) => { + error!("Refusing agent connection: cannot read peer credentials: {e}"); + MaybeAuthorized::Denied + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -439,4 +562,80 @@ mod tests { session.remove_all_identities().await.unwrap(); assert_eq!(handle.key_count().unwrap(), 0); } + + #[cfg(unix)] + fn registered_sign_request(handle: &Arc) -> SignRequest { + let pkcs8 = generate_test_pkcs8(); + handle + .register_key(Zeroizing::new(pkcs8)) + .expect("register key"); + let pubkeys = handle.public_keys().expect("public keys"); + let pubkey_bytes: [u8; 32] = pubkeys[0] + .as_slice() + .try_into() + .expect("ed25519 pubkey is 32 bytes"); + SignRequest { + pubkey: KeyData::Ed25519(Ed25519PublicKey(pubkey_bytes)), + data: b"data to sign".to_vec(), + flags: 0, + } + } + + #[test] + fn peer_authorized_only_for_matching_uid() { + assert!(peer_is_authorized(1000, 1000)); + assert!(!peer_is_authorized(1001, 1000)); + assert!(!peer_is_authorized(0, 1000)); + } + + #[cfg(unix)] + #[tokio::test] + async fn new_session_serves_owner_uid() { + use tokio::net::UnixStream; + let (a, _b) = UnixStream::pair().expect("socketpair"); + let owner_uid = a.peer_cred().expect("peer cred").uid(); + + let handle = Arc::new(AgentHandle::new(PathBuf::from("/tmp/peer-allow.sock"))); + let request = registered_sign_request(&handle); + + let mut agent = PeerAuthorizedAgent::new(handle, owner_uid); + let mut session = agent.new_session(&a); + assert!( + session.sign(request).await.is_ok(), + "the owning uid must be able to sign" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn new_session_denies_foreign_uid() { + use tokio::net::UnixStream; + let (a, _b) = UnixStream::pair().expect("socketpair"); + let peer_uid = a.peer_cred().expect("peer cred").uid(); + + let handle = Arc::new(AgentHandle::new(PathBuf::from("/tmp/peer-deny.sock"))); + let request = registered_sign_request(&handle); + + // Owner is a different uid than the connecting peer: the connection must be denied + // even though a key is loaded and signing would otherwise succeed. + let mut agent = PeerAuthorizedAgent::new(handle, peer_uid.wrapping_add(1)); + let mut session = agent.new_session(&a); + assert!( + session.sign(request).await.is_err(), + "a foreign-uid peer must not be able to sign" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn denied_session_refuses_every_request() { + let mut session = MaybeAuthorized::Denied; + assert!(session.request_identities().await.is_err()); + let request = SignRequest { + pubkey: KeyData::Ed25519(Ed25519PublicKey([7u8; 32])), + data: b"x".to_vec(), + flags: 0, + }; + assert!(session.sign(request).await.is_err()); + } } diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 85cc0bbf..11b47461 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -8,7 +8,7 @@ use crate::agent::AgentCore; use crate::agent::AgentHandle; #[cfg(unix)] -use crate::agent::AgentSession; +use crate::agent::PeerAuthorizedAgent; use crate::crypto::provider_bridge; use crate::crypto::signer::extract_seed_from_key_bytes; use crate::crypto::signer::{decrypt_keypair, encrypt_keypair}; @@ -821,6 +821,14 @@ fn harden_socket_file(socket_path: &std::path::Path) -> std::io::Result<()> { std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600)) } +/// Returns the effective user id of the current process — the only user permitted +/// to connect to the agent socket. +#[cfg(unix)] +fn current_euid() -> u32 { + // SAFETY: `geteuid` takes no arguments, has no preconditions, and always succeeds. + unsafe { libc::geteuid() } +} + /// Starts the SSH agent listener using the provided `AgentHandle`. /// /// Binds to the socket path from the handle, cleans up any old socket file if present, @@ -899,11 +907,11 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul // Mark agent as running handle.set_running(true); - // --- Create the agent session handler using the provided handle --- - let session = AgentSession::new(handle.clone()); + // --- Create the peer-authorizing session factory --- + let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid()); // --- Start the main listener loop from ssh_agent_lib --- - let result = listen(listener, session).await; + let result = listen(listener, agent).await; // Mark agent as no longer running handle.set_running(false); From ef018070486541ba815d1d376e838a46bbbbf26f Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 23 Jun 2026 23:23:47 +0100 Subject: [PATCH 03/10] agent: bound how long a single unlock keeps signing capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps left the agent able to sign indefinitely after one unlock. The session signed by reaching into the key store directly, so it never honored the locked state or recorded activity; route signing through the handle so a locked agent refuses to sign and each signature resets the idle timer. The idle timeout was also never enforced — nothing checked it — so add a background monitor that locks the agent (clearing its keys) once it has been idle past the timeout. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/src/agent/session.rs | 32 +++++++++-- crates/auths-core/src/api/runtime.rs | 79 +++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/auths-core/src/agent/session.rs b/crates/auths-core/src/agent/session.rs index 5ccb4437..6e52a43a 100644 --- a/crates/auths-core/src/agent/session.rs +++ b/crates/auths-core/src/agent/session.rs @@ -134,12 +134,7 @@ impl Session for AgentSession { } }; - let core = self.handle.lock().map_err(|_| { - error!("AgentSession failed to lock agent core (mutex poisoned)."); - SSHAgentError::Failure - })?; - - match core.sign(&pubkey_bytes_to_sign_with, &request.data) { + match self.handle.sign(&pubkey_bytes_to_sign_with, &request.data) { Ok(signature_bytes) => { debug!("Successfully signed data using agent core."); Signature::new(algorithm, signature_bytes).map_err(|e| { @@ -155,6 +150,10 @@ impl Session for AgentSession { warn!("Sign request failed: Key not found in agent core."); Err(SSHAgentError::Failure) } + Err(AuthsAgentError::AgentLocked) => { + warn!("Sign request refused: agent is locked."); + Err(SSHAgentError::Failure) + } Err(other_core_error) => { let err_msg = format!("Agent core signing error: {}", other_core_error); error!("{}", err_msg); @@ -638,4 +637,25 @@ mod tests { }; assert!(session.sign(request).await.is_err()); } + + #[cfg(unix)] + #[tokio::test] + async fn sign_resets_idle_timer() { + use std::time::Duration; + let handle = Arc::new(AgentHandle::with_timeout( + PathBuf::from("/tmp/idle-touch.sock"), + Duration::from_millis(300), + )); + let request = registered_sign_request(&handle); + let mut session = AgentSession::new(handle.clone()); + + tokio::time::sleep(Duration::from_millis(200)).await; + session.sign(request).await.expect("sign should succeed"); + tokio::time::sleep(Duration::from_millis(200)).await; + + assert!( + !handle.is_idle_timed_out(), + "a successful sign must reset the idle timer so the agent stays unlocked" + ); + } } diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 11b47461..20efb7a2 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -829,6 +829,43 @@ fn current_euid() -> u32 { unsafe { libc::geteuid() } } +/// Chooses how often to check the agent for idle timeout, bounded to a sensible +/// range. Returns `None` when the idle timeout is disabled (zero). +#[cfg(unix)] +fn idle_monitor_interval(idle_timeout: std::time::Duration) -> Option { + use std::time::Duration; + if idle_timeout.is_zero() { + return None; + } + Some((idle_timeout / 4).clamp(Duration::from_secs(1), Duration::from_secs(60))) +} + +/// Locks the agent once it has been idle past its timeout, clearing its keys, so a +/// single unlock does not leave signing capability available indefinitely. +/// +/// Runs until the agent stops; intended to be spawned in the background. +/// +/// Args: +/// * `handle`: The agent handle to monitor and lock when idle. +/// * `interval`: How often to check for idle timeout. +/// +/// Usage: +/// ```ignore +/// tokio::spawn(run_idle_monitor(handle.clone(), interval)); +/// ``` +#[cfg(unix)] +async fn run_idle_monitor(handle: Arc, interval: std::time::Duration) { + loop { + tokio::time::sleep(interval).await; + if !handle.is_running() { + break; + } + if let Err(e) = handle.check_idle_timeout() { + warn!("Idle timeout check failed: {e}"); + } + } +} + /// Starts the SSH agent listener using the provided `AgentHandle`. /// /// Binds to the socket path from the handle, cleans up any old socket file if present, @@ -907,6 +944,11 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul // Mark agent as running handle.set_running(true); + // --- Auto-lock the agent after it has been idle past its timeout --- + if let Some(interval) = idle_monitor_interval(handle.idle_timeout()) { + tokio::spawn(run_idle_monitor(handle.clone(), interval)); + } + // --- Create the peer-authorizing session factory --- let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid()); @@ -951,8 +993,13 @@ pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentEr #[cfg(all(test, unix))] mod hardening_tests { - use super::{harden_socket_dir, harden_socket_file}; + use super::{ + AgentHandle, harden_socket_dir, harden_socket_file, idle_monitor_interval, run_idle_monitor, + }; use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; use tempfile::TempDir; fn mode_of(path: &std::path::Path) -> u32 { @@ -988,4 +1035,34 @@ mod hardening_tests { "agent socket must be owner-only (0o600) so no other process can connect" ); } + + #[test] + fn idle_monitor_disabled_when_timeout_is_zero() { + assert!(idle_monitor_interval(Duration::ZERO).is_none()); + } + + #[test] + fn idle_monitor_interval_is_bounded() { + let interval = idle_monitor_interval(Duration::from_secs(30 * 60)).expect("some interval"); + assert!(interval >= Duration::from_secs(1)); + assert!(interval <= Duration::from_secs(60)); + } + + #[tokio::test] + async fn idle_monitor_locks_idle_agent() { + let handle = Arc::new(AgentHandle::with_timeout( + PathBuf::from("/tmp/idle-monitor.sock"), + Duration::from_millis(80), + )); + handle.set_running(true); + assert!(!handle.is_agent_locked()); + + tokio::spawn(run_idle_monitor(handle.clone(), Duration::from_millis(20))); + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + handle.is_agent_locked(), + "an agent left idle past its timeout must auto-lock" + ); + } } From 65e429a8198cf588a69b542a948ba2a1ecc17897 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 01:31:48 +0100 Subject: [PATCH 04/10] harden producer-side signing: locked-down agent socket, bounded unlock window, and refuse revoked/rotated-out keys Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/Cargo.toml | 1 + crates/auths-core/src/agent/handle.rs | 148 +++++++++++++++- crates/auths-core/src/agent/mod.rs | 2 +- crates/auths-core/src/agent/session.rs | 83 ++++++++- crates/auths-core/src/api/runtime.rs | 162 ++++++++++++++---- crates/auths-core/src/crypto/encryption.rs | 33 +++- .../auths-core/src/storage/encrypted_file.rs | 46 +++++ crates/auths-core/tests/cases/agent_socket.rs | 88 ++++++++++ crates/auths-core/tests/cases/key_export.rs | 98 +++++++++++ crates/auths-core/tests/cases/mod.rs | 1 + .../tests/cases/rotation_edge_cases.rs | 49 ++++++ .../auths-sdk/src/domains/signing/service.rs | 112 +++++++++++- crates/auths-sdk/tests/cases/device.rs | 49 ++++++ crates/auths-sdk/tests/cases/rotation.rs | 54 ++++++ crates/auths-verifier/src/duplicity.rs | 28 +++ 15 files changed, 907 insertions(+), 47 deletions(-) create mode 100644 crates/auths-core/tests/cases/agent_socket.rs diff --git a/crates/auths-core/Cargo.toml b/crates/auths-core/Cargo.toml index 4ee93953..bcd5316e 100644 --- a/crates/auths-core/Cargo.toml +++ b/crates/auths-core/Cargo.toml @@ -86,6 +86,7 @@ windows = { version = "0.58", features = ["Security_Credentials", "Foundation_Co [dev-dependencies] ring.workspace = true anyhow = "1.0" +log = "0.4" p256 = { version = "=0.13.2", features = ["ecdsa"] } assert_matches = "1.5.0" auths-verifier = { path = "../auths-verifier", features = ["test-utils"] } diff --git a/crates/auths-core/src/agent/handle.rs b/crates/auths-core/src/agent/handle.rs index 4ad0c6e2..75d8f290 100644 --- a/crates/auths-core/src/agent/handle.rs +++ b/crates/auths-core/src/agent/handle.rs @@ -14,6 +14,10 @@ use zeroize::Zeroizing; /// Default idle timeout (30 minutes) pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Default absolute cap on how long a single unlock keeps signing capability, +/// measured from the last unlock and independent of activity (8 hours). +pub const DEFAULT_MAX_UNLOCK_TTL: Duration = Duration::from_secs(8 * 60 * 60); + /// A handle to an agent instance that manages its lifecycle. /// /// `AgentHandle` wraps an `AgentCore` and provides: @@ -37,6 +41,10 @@ pub struct AgentHandle { last_activity: Arc>, /// Idle timeout duration (0 = never timeout) idle_timeout: Duration, + /// Timestamp of the last unlock (for the absolute unlock cap, shared across clones) + unlocked_at: Arc>, + /// Absolute cap on how long a single unlock keeps signing capability (0 = no cap) + max_unlock_ttl: Duration, /// Whether the agent is currently locked (shared across clones) locked: Arc, } @@ -49,6 +57,7 @@ impl std::fmt::Debug for AgentHandle { .field("running", &self.is_running()) .field("locked", &self.is_agent_locked()) .field("idle_timeout", &self.idle_timeout) + .field("max_unlock_ttl", &self.max_unlock_ttl) .finish_non_exhaustive() } } @@ -59,8 +68,32 @@ impl AgentHandle { Self::with_timeout(socket_path, DEFAULT_IDLE_TIMEOUT) } - /// Creates a new agent handle with the specified socket path and timeout. + /// Creates a new agent handle with the specified socket path and idle timeout, + /// using the default absolute unlock cap. pub fn with_timeout(socket_path: PathBuf, idle_timeout: Duration) -> Self { + Self::with_unlock_cap(socket_path, idle_timeout, DEFAULT_MAX_UNLOCK_TTL) + } + + /// Creates a new agent handle with an explicit idle timeout and absolute unlock cap. + /// + /// The idle timeout locks the agent after a period of no signing (a "walked away" + /// control); the unlock cap locks it a fixed time after it was last unlocked, + /// regardless of activity, so continuous signing cannot keep it unlocked forever. + /// + /// Args: + /// * `socket_path`: The Unix-domain socket path. + /// * `idle_timeout`: Sliding inactivity timeout (0 disables). + /// * `max_unlock_ttl`: Absolute cap measured from the last unlock (0 disables). + /// + /// Usage: + /// ```ignore + /// let handle = AgentHandle::with_unlock_cap(path, idle, cap); + /// ``` + pub fn with_unlock_cap( + socket_path: PathBuf, + idle_timeout: Duration, + max_unlock_ttl: Duration, + ) -> Self { Self { core: Arc::new(Mutex::new(AgentCore::default())), socket_path, @@ -68,6 +101,8 @@ impl AgentHandle { running: Arc::new(AtomicBool::new(false)), last_activity: Arc::new(Mutex::new(Instant::now())), idle_timeout, + unlocked_at: Arc::new(Mutex::new(Instant::now())), + max_unlock_ttl, locked: Arc::new(AtomicBool::new(false)), } } @@ -81,6 +116,8 @@ impl AgentHandle { running: Arc::new(AtomicBool::new(false)), last_activity: Arc::new(Mutex::new(Instant::now())), idle_timeout: DEFAULT_IDLE_TIMEOUT, + unlocked_at: Arc::new(Mutex::new(Instant::now())), + max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL, locked: Arc::new(AtomicBool::new(false)), } } @@ -98,6 +135,8 @@ impl AgentHandle { running: Arc::new(AtomicBool::new(false)), last_activity: Arc::new(Mutex::new(Instant::now())), idle_timeout, + unlocked_at: Arc::new(Mutex::new(Instant::now())), + max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL, locked: Arc::new(AtomicBool::new(false)), } } @@ -111,6 +150,8 @@ impl AgentHandle { running: Arc::new(AtomicBool::new(false)), last_activity: Arc::new(Mutex::new(Instant::now())), idle_timeout: DEFAULT_IDLE_TIMEOUT, + unlocked_at: Arc::new(Mutex::new(Instant::now())), + max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL, locked: Arc::new(AtomicBool::new(false)), } } @@ -186,6 +227,31 @@ impl AgentHandle { self.idle_duration() >= self.idle_timeout } + /// Returns the absolute cap on how long a single unlock keeps signing capability. + pub fn max_unlock_ttl(&self) -> Duration { + self.max_unlock_ttl + } + + /// Returns the time elapsed since the agent was last unlocked. + pub fn unlock_age(&self) -> Duration { + self.unlocked_at + .lock() + .map(|t| t.elapsed()) + .unwrap_or(Duration::ZERO) + } + + /// Returns whether the agent has been unlocked longer than its absolute cap. + /// + /// Unlike the idle timeout, this does not reset on activity — it bounds the total + /// lifetime of a single unlock so continuous signing cannot keep the agent unlocked + /// indefinitely. A cap of 0 disables it. + pub fn is_unlock_window_expired(&self) -> bool { + if self.max_unlock_ttl.is_zero() { + return false; + } + self.unlock_age() >= self.max_unlock_ttl + } + /// Returns whether the agent is currently locked. pub fn is_agent_locked(&self) -> bool { self.locked.load(Ordering::SeqCst) @@ -217,16 +283,24 @@ impl AgentHandle { log::info!("Unlocking agent"); self.locked.store(false, Ordering::SeqCst); self.touch(); // Reset idle timer + if let Ok(mut unlocked) = self.unlocked_at.lock() { + *unlocked = Instant::now(); // Start the absolute unlock window + } } - /// Checks idle timeout and locks the agent if exceeded. + /// Locks the agent if its unlock window has ended, clearing its keys. /// - /// Call this periodically from a background task. + /// The agent locks when it has been idle past the idle timeout (a "walked away" + /// control) or when it has been unlocked longer than the absolute cap (a backstop + /// so continuous signing cannot keep it unlocked indefinitely). Call this + /// periodically from a background task. Neither control stops a process running as + /// the same user from using the agent within the window — they only bound the window. pub fn check_idle_timeout(&self) -> Result { - if self.is_idle_timed_out() && !self.is_agent_locked() { + if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked() { log::info!( - "Agent idle for {:?}, locking due to timeout", - self.idle_duration() + "Locking agent: idle for {:?}, unlocked for {:?}", + self.idle_duration(), + self.unlock_age() ); self.lock_agent()?; return Ok(true); @@ -292,8 +366,14 @@ impl AgentHandle { /// Registers a key in the agent core. pub fn register_key(&self, pkcs8_bytes: Zeroizing>) -> Result<(), AgentError> { - let mut core = self.lock()?; - core.register_key(pkcs8_bytes) + { + let mut core = self.lock()?; + core.register_key(pkcs8_bytes)?; + } + if let Ok(mut unlocked) = self.unlocked_at.lock() { + *unlocked = Instant::now(); // Loading a key starts the absolute unlock window + } + Ok(()) } /// Signs data using a key in the agent core. @@ -327,6 +407,8 @@ impl Clone for AgentHandle { running: Arc::clone(&self.running), last_activity: Arc::clone(&self.last_activity), idle_timeout: self.idle_timeout, + unlocked_at: Arc::clone(&self.unlocked_at), + max_unlock_ttl: self.max_unlock_ttl, locked: Arc::clone(&self.locked), } } @@ -574,4 +656,54 @@ mod tests { let result = handle_b.sign(pubkey, b"test data"); assert!(matches!(result, Err(AgentError::AgentLocked))); } + + #[test] + fn absolute_cap_locks_despite_continuous_activity() { + // Idle timeout effectively never; only the absolute unlock cap can fire. + let handle = AgentHandle::with_unlock_cap( + PathBuf::from("/tmp/test.sock"), + Duration::from_secs(3600), + Duration::from_millis(80), + ); + handle + .register_key(Zeroizing::new(generate_test_pkcs8())) + .unwrap(); + + // Continuous activity keeps resetting the sliding idle timer. + for _ in 0..6 { + std::thread::sleep(Duration::from_millis(25)); + handle.touch(); + } + + assert!( + handle.is_unlock_window_expired(), + "unlock window should be expired ~150ms past an 80ms cap" + ); + let locked = handle.check_idle_timeout().unwrap(); + assert!( + locked, + "the absolute unlock cap must lock the agent regardless of activity" + ); + assert!(handle.is_agent_locked()); + assert_eq!(handle.key_count().unwrap(), 0); + } + + #[test] + fn public_keys_empty_after_lock() { + let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock")); + handle + .register_key(Zeroizing::new(generate_test_pkcs8())) + .unwrap(); + assert_eq!(handle.public_keys().unwrap().len(), 1); + + handle.lock_agent().unwrap(); + + // Locking must clear keys before flipping the locked flag, so a listing request + // observed while locked cannot leak the key set. + assert!(handle.is_agent_locked()); + assert!( + handle.public_keys().unwrap().is_empty(), + "locking must clear keys so the key list cannot leak while locked" + ); + } } diff --git a/crates/auths-core/src/agent/mod.rs b/crates/auths-core/src/agent/mod.rs index e21f624a..6765e1eb 100644 --- a/crates/auths-core/src/agent/mod.rs +++ b/crates/auths-core/src/agent/mod.rs @@ -18,4 +18,4 @@ pub use core::AgentCore; pub use handle::{AgentHandle, DEFAULT_IDLE_TIMEOUT}; pub use session::AgentSession; #[cfg(unix)] -pub use session::PeerAuthorizedAgent; +pub(crate) use session::PeerAuthorizedAgent; diff --git a/crates/auths-core/src/agent/session.rs b/crates/auths-core/src/agent/session.rs index 6e52a43a..67bbdae1 100644 --- a/crates/auths-core/src/agent/session.rs +++ b/crates/auths-core/src/agent/session.rs @@ -296,7 +296,7 @@ impl Session for AgentSession { /// ```ignore /// if peer_is_authorized(peer_uid, owner_uid) { /* serve the connection */ } /// ``` -pub fn peer_is_authorized(peer_uid: u32, owner_uid: u32) -> bool { +fn peer_is_authorized(peer_uid: u32, owner_uid: u32) -> bool { peer_uid == owner_uid } @@ -305,7 +305,7 @@ pub fn peer_is_authorized(peer_uid: u32, owner_uid: u32) -> bool { /// `Authorized` connections are served by an `AgentSession`; `Denied` connections /// have every request refused, so an unauthorized peer can neither sign nor list keys. #[cfg(unix)] -pub enum MaybeAuthorized { +pub(crate) enum MaybeAuthorized { /// The peer owns the agent and is served normally. Authorized(AgentSession), /// The peer is not the owner; every request is refused. @@ -359,7 +359,7 @@ impl Session for MaybeAuthorized { /// connection that is not the owning user (failing closed if the credentials /// cannot be read). #[cfg(unix)] -pub struct PeerAuthorizedAgent { +pub(crate) struct PeerAuthorizedAgent { handle: Arc, owner_uid: u32, } @@ -376,7 +376,7 @@ impl PeerAuthorizedAgent { /// ```ignore /// let agent = PeerAuthorizedAgent::new(handle, owner_uid); /// ``` - pub fn new(handle: Arc, owner_uid: u32) -> Self { + pub(crate) fn new(handle: Arc, owner_uid: u32) -> Self { Self { handle, owner_uid } } } @@ -638,6 +638,81 @@ mod tests { assert!(session.sign(request).await.is_err()); } + #[cfg(unix)] + #[tokio::test] + async fn denied_session_refuses_every_request_variant() { + use ssh_agent_lib::proto::{ + AddIdentityConstrained, AddSmartcardKeyConstrained, Extension, Request, Response, + SmartcardKey, + }; + + fn ed_keydata() -> KeyData { + KeyData::Ed25519(Ed25519PublicKey([0u8; 32])) + } + fn an_add_identity() -> AddIdentity { + AddIdentity { + credential: Credential::Key { + privkey: KeypairData::Ed25519(SshEd25519Keypair::from_seed(&[0u8; 32])), + comment: String::new(), + }, + } + } + fn a_smartcard() -> SmartcardKey { + SmartcardKey { + id: String::new(), + pin: String::new().into(), + } + } + + // Every request the SSH agent protocol defines. A denied connection must refuse + // all of them, so an upstream change that adds a permissive default cannot open + // a hole. Payloads are minimal — a denied session never inspects them. + let requests = vec![ + Request::RequestIdentities, + Request::SignRequest(SignRequest { + pubkey: ed_keydata(), + data: Vec::new(), + flags: 0, + }), + Request::AddIdentity(an_add_identity()), + Request::RemoveIdentity(RemoveIdentity { + pubkey: ed_keydata(), + }), + Request::RemoveAllIdentities, + Request::AddSmartcardKey(a_smartcard()), + Request::RemoveSmartcardKey(a_smartcard()), + Request::Lock(String::new()), + Request::Unlock(String::new()), + Request::AddIdConstrained(AddIdentityConstrained { + identity: an_add_identity(), + constraints: Vec::new(), + }), + Request::AddSmartcardKeyConstrained(AddSmartcardKeyConstrained { + key: a_smartcard(), + constraints: Vec::new(), + }), + Request::Extension(Extension { + name: String::new(), + details: Vec::::new().into(), + }), + ]; + assert_eq!(requests.len(), 12, "all agent request variants are covered"); + + for request in requests { + let mut session = MaybeAuthorized::Denied; + // Drive the real dispatcher and map its result the way the wire loop does. + let response = match session.handle(request).await { + Err(SSHAgentError::ExtensionFailure) => Response::ExtensionFailure, + Err(_) => Response::Failure, + Ok(r) => r, + }; + assert!( + matches!(response, Response::Failure | Response::ExtensionFailure), + "a denied session must refuse every request variant, got {response:?}" + ); + } + } + #[cfg(unix)] #[tokio::test] async fn sign_resets_idle_timer() { diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 20efb7a2..68bc766e 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -788,11 +788,16 @@ fn register_keys_with_macos_agent_internal( Ok(results) } -/// Creates the directory that holds the agent socket and restricts it to owner-only -/// access (`0o700`) so no other user can traverse into it and reach the socket. +/// Ensures the directory that holds the agent socket is restricted to the owner. +/// +/// If the directory does not exist it is created (with parents) and locked to `0o700`. +/// If it already exists it is accepted only when it is already owner-only (no group or +/// other access) and owned by this user; otherwise it is refused (fail closed) rather +/// than silently widening or narrowing a directory the agent did not create. A +/// non-directory or a symlink at the path is also refused. /// /// Args: -/// * `dir`: The directory to create (with parents) and lock down. +/// * `dir`: The directory the socket lives in. /// /// Usage: /// ```ignore @@ -800,14 +805,47 @@ fn register_keys_with_macos_agent_internal( /// ``` #[cfg(unix)] fn harden_socket_dir(dir: &std::path::Path) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt as _; - std::fs::create_dir_all(dir)?; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + match std::fs::symlink_metadata(dir) { + Ok(meta) if meta.file_type().is_dir() => { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "socket directory {dir:?} is group/other-accessible (mode {mode:o}); refusing" + ), + )); + } + if meta.uid() != current_euid() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("socket directory {dir:?} is not owned by this user; refusing"), + )); + } + Ok(()) + } + Ok(_) => Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("socket directory path {dir:?} exists but is not a directory; refusing"), + )), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + std::fs::create_dir_all(dir)?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + } + Err(e) => Err(e), + } } /// Restricts a bound agent socket to owner-only read/write (`0o600`) so only the /// owning user can connect and request signatures. /// +/// This is best-effort defense-in-depth: there is a brief window between `bind` and +/// this call. The authoritative, race-free control is the owner-only (`0o700`) socket +/// directory established by [`harden_socket_dir`] — no other user can traverse into it +/// to reach the socket regardless of the socket file's own mode. +/// /// Args: /// * `socket_path`: The path of the already-bound Unix-domain socket. /// @@ -821,6 +859,28 @@ fn harden_socket_file(socket_path: &std::path::Path) -> std::io::Result<()> { std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600)) } +/// Returns the directory that must be restricted to the owner for a given socket +/// path, refusing a path that has no such directory (a bare relative name) rather +/// than leaving the socket in an unrestricted location. +/// +/// Args: +/// * `socket_path`: The socket path whose containing directory will be locked down. +/// +/// Usage: +/// ```ignore +/// let dir = socket_dir_to_harden(socket_path)?; +/// ``` +#[cfg(unix)] +fn socket_dir_to_harden(socket_path: &std::path::Path) -> Result<&std::path::Path, AgentError> { + match socket_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => Ok(parent), + _ => Err(AgentError::IO(io::Error::new( + io::ErrorKind::InvalidInput, + "agent socket path must include a directory that can be restricted to the owner", + ))), + } +} + /// Returns the effective user id of the current process — the only user permitted /// to connect to the agent socket. #[cfg(unix)] @@ -829,15 +889,19 @@ fn current_euid() -> u32 { unsafe { libc::geteuid() } } -/// Chooses how often to check the agent for idle timeout, bounded to a sensible -/// range. Returns `None` when the idle timeout is disabled (zero). +/// Chooses how often to check the agent for auto-lock, bounded to a sensible range. +/// Returns `None` when both the idle timeout and the absolute unlock cap are disabled. #[cfg(unix)] -fn idle_monitor_interval(idle_timeout: std::time::Duration) -> Option { +fn idle_monitor_interval( + idle_timeout: std::time::Duration, + max_unlock_ttl: std::time::Duration, +) -> Option { use std::time::Duration; - if idle_timeout.is_zero() { - return None; - } - Some((idle_timeout / 4).clamp(Duration::from_secs(1), Duration::from_secs(60))) + let shortest = [idle_timeout, max_unlock_ttl] + .into_iter() + .filter(|d| !d.is_zero()) + .min()?; + Some((shortest / 4).clamp(Duration::from_secs(1), Duration::from_secs(60))) } /// Locks the agent once it has been idle past its timeout, clearing its keys, so a @@ -868,9 +932,9 @@ async fn run_idle_monitor(handle: Arc, interval: std::time::Duratio /// Starts the SSH agent listener using the provided `AgentHandle`. /// -/// Binds to the socket path from the handle, cleans up any old socket file if present, -/// and enters an asynchronous loop (`ssh_agent_lib::listen`) to accept and handle -/// incoming agent connections using `AgentSession`. +/// Binds to the socket path from the handle, restricts the socket and its directory +/// to the owner, and enters an asynchronous loop (`ssh_agent_lib::listen`) that +/// authorizes each connection by peer UID before serving it. /// /// Requires a `tokio` runtime context. Runs indefinitely on success. /// @@ -886,17 +950,14 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul let socket_path = handle.socket_path(); info!("Attempting to start agent listener at {:?}", socket_path); - // --- Ensure parent directory exists and is owner-only --- - if let Some(parent) = socket_path.parent() - && !parent.as_os_str().is_empty() - { - if let Err(e) = harden_socket_dir(parent) { - error!( - "Failed to prepare owner-only socket directory {:?}: {}", - parent, e - ); - return Err(AgentError::IO(e)); - } + // --- Ensure the socket lives in an owner-only directory --- + let socket_dir = socket_dir_to_harden(socket_path)?; + if let Err(e) = harden_socket_dir(socket_dir) { + error!( + "Failed to prepare owner-only socket directory {:?}: {}", + socket_dir, e + ); + return Err(AgentError::IO(e)); } // --- Clean up existing socket file (if any) --- @@ -944,8 +1005,8 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul // Mark agent as running handle.set_running(true); - // --- Auto-lock the agent after it has been idle past its timeout --- - if let Some(interval) = idle_monitor_interval(handle.idle_timeout()) { + // --- Auto-lock the agent after it has been idle, or unlocked past its cap --- + if let Some(interval) = idle_monitor_interval(handle.idle_timeout(), handle.max_unlock_ttl()) { tokio::spawn(run_idle_monitor(handle.clone(), interval)); } @@ -994,7 +1055,8 @@ pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentEr #[cfg(all(test, unix))] mod hardening_tests { use super::{ - AgentHandle, harden_socket_dir, harden_socket_file, idle_monitor_interval, run_idle_monitor, + AgentHandle, harden_socket_dir, harden_socket_file, idle_monitor_interval, + run_idle_monitor, socket_dir_to_harden, }; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; @@ -1036,14 +1098,50 @@ mod hardening_tests { ); } + #[test] + fn refuses_preexisting_group_accessible_socket_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("loose"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + // A pre-existing directory we did not lock down must be refused, not narrowed. + assert!(harden_socket_dir(&dir).is_err()); + assert_eq!( + mode_of(&dir), + 0o755, + "must not silently chmod a directory it does not own" + ); + } + + #[test] + fn accepts_preexisting_owner_only_socket_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("tight"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(harden_socket_dir(&dir).is_ok()); + } + + #[test] + fn socket_with_no_restrictable_directory_is_rejected() { + // A bare/relative socket name has no directory we can lock down to 0o700, + // so the listener must refuse it rather than skip directory hardening. + assert!(socket_dir_to_harden(std::path::Path::new("agent.sock")).is_err()); + assert!(socket_dir_to_harden(std::path::Path::new("")).is_err()); + let dir = socket_dir_to_harden(std::path::Path::new("/home/u/.auths/agent.sock")) + .expect("an absolute socket path has a directory to harden"); + assert_eq!(dir, std::path::Path::new("/home/u/.auths")); + } + #[test] fn idle_monitor_disabled_when_timeout_is_zero() { - assert!(idle_monitor_interval(Duration::ZERO).is_none()); + assert!(idle_monitor_interval(Duration::ZERO, Duration::ZERO).is_none()); } #[test] fn idle_monitor_interval_is_bounded() { - let interval = idle_monitor_interval(Duration::from_secs(30 * 60)).expect("some interval"); + let interval = idle_monitor_interval(Duration::from_secs(30 * 60), Duration::from_secs(8 * 60 * 60)) + .expect("some interval"); assert!(interval >= Duration::from_secs(1)); assert!(interval <= Duration::from_secs(60)); } diff --git a/crates/auths-core/src/crypto/encryption.rs b/crates/auths-core/src/crypto/encryption.rs index a40be428..e0b10bb2 100644 --- a/crates/auths-core/src/crypto/encryption.rs +++ b/crates/auths-core/src/crypto/encryption.rs @@ -26,6 +26,15 @@ pub const ARGON2_TAG: u8 = 3; /// Length of embedded Argon2 parameters: 3 × u32 LE = 12 bytes. const ARGON2_PARAMS_LEN: usize = 12; +/// Production Argon2id memory cost in KiB (64 MiB). Exposed as a const so the +/// strength of the at-rest KDF can be asserted regardless of build profile (test +/// builds derive keys with deliberately weak params for speed). +pub const PRODUCTION_KDF_M_COST_KIB: u32 = 65536; +/// Production Argon2id time cost (iterations). +pub const PRODUCTION_KDF_T_COST: u32 = 3; +/// Production Argon2id parallelism. +pub const PRODUCTION_KDF_P_COST: u32 = 1; + /// Returns Argon2id parameters for key derivation. /// /// Production builds use OWASP-recommended settings (m=64 MiB, t=3). @@ -44,7 +53,12 @@ const ARGON2_PARAMS_LEN: usize = 12; /// ``` pub fn get_kdf_params() -> Result { #[cfg(not(any(test, feature = "test-utils")))] - let params = Params::new(65536, 3, 1, Some(SYMMETRIC_KEY_LEN)); + let params = Params::new( + PRODUCTION_KDF_M_COST_KIB, + PRODUCTION_KDF_T_COST, + PRODUCTION_KDF_P_COST, + Some(SYMMETRIC_KEY_LEN), + ); #[cfg(any(test, feature = "test-utils"))] let params = Params::new(8, 1, 1, Some(SYMMETRIC_KEY_LEN)); params.map_err(|e| AgentError::CryptoError(format!("Invalid Argon2 params: {}", e))) @@ -336,4 +350,21 @@ mod tests { let result = encrypt_bytes(b"data", "weak", EncryptionAlgorithm::AesGcm256); assert!(matches!(result, Err(AgentError::WeakPassphrase(_)))); } + + #[test] + fn keychain_kdf_cost_is_strong() { + // The at-rest keychain KDF must make offline brute-force of a weak passphrase + // expensive. Assert the production Argon2id parameters meet OWASP minimums + // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys + // with weaker params for speed, so this checks the production constants. + assert!( + PRODUCTION_KDF_M_COST_KIB >= 19 * 1024, + "production Argon2 memory cost too low: {PRODUCTION_KDF_M_COST_KIB} KiB" + ); + assert!( + PRODUCTION_KDF_T_COST >= 2, + "production Argon2 time cost too low: {PRODUCTION_KDF_T_COST}" + ); + assert!(PRODUCTION_KDF_P_COST >= 1); + } } diff --git a/crates/auths-core/src/storage/encrypted_file.rs b/crates/auths-core/src/storage/encrypted_file.rs index bb125d1a..10eb1331 100644 --- a/crates/auths-core/src/storage/encrypted_file.rs +++ b/crates/auths-core/src/storage/encrypted_file.rs @@ -466,6 +466,52 @@ mod tests { assert_eq!(loaded_data, encrypted_data); } + #[test] + fn keychain_file_has_no_plaintext_secret() { + use crate::crypto::signer::encrypt_keypair; + use base64::{Engine, engine::general_purpose::STANDARD as B64}; + use ring::signature::Ed25519KeyPair; + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) + } + + let (storage, temp) = create_test_storage(); + let alias = KeyAlias::new("secret").unwrap(); + let identity_did = IdentityDID::parse("did:keri:test123").unwrap(); + + // A real keypair whose raw seed bytes we learn, then persist (encrypted). + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let pkcs8_bytes = pkcs8.as_ref().to_vec(); + let seed = pkcs8_bytes[16..48].to_vec(); // ring Ed25519 v2 seed offset + + let encrypted = encrypt_keypair(&pkcs8_bytes, "Test-passphrase1!").unwrap(); + storage + .store_key(&alias, &identity_did, KeyRole::Primary, &encrypted) + .unwrap(); + + let raw = std::fs::read(temp.path().join("keys.enc")).unwrap(); + assert!(!contains(&raw, &seed), "raw seed found in keystore file"); + assert!( + !contains(&raw, &pkcs8_bytes), + "raw private key found in keystore file" + ); + + // Genuinely encrypted, not merely encoded: the seed must not survive + // base64-decoding the ciphertext field. + let parsed: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + let ciphertext = B64.decode(parsed["ciphertext"].as_str().unwrap()).unwrap(); + assert!( + !contains(&ciphertext, &seed), + "raw seed recoverable from the base64-decoded ciphertext (encoded, not encrypted)" + ); + assert!( + !contains(&ciphertext, &pkcs8_bytes), + "raw private key recoverable from the base64-decoded ciphertext" + ); + } + #[test] fn load_rejects_tampered_identity_did() { let (storage, _temp) = create_test_storage(); diff --git a/crates/auths-core/tests/cases/agent_socket.rs b/crates/auths-core/tests/cases/agent_socket.rs new file mode 100644 index 00000000..5f23b1ce --- /dev/null +++ b/crates/auths-core/tests/cases/agent_socket.rs @@ -0,0 +1,88 @@ +//! End-to-end test for the agent socket on the live wired path. +//! +//! Drives the real listener over a real Unix socket through the connecting +//! client, and asserts that locking the agent stops it from signing — the +//! property a single-component unit test cannot cover, since it spans the +//! client transport, the listener, peer authorization, and the lock check. + +#![cfg(unix)] + +use auths_core::AgentHandle; +use auths_core::agent::{AgentStatus, add_identity, agent_sign, check_agent_status}; +use auths_core::api::start_agent_listener_with_handle; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +fn generate_ed25519_pkcs8() -> Vec { + use ring::rand::SystemRandom; + use ring::signature::Ed25519KeyPair; + let rng = SystemRandom::new(); + Ed25519KeyPair::generate_pkcs8(&rng) + .expect("generate pkcs8") + .as_ref() + .to_vec() +} + +fn unique_socket_dir() -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("auths-agent-e2e-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp socket dir"); + // The listener accepts a pre-existing socket directory only when it is owner-only. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .expect("restrict temp socket dir"); + dir +} + +async fn wait_until_running(socket_path: &Path) { + for _ in 0..150 { + if let AgentStatus::Running { .. } = check_agent_status(socket_path) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("agent listener did not become ready"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn locked_agent_refuses_signing_over_the_socket() { + let dir = unique_socket_dir(); + let socket_path = dir.join("agent.sock"); + + let handle = Arc::new(AgentHandle::new(socket_path.clone())); + let server = tokio::spawn(start_agent_listener_with_handle(handle.clone())); + wait_until_running(&socket_path).await; + + let pkcs8 = generate_ed25519_pkcs8(); + let add_path = socket_path.clone(); + let pubkey = tokio::task::spawn_blocking(move || add_identity(&add_path, &pkcs8)) + .await + .expect("add task") + .expect("add_identity over the socket should succeed"); + + let sign_path = socket_path.clone(); + let unlocked_pubkey = pubkey.clone(); + let unlocked = tokio::task::spawn_blocking(move || { + agent_sign(&sign_path, &unlocked_pubkey, b"payload") + }) + .await + .expect("sign task"); + assert!( + unlocked.is_ok(), + "an unlocked agent should sign over the socket" + ); + + handle.lock_agent().expect("lock the agent"); + + let denied_path = socket_path.clone(); + let denied = tokio::task::spawn_blocking(move || agent_sign(&denied_path, &pubkey, b"payload")) + .await + .expect("sign task"); + assert!( + denied.is_err(), + "a locked agent must refuse to sign over the socket" + ); + + server.abort(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/auths-core/tests/cases/key_export.rs b/crates/auths-core/tests/cases/key_export.rs index 7e451bb1..92bc25be 100644 --- a/crates/auths-core/tests/cases/key_export.rs +++ b/crates/auths-core/tests/cases/key_export.rs @@ -176,3 +176,101 @@ fn test_export_nonexistent_key() { let result = export_key_openssh_pub("nonexistent-alias", "any-pass", keychain.as_ref()); assert!(result.is_err(), "Should fail for nonexistent key"); } + +fn byte_seq_present(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) +} + +#[test] +fn export_output_contains_no_raw_secret() { + let keychain = fresh_keychain(); + let alias = "export-secret"; + let passphrase = "Test-passphrase1!"; + let identity_did = IdentityDID::parse("did:keri:ExportSecret").unwrap(); + + let (pkcs8_bytes, _pubkey) = create_ring_compatible_pkcs8(); + let seed = pkcs8_bytes[16..48].to_vec(); + let encrypted = encrypt_keypair(&pkcs8_bytes, passphrase).expect("encrypt"); + keychain + .store_key( + &KeyAlias::new_unchecked(alias), + &identity_did, + KeyRole::Primary, + &encrypted, + ) + .expect("store"); + + // Public-key export carries only public material — never the seed or private key. + let pub_out = export_key_openssh_pub(alias, passphrase, keychain.as_ref()).expect("pub export"); + assert!( + !byte_seq_present(pub_out.as_bytes(), &seed), + "public export leaked the seed" + ); + assert!( + !byte_seq_present(pub_out.as_bytes(), &pkcs8_bytes), + "public export leaked the private key" + ); + + // The at-rest (encrypted) export a non-interactive caller can obtain is + // passphrase-wrapped, never the raw secret. + let (_did, _role, enc_out) = keychain + .load_key(&KeyAlias::new_unchecked(alias)) + .expect("load"); + assert!( + !byte_seq_present(&enc_out, &seed), + "encrypted export leaked the seed" + ); +} + +#[test] +fn no_secret_in_logs() { + use std::sync::{Mutex, OnceLock}; + + static LOG_BUF: OnceLock>> = OnceLock::new(); + struct CaptureLogger; + impl log::Log for CaptureLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, record: &log::Record) { + if let Some(buf) = LOG_BUF.get() { + use std::io::Write; + let _ = writeln!(buf.lock().unwrap(), "{} {}", record.level(), record.args()); + } + } + fn flush(&self) {} + } + + LOG_BUF.set(Mutex::new(Vec::new())).ok(); + // nextest runs each test in its own process, so installing a global logger is safe here. + let _ = log::set_boxed_logger(Box::new(CaptureLogger)); + log::set_max_level(log::LevelFilter::Trace); + + let keychain = fresh_keychain(); + let alias = "log-secret"; + let passphrase = "Test-passphrase1!"; + let identity_did = IdentityDID::parse("did:keri:LogSecret").unwrap(); + let (pkcs8_bytes, _pubkey) = create_ring_compatible_pkcs8(); + let seed = pkcs8_bytes[16..48].to_vec(); + let encrypted = encrypt_keypair(&pkcs8_bytes, passphrase).expect("encrypt"); + keychain + .store_key( + &KeyAlias::new_unchecked(alias), + &identity_did, + KeyRole::Primary, + &encrypted, + ) + .expect("store"); + let _ = export_key_openssh_pub(alias, passphrase, keychain.as_ref()); + + let logs = LOG_BUF.get().unwrap().lock().unwrap().clone(); + assert!(!byte_seq_present(&logs, &seed), "seed leaked in logs"); + assert!( + !byte_seq_present(&logs, &pkcs8_bytes), + "private key leaked in logs" + ); + assert!( + !byte_seq_present(&logs, passphrase.as_bytes()), + "passphrase leaked in logs" + ); +} diff --git a/crates/auths-core/tests/cases/mod.rs b/crates/auths-core/tests/cases/mod.rs index c07fb892..d297d43c 100644 --- a/crates/auths-core/tests/cases/mod.rs +++ b/crates/auths-core/tests/cases/mod.rs @@ -1,3 +1,4 @@ +mod agent_socket; mod assurance_level; mod ffi_context; mod key_export; diff --git a/crates/auths-id/tests/cases/rotation_edge_cases.rs b/crates/auths-id/tests/cases/rotation_edge_cases.rs index 85e2cbe2..980c2387 100644 --- a/crates/auths-id/tests/cases/rotation_edge_cases.rs +++ b/crates/auths-id/tests/cases/rotation_edge_cases.rs @@ -352,3 +352,52 @@ fn multiple_rotations_interleaved_with_ixn() { let key_state = get_key_state(&repo, &init.prefix).unwrap(); assert_eq!(key_state.sequence, 5); } + +#[test] +fn interrupted_rotation_leaves_consistent_state() { + // A rotation that is interrupted and retried (here: a second rotation replaying the + // already-consumed next key, as a crash-and-retry would) must not append a second, + // competing event. The KEL is left with exactly one valid head — no half-rotated, + // dual-valid, or forkable state. + let backend = FakeRegistryBackend::new(); + let init = create_keri_identity_with_backend(&backend, None).unwrap(); + + let rot1 = rotate_keys_with_backend( + &backend, + &init.prefix, + &init.next_keypair_pkcs8, + chrono::Utc::now(), + None, + ) + .unwrap(); + assert_eq!(rot1.sequence, 1); + + // The retry is rejected before any append (the commitment check precedes the write), + // so it cannot fork the KEL. + let interrupted = rotate_keys_with_backend( + &backend, + &init.prefix, + &init.next_keypair_pkcs8, + chrono::Utc::now(), + None, + ); + assert!(matches!(interrupted, Err(RotationError::CommitmentMismatch))); + + // Exactly one head: inception + one rotation, no competing event at any sequence. + let mut events = Vec::new(); + backend + .visit_events(&init.prefix, 0, &mut |e| { + events.push(e.clone()); + ControlFlow::Continue(()) + }) + .unwrap(); + assert_eq!( + events.len(), + 2, + "interrupted rotation must not append a competing event" + ); + + let state = validate_kel(&events).unwrap(); + assert_eq!(state.sequence, 1, "exactly one head at the rotated sequence"); + assert!(!state.is_abandoned); +} diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index dc493316..661c2644 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -278,6 +278,14 @@ pub enum ArtifactSigningError { /// The provided commit SHA has an invalid format. #[error("invalid commit SHA: {0} (expected 40 or 64 hex characters)")] InvalidCommitSha(String), + + /// The signing device's delegated identity has been revoked by the root. + #[error("device revoked: {0}")] + DeviceRevoked(String), + + /// The signing key is no longer the current key in the identity's KEL (rotated out). + #[error("signing key rotated out of the KEL: {0}")] + KeyRotatedOut(String), } impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { @@ -290,6 +298,8 @@ impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { Self::AttestationFailed(_) => "AUTHS-E5854", Self::ResignFailed(_) => "AUTHS-E5855", Self::InvalidCommitSha(_) => "AUTHS-E5856", + Self::DeviceRevoked(_) => "AUTHS-E5857", + Self::KeyRotatedOut(_) => "AUTHS-E5858", } } @@ -310,6 +320,12 @@ impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { Self::InvalidCommitSha(_) => { Some("Provide a full SHA-1 (40 hex chars) or SHA-256 (64 hex chars) commit hash") } + Self::DeviceRevoked(_) => { + Some("This device has been removed; pair a new device or sign from an active one") + } + Self::KeyRotatedOut(_) => { + Some("This key was rotated out; sign with the identity's current key") + } } } } @@ -360,6 +376,10 @@ struct ResolvedKey { curve: auths_crypto::CurveType, /// True if this key is backed by hardware (Secure Enclave, HSM). is_hardware: bool, + /// The identity (KERI AID) the stored key belongs to, when known. Present for a + /// software key loaded by alias; `None` for direct seeds and hardware backends, + /// which carry no resolvable registry identity here. + identity_did: Option, } fn resolve_optional_key( @@ -388,10 +408,11 @@ fn resolve_optional_key( public_key_bytes: pubkey, curve, is_hardware: true, + identity_did: None, })); } - let (_, _role, encrypted) = keychain + let (device_did, _role, encrypted) = keychain .load_key(alias) .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?; let passphrase = passphrase_provider @@ -412,6 +433,7 @@ fn resolve_optional_key( public_key_bytes: pubkey.to_vec(), curve, is_hardware: false, + identity_did: Some(device_did), })) } Some(SigningKeyMaterial::Direct(seed)) => { @@ -424,6 +446,7 @@ fn resolve_optional_key( public_key_bytes: pubkey, curve: auths_crypto::CurveType::Ed25519, is_hardware: false, + identity_did: None, })) } } @@ -450,6 +473,84 @@ fn resolve_required_key( })? } +/// Refuses to sign with a device whose delegated identity has been revoked by the +/// root, or whose key has been rotated out of its identity's KEL. +/// +/// The check is registry-backed: it reads the root KEL for a revocation marker on the +/// device, and the device KEL for its current key. It applies only to keys resolved by +/// alias (which carry a stored KERI AID); direct seeds and hardware keys carry no AID +/// here and are not checked. +/// +/// Args: +/// * `ctx`: The signing context, providing the registry. +/// * `controller_did`: The root identity that may have revoked the device. +/// * `device_did`: The signing key's stored KERI identity (AID). +/// * `device_pk_bytes`: The raw public key bytes being signed with. +/// +/// Usage: +/// ```ignore +/// enforce_signer_authority(ctx, &managed.controller_did, device_did, &pubkey)?; +/// ``` +fn enforce_signer_authority( + ctx: &AuthsContext, + controller_did: &IdentityDID, + device_did: &IdentityDID, + device_pk_bytes: &[u8], +) -> Result<(), ArtifactSigningError> { + use std::ops::ControlFlow; + + let device_prefix = auths_id::keri::parse_did_keri(device_did.as_str()) + .map_err(|e| ArtifactSigningError::KeyResolutionFailed(e.to_string()))?; + let root_prefix = auths_id::keri::parse_did_keri(controller_did.as_str()) + .map_err(|e| ArtifactSigningError::AttestationFailed(e.to_string()))?; + + // Revoked? The root anchors a Seal::Digest of the device prefix on its own KEL. + // Fail closed: if the root KEL cannot be read, we cannot prove the device is live. + let mut revoked = false; + ctx.registry + .visit_events(&root_prefix, 0, &mut |event| { + let hit = event.anchors().iter().any(|seal| { + matches!(seal, auths_id::keri::Seal::Digest { d } if d.as_str() == device_prefix.as_str()) + }); + if hit { + revoked = true; + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .map_err(|e| { + ArtifactSigningError::KeyResolutionFailed(format!( + "cannot read root KEL to check revocation: {e}" + )) + })?; + if revoked { + return Err(ArtifactSigningError::DeviceRevoked( + device_did.as_str().to_string(), + )); + } + + // Rotated out? The signing key must be the current key in the device's KEL. + // Fail closed: if the KEL state cannot be read, we cannot prove the key is current. + let state = ctx.registry.get_key_state(&device_prefix).map_err(|e| { + ArtifactSigningError::KeyResolutionFailed(format!( + "cannot read KEL state for {} to verify the signing key is current: {e}", + device_did.as_str() + )) + })?; + let is_current = state.current_keys.iter().any(|k| { + k.parse() + .map(|pk| pk.as_bytes() == device_pk_bytes) + .unwrap_or(false) + }); + if !is_current { + return Err(ArtifactSigningError::KeyRotatedOut( + device_did.as_str().to_string(), + )); + } + Ok(()) +} + /// Validate and normalize a commit SHA (40-char SHA-1 or 64-char SHA-256). /// /// Args: @@ -518,6 +619,15 @@ pub fn sign_artifact( "Enter passphrase for device key:", )?; + if let Some(ref device_did) = device_resolved.identity_did { + enforce_signer_authority( + ctx, + &managed.controller_did, + device_did, + &device_resolved.public_key_bytes, + )?; + } + let mut seeds: HashMap = HashMap::new(); let identity_alias: Option = identity_resolved.map(|r| { let alias = r.alias.clone(); diff --git a/crates/auths-sdk/tests/cases/device.rs b/crates/auths-sdk/tests/cases/device.rs index f6a3ed60..6a24994a 100644 --- a/crates/auths-sdk/tests/cases/device.rs +++ b/crates/auths-sdk/tests/cases/device.rs @@ -23,6 +23,10 @@ use auths_id::keri::types::Prefix; use auths_id::keri::validate_delegation; use auths_id::storage::registry::backend::RegistryBackend; use auths_sdk::domains::device::{add_device, list_delegated_devices, remove_device}; +use auths_sdk::domains::signing::service::{ + ArtifactSigningError, ArtifactSigningParams, SigningKeyMaterial, sign_artifact, +}; +use auths_sdk::testing::fakes::FakeArtifactSource; fn setup_test_identity(registry_path: &std::path::Path) -> (KeyAlias, IsolatedKeychainHandle) { let keychain = IsolatedKeychainHandle::new(); @@ -398,3 +402,48 @@ fn extend_device_nonexistent_device_returns_error() { result.unwrap_err() ); } + +fn try_sign_with_device( + ctx: &auths_sdk::context::AuthsContext, + device_alias: &KeyAlias, +) -> Result<(), ArtifactSigningError> { + let params = ArtifactSigningParams { + artifact: Arc::new(FakeArtifactSource::from_data("release.bin", b"payload")), + identity_key: None, + device_key: SigningKeyMaterial::Alias(device_alias.clone()), + expires_in: None, + note: None, + commit_sha: None, + }; + sign_artifact(params, ctx).map(|_| ()) +} + +#[test] +fn revoked_device_cannot_produce_accepted_signature() { + let tmp = tempfile::tempdir().unwrap(); + let (root_alias, keychain) = setup_test_identity(tmp.path()); + let provider: Arc = + Arc::new(PrefilledPassphraseProvider::new("Test-passphrase1!")); + let ctx = + build_test_context_with_provider(tmp.path(), Arc::new(keychain.clone()), Some(provider)); + + let device_alias = KeyAlias::new_unchecked("laptop"); + let dev = add_device(&ctx, &root_alias, &device_alias, CurveType::Ed25519).expect("add device"); + + // Before removal the device's key is current and not revoked, so it signs. This also + // confirms the current-key comparison accepts a legitimately current key. + assert!( + try_sign_with_device(&ctx, &device_alias).is_ok(), + "an active device should be able to sign" + ); + + // Remove (revoke) the device on the root KEL. + remove_device(&ctx, &root_alias, &dev.device_did).expect("revoke device"); + + // The producer must now refuse to sign with the revoked device's key. + let denied = try_sign_with_device(&ctx, &device_alias); + assert!( + matches!(denied, Err(ArtifactSigningError::DeviceRevoked(_))), + "expected DeviceRevoked, got {denied:?}" + ); +} diff --git a/crates/auths-sdk/tests/cases/rotation.rs b/crates/auths-sdk/tests/cases/rotation.rs index 949b2e36..cbc26e66 100644 --- a/crates/auths-sdk/tests/cases/rotation.rs +++ b/crates/auths-sdk/tests/cases/rotation.rs @@ -16,7 +16,11 @@ use auths_sdk::domains::identity::types::InitializeResult; use auths_sdk::domains::identity::types::{ CreateDeveloperIdentityConfig, IdentityConfig, IdentityRotationConfig, }; +use auths_sdk::domains::signing::service::{ + ArtifactSigningError, ArtifactSigningParams, SigningKeyMaterial, sign_artifact, +}; use auths_sdk::domains::signing::types::GitSigningScope; +use auths_sdk::testing::fakes::FakeArtifactSource; use auths_sdk::workflows::rotation::{ RotationKeyMaterial, apply_rotation, compute_rotation_event, rotate_identity, }; @@ -338,3 +342,53 @@ fn apply_rotation_returns_partial_rotation_on_keychain_failure() { result.unwrap_err() ); } + +#[test] +fn rotated_out_key_cannot_sign() { + let tmp = tempfile::tempdir().unwrap(); + let (root_alias, keychain) = setup_test_identity(tmp.path()); + let provider: Arc = + Arc::new(PrefilledPassphraseProvider::new("Test-passphrase1!")); + let ctx = + build_test_context_with_provider(tmp.path(), Arc::new(keychain.clone()), Some(provider)); + + // Snapshot the current key under a second alias pointing at the same identity AID. + let (root_did, role, enc) = keychain.load_key(&root_alias).expect("load current key"); + let stale_alias = KeyAlias::new_unchecked("stale-prev"); + keychain + .store_key(&stale_alias, &root_did, role, &enc) + .expect("snapshot the pre-rotation key"); + + let try_sign = |device_alias: &KeyAlias| -> Result<(), ArtifactSigningError> { + let params = ArtifactSigningParams { + artifact: Arc::new(FakeArtifactSource::from_data("release.bin", b"payload")), + identity_key: None, + device_key: SigningKeyMaterial::Alias(device_alias.clone()), + expires_in: None, + note: None, + commit_sha: None, + }; + sign_artifact(params, &ctx).map(|_| ()) + }; + + // Before rotation the snapshot is the current key, so it signs. + assert!( + try_sign(&stale_alias).is_ok(), + "the current key should sign before rotation" + ); + + // Rotate the identity key: the KEL head is now a different key. + let config = IdentityRotationConfig { + repo_path: tmp.path().to_path_buf(), + identity_key_alias: Some(root_alias.clone()), + next_key_alias: None, + }; + rotate_identity(config, &ctx, &SystemClock).expect("rotate identity"); + + // The snapshot now holds a key that was rotated out → the producer refuses to sign. + let denied = try_sign(&stale_alias); + assert!( + matches!(denied, Err(ArtifactSigningError::KeyRotatedOut(_))), + "expected KeyRotatedOut, got {denied:?}" + ); +} diff --git a/crates/auths-verifier/src/duplicity.rs b/crates/auths-verifier/src/duplicity.rs index 1c131856..847358a7 100644 --- a/crates/auths-verifier/src/duplicity.rs +++ b/crates/auths-verifier/src/duplicity.rs @@ -193,6 +193,34 @@ mod tests { assert_eq!(detect_duplicity(&events), DuplicityReport::Clean); } + #[test] + fn concurrent_rotation_is_detected_or_prevented() { + // The documented kt=1 accepted risk: with a single-signer shared KEL, two + // controllers can each author a rotation at the same sequence, forking the KEL. + // Authoring does not prevent it, so it must be surfaced — detect_duplicity flags + // the two competing heads rather than silently accepting both as valid. + let events = vec![ + ev("did:keri:EShared", 0, "Eicp"), + ev("did:keri:EShared", 1, "Erot1"), + ev("did:keri:EShared", 2, "ErotControllerA"), + ev("did:keri:EShared", 2, "ErotControllerB"), + ]; + match detect_duplicity(&events) { + DuplicityReport::Diverging { + shared_kel_prefix, + seq, + event_saids, + } => { + assert_eq!(shared_kel_prefix.as_str(), "did:keri:EShared"); + assert_eq!(seq, 2, "divergence is at the concurrent rotation"); + assert_eq!(event_saids.len(), 2); + } + DuplicityReport::Clean => { + panic!("concurrent rotations at the same sequence must be flagged, not accepted") + } + } + } + #[test] fn diverging_report_is_diverging() { let report = DuplicityReport::Diverging { From 713d74de98ce1b2f95dc1c5d474988f263ede920 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 01:42:17 +0100 Subject: [PATCH 05/10] tests: fix broken rotation test Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-sdk/tests/cases/rotation.rs | 28 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/auths-sdk/tests/cases/rotation.rs b/crates/auths-sdk/tests/cases/rotation.rs index cbc26e66..6ad96119 100644 --- a/crates/auths-sdk/tests/cases/rotation.rs +++ b/crates/auths-sdk/tests/cases/rotation.rs @@ -352,12 +352,8 @@ fn rotated_out_key_cannot_sign() { let ctx = build_test_context_with_provider(tmp.path(), Arc::new(keychain.clone()), Some(provider)); - // Snapshot the current key under a second alias pointing at the same identity AID. - let (root_did, role, enc) = keychain.load_key(&root_alias).expect("load current key"); - let stale_alias = KeyAlias::new_unchecked("stale-prev"); - keychain - .store_key(&stale_alias, &root_did, role, &enc) - .expect("snapshot the pre-rotation key"); + // Capture the current key bytes BEFORE rotation; rotation mutates the keystore. + let (root_did, role, old_enc) = keychain.load_key(&root_alias).expect("load current key"); let try_sign = |device_alias: &KeyAlias| -> Result<(), ArtifactSigningError> { let params = ArtifactSigningParams { @@ -371,9 +367,9 @@ fn rotated_out_key_cannot_sign() { sign_artifact(params, &ctx).map(|_| ()) }; - // Before rotation the snapshot is the current key, so it signs. + // The current key signs before rotation. assert!( - try_sign(&stale_alias).is_ok(), + try_sign(&root_alias).is_ok(), "the current key should sign before rotation" ); @@ -385,7 +381,21 @@ fn rotated_out_key_cannot_sign() { }; rotate_identity(config, &ctx, &SystemClock).expect("rotate identity"); - // The snapshot now holds a key that was rotated out → the producer refuses to sign. + // Store the captured pre-rotation key under a stale alias AFTER rotation (so it is + // not cleared by the rotation), pointing at the same identity AID. It is now a + // rotated-out key. + let stale_alias = KeyAlias::new_unchecked("stale-prev"); + keychain + .store_key(&stale_alias, &root_did, role, &old_enc) + .expect("store the rotated-out key"); + + // The current alias still signs (its key is the new KEL head). + assert!( + try_sign(&root_alias).is_ok(), + "the rotated (current) key should still sign" + ); + + // Signing with the rotated-out key is refused. let denied = try_sign(&stale_alias); assert!( matches!(denied, Err(ArtifactSigningError::KeyRotatedOut(_))), From 986a1ff26b05039e491032fef537c87bd8e313fc Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 01:45:25 +0100 Subject: [PATCH 06/10] fix: formatting Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/src/agent/handle.rs | 3 ++- crates/auths-core/src/api/runtime.rs | 7 +++++-- crates/auths-core/tests/cases/agent_socket.rs | 9 ++++----- crates/auths-id/tests/cases/rotation_edge_cases.rs | 10 ++++++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/auths-core/src/agent/handle.rs b/crates/auths-core/src/agent/handle.rs index 75d8f290..65ef3d91 100644 --- a/crates/auths-core/src/agent/handle.rs +++ b/crates/auths-core/src/agent/handle.rs @@ -296,7 +296,8 @@ impl AgentHandle { /// periodically from a background task. Neither control stops a process running as /// the same user from using the agent within the window — they only bound the window. pub fn check_idle_timeout(&self) -> Result { - if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked() { + if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked() + { log::info!( "Locking agent: idle for {:?}, unlocked for {:?}", self.idle_duration(), diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 68bc766e..9b53cba6 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -1140,8 +1140,11 @@ mod hardening_tests { #[test] fn idle_monitor_interval_is_bounded() { - let interval = idle_monitor_interval(Duration::from_secs(30 * 60), Duration::from_secs(8 * 60 * 60)) - .expect("some interval"); + let interval = idle_monitor_interval( + Duration::from_secs(30 * 60), + Duration::from_secs(8 * 60 * 60), + ) + .expect("some interval"); assert!(interval >= Duration::from_secs(1)); assert!(interval <= Duration::from_secs(60)); } diff --git a/crates/auths-core/tests/cases/agent_socket.rs b/crates/auths-core/tests/cases/agent_socket.rs index 5f23b1ce..9e3ba7ef 100644 --- a/crates/auths-core/tests/cases/agent_socket.rs +++ b/crates/auths-core/tests/cases/agent_socket.rs @@ -62,11 +62,10 @@ async fn locked_agent_refuses_signing_over_the_socket() { let sign_path = socket_path.clone(); let unlocked_pubkey = pubkey.clone(); - let unlocked = tokio::task::spawn_blocking(move || { - agent_sign(&sign_path, &unlocked_pubkey, b"payload") - }) - .await - .expect("sign task"); + let unlocked = + tokio::task::spawn_blocking(move || agent_sign(&sign_path, &unlocked_pubkey, b"payload")) + .await + .expect("sign task"); assert!( unlocked.is_ok(), "an unlocked agent should sign over the socket" diff --git a/crates/auths-id/tests/cases/rotation_edge_cases.rs b/crates/auths-id/tests/cases/rotation_edge_cases.rs index 980c2387..2b3ea154 100644 --- a/crates/auths-id/tests/cases/rotation_edge_cases.rs +++ b/crates/auths-id/tests/cases/rotation_edge_cases.rs @@ -381,7 +381,10 @@ fn interrupted_rotation_leaves_consistent_state() { chrono::Utc::now(), None, ); - assert!(matches!(interrupted, Err(RotationError::CommitmentMismatch))); + assert!(matches!( + interrupted, + Err(RotationError::CommitmentMismatch) + )); // Exactly one head: inception + one rotation, no competing event at any sequence. let mut events = Vec::new(); @@ -398,6 +401,9 @@ fn interrupted_rotation_leaves_consistent_state() { ); let state = validate_kel(&events).unwrap(); - assert_eq!(state.sequence, 1, "exactly one head at the rotated sequence"); + assert_eq!( + state.sequence, 1, + "exactly one head at the rotated sequence" + ); assert!(!state.is_abandoned); } From 617378fcc6eb7a737cc14582912e49313ff3049b Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 02:13:46 +0100 Subject: [PATCH 07/10] fix: issuer signature in golden path dropping Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .../auths-cli/src/commands/artifact/sign.rs | 7 +++- crates/auths-cli/tests/cases/golden_path.rs | 35 +++++++++++++++++++ crates/auths-core/Cargo.toml | 2 +- crates/auths-core/src/api/runtime.rs | 9 +++-- crates/auths-core/src/crypto/encryption.rs | 12 ++----- .../auths-sdk/src/domains/signing/service.rs | 12 ++++--- 6 files changed, 57 insertions(+), 20 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/sign.rs b/crates/auths-cli/src/commands/artifact/sign.rs index 3f4001bf..f73d697e 100644 --- a/crates/auths-cli/src/commands/artifact/sign.rs +++ b/crates/auths-cli/src/commands/artifact/sign.rs @@ -60,7 +60,12 @@ pub fn handle_sign( let params = ArtifactSigningParams { artifact: Arc::new(FileArtifact::new(file)), - identity_key: key.map(|a| SigningKeyMaterial::Alias(KeyAlias::new_unchecked(a))), + // A file attestation must be attributable to the signing identity, so it always + // carries an issuer signature: use the explicit `--key` when given, otherwise the + // same key that signs as the device (for a single-key identity they are one key). + identity_key: Some(SigningKeyMaterial::Alias(KeyAlias::new_unchecked( + key.unwrap_or(device_key), + ))), device_key: SigningKeyMaterial::Alias(KeyAlias::new_unchecked(device_key)), expires_in, note, diff --git a/crates/auths-cli/tests/cases/golden_path.rs b/crates/auths-cli/tests/cases/golden_path.rs index 8e463a9c..a1759076 100644 --- a/crates/auths-cli/tests/cases/golden_path.rs +++ b/crates/auths-cli/tests/cases/golden_path.rs @@ -65,3 +65,38 @@ fn test_golden_path_init_sign_verify() { result["error"] ); } + +/// Golden path for file/artifact signing: init → `sign ` → `verify `. +/// +/// Without an explicit `--key`, a file attestation must still carry an issuer +/// signature so it is attributable to the identity. Regression: it previously signed +/// device-only, and verification failed with "missing issuer signature". +#[test] +fn test_golden_path_artifact_sign_verify() { + let env = TestEnv::new(); + env.init_identity(); + + std::fs::write(env.repo_path.join("artifact.bin"), b"payload bytes").unwrap(); + + let sign = env + .cmd("auths") + .args(["sign", "artifact.bin"]) + .output() + .unwrap(); + assert!( + sign.status.success(), + "auths sign artifact.bin failed: {}", + String::from_utf8_lossy(&sign.stderr) + ); + + let verify = env + .cmd("auths") + .args(["verify", "artifact.bin"]) + .output() + .unwrap(); + assert!( + verify.status.success(), + "auths verify artifact.bin failed: {}", + String::from_utf8_lossy(&verify.stderr) + ); +} diff --git a/crates/auths-core/Cargo.toml b/crates/auths-core/Cargo.toml index bcd5316e..7e5ca525 100644 --- a/crates/auths-core/Cargo.toml +++ b/crates/auths-core/Cargo.toml @@ -86,7 +86,7 @@ windows = { version = "0.58", features = ["Security_Credentials", "Foundation_Co [dev-dependencies] ring.workspace = true anyhow = "1.0" -log = "0.4" +log = { version = "0.4", features = ["std"] } p256 = { version = "=0.13.2", features = ["ecdsa"] } assert_matches = "1.5.0" auths-verifier = { path = "../auths-verifier", features = ["test-utils"] } diff --git a/crates/auths-core/src/api/runtime.rs b/crates/auths-core/src/api/runtime.rs index 9b53cba6..86f809e6 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -1065,11 +1065,10 @@ mod hardening_tests { use tempfile::TempDir; fn mode_of(path: &std::path::Path) -> u32 { - std::fs::metadata(path) - .expect("metadata") - .permissions() - .mode() - & 0o777 + match std::fs::metadata(path) { + Ok(meta) => meta.permissions().mode() & 0o777, + Err(e) => panic!("metadata({path:?}): {e}"), + } } #[test] diff --git a/crates/auths-core/src/crypto/encryption.rs b/crates/auths-core/src/crypto/encryption.rs index e0b10bb2..67b81e14 100644 --- a/crates/auths-core/src/crypto/encryption.rs +++ b/crates/auths-core/src/crypto/encryption.rs @@ -357,14 +357,8 @@ mod tests { // expensive. Assert the production Argon2id parameters meet OWASP minimums // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys // with weaker params for speed, so this checks the production constants. - assert!( - PRODUCTION_KDF_M_COST_KIB >= 19 * 1024, - "production Argon2 memory cost too low: {PRODUCTION_KDF_M_COST_KIB} KiB" - ); - assert!( - PRODUCTION_KDF_T_COST >= 2, - "production Argon2 time cost too low: {PRODUCTION_KDF_T_COST}" - ); - assert!(PRODUCTION_KDF_P_COST >= 1); + const { assert!(PRODUCTION_KDF_M_COST_KIB >= 19 * 1024, "production Argon2 memory cost below the OWASP minimum (19 MiB)") }; + const { assert!(PRODUCTION_KDF_T_COST >= 2, "production Argon2 time cost too low") }; + const { assert!(PRODUCTION_KDF_P_COST >= 1) }; } } diff --git a/crates/auths-sdk/src/domains/signing/service.rs b/crates/auths-sdk/src/domains/signing/service.rs index 661c2644..c8543559 100644 --- a/crates/auths-sdk/src/domains/signing/service.rs +++ b/crates/auths-sdk/src/domains/signing/service.rs @@ -538,10 +538,14 @@ fn enforce_signer_authority( device_did.as_str() )) })?; - let is_current = state.current_keys.iter().any(|k| { - k.parse() - .map(|pk| pk.as_bytes() == device_pk_bytes) - .unwrap_or(false) + let is_current = state.current_keys.iter().any(|k| match k.parse() { + // Public keys, decoded from CESR (curve-tag-aware) to raw bytes; a plain + // comparison is correct — no secret is involved. + Ok(pk) => { + let key_bytes: &[u8] = pk.as_bytes(); + key_bytes == device_pk_bytes + } + Err(_) => false, }); if !is_current { return Err(ArtifactSigningError::KeyRotatedOut( From 4e0fd13c9eae9b3ed876964d9740a650b0078dd9 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 02:14:44 +0100 Subject: [PATCH 08/10] fix: formatting Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-core/src/crypto/encryption.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/auths-core/src/crypto/encryption.rs b/crates/auths-core/src/crypto/encryption.rs index 67b81e14..29d8caae 100644 --- a/crates/auths-core/src/crypto/encryption.rs +++ b/crates/auths-core/src/crypto/encryption.rs @@ -357,8 +357,18 @@ mod tests { // expensive. Assert the production Argon2id parameters meet OWASP minimums // (m >= 19 MiB, t >= 2) regardless of build profile — test builds derive keys // with weaker params for speed, so this checks the production constants. - const { assert!(PRODUCTION_KDF_M_COST_KIB >= 19 * 1024, "production Argon2 memory cost below the OWASP minimum (19 MiB)") }; - const { assert!(PRODUCTION_KDF_T_COST >= 2, "production Argon2 time cost too low") }; + const { + assert!( + PRODUCTION_KDF_M_COST_KIB >= 19 * 1024, + "production Argon2 memory cost below the OWASP minimum (19 MiB)" + ) + }; + const { + assert!( + PRODUCTION_KDF_T_COST >= 2, + "production Argon2 time cost too low" + ) + }; const { assert!(PRODUCTION_KDF_P_COST >= 1) }; } } From df6cc2509306e11f067d81f78972a4f610cece02 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 02:38:02 +0100 Subject: [PATCH 09/10] fix: constant time test flakiness Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .../auths-crypto/tests/cases/constant_time.rs | 25 ++++++------ docs/cli/commands/advanced.md | 38 ------------------- 2 files changed, 13 insertions(+), 50 deletions(-) diff --git a/crates/auths-crypto/tests/cases/constant_time.rs b/crates/auths-crypto/tests/cases/constant_time.rs index 024c67b4..3f5fa6b4 100644 --- a/crates/auths-crypto/tests/cases/constant_time.rs +++ b/crates/auths-crypto/tests/cases/constant_time.rs @@ -94,13 +94,14 @@ fn median_leak_t(cmp: fn(&[u8], &[u8]) -> bool) -> f64 { ts[ts.len() / 2] } -/// The threshold sits in the wide gap between the two regimes, set from the measured |t| -/// across repeated runs: the constant-time `ct_eq` produces ~1–4, while the early-return -/// control produces ~3000–11000 — three orders of magnitude apart. 100 is near the -/// geometric midpoint, so both directions carry ~30× margin and a noisy CI runner cannot -/// flake it either way. The negative control re-verifies the lower bound (the control must -/// still exceed it) on every run. -const LEAK_THRESHOLD: f64 = 100.0; +/// The check is *relative*, not absolute. The naive early-return control leaks, so its +/// Welch |t| is large; `subtle::ct_eq` does not, so its |t| stays small. The absolute +/// magnitudes vary widely by runner — a busy shared CI core is far noisier than a quiet +/// laptop (observed `ct_eq` |t| from ~3 to ~100), so a fixed threshold flakes. Instead: +/// the control must clear a floor that proves the harness has detection power, and +/// `ct_eq` must be many times quieter than that same control on the same run. +const CONTROL_FLOOR: f64 = 1000.0; +const MIN_SEPARATION: f64 = 8.0; #[test] fn secret_comparison_is_constant_time() { @@ -109,15 +110,15 @@ fn secret_comparison_is_constant_time() { let control = median_leak_t(naive_compare); let ct = median_leak_t(ct_compare); eprintln!( - "[constant-time] naive(control) |t|={control:.1} ct_eq |t|={ct:.1} threshold={LEAK_THRESHOLD}" + "[constant-time] naive(control) |t|={control:.1} ct_eq |t|={ct:.1} control_floor={CONTROL_FLOOR} min_separation={MIN_SEPARATION}x" ); assert!( - control > LEAK_THRESHOLD, - "negative control failed: the harness must detect the naive compare's leak, but |t|={control:.1} <= {LEAK_THRESHOLD} — the test has no detection power", + control > CONTROL_FLOOR, + "negative control failed: the harness must detect the naive compare's leak, but |t|={control:.1} <= {CONTROL_FLOOR} — the test has no detection power", ); assert!( - ct < LEAK_THRESHOLD, - "subtle::ct_eq is not constant-time on this build: |t|={ct:.1} >= {LEAK_THRESHOLD} (the leaky control measured {control:.1})", + ct * MIN_SEPARATION < control, + "subtle::ct_eq is not constant-time on this build: its leak |t|={ct:.1} is within {MIN_SEPARATION}x of the naive control's |t|={control:.1}", ); } diff --git a/docs/cli/commands/advanced.md b/docs/cli/commands/advanced.md index 763a38fa..a61d7037 100644 --- a/docs/cli/commands/advanced.md +++ b/docs/cli/commands/advanced.md @@ -118,23 +118,6 @@ Remove Auths identity and git signing configuration | `--repo ` | — | Override the local storage directory (default: ~/.auths) | -### auths signcommit - -```bash -auths signcommit -``` - - -Sign a Git commit with machine identity. - -| Flag | Default | Description | -|------|---------|-------------| -| `` | — | Git commit SHA or reference (e.g., HEAD, main..HEAD) | -| `--json` | — | Output format (json or human-readable) | -| `-q, --quiet` | — | Suppress non-essential output | -| `--repo ` | — | Override the local storage directory (default: ~/.auths) | - - ### auths error list ```bash @@ -246,27 +229,6 @@ Rotate identity keys. Stores the new key under a new alias | `--repo ` | — | Override the local storage directory (default: ~/.auths) | -### auths id expand - -```bash -auths id expand -``` - - -Expand a single-device identity into multi-device via one rotation - -| Flag | Default | Description | -|------|---------|-------------| -| `--add-device ` | — | Add a device slot (repeatable). Curve name: `P256` or `Ed25519` | -| `--signing-threshold ` | — | Signing threshold after expansion. Required | -| `--rotation-threshold ` | — | Rotation threshold after expansion. Required | -| `--alias ` | `main` | Base alias for the existing single-key identity | -| `--next-alias ` | `main` | Alias for the new multi-key identity set | -| `-j, --json` | — | Emit machine-readable JSON | -| `-q, --quiet` | — | Suppress non-essential output | -| `--repo ` | — | Override the local storage directory (default: ~/.auths) | - - ### auths id export-bundle ```bash From 352fa375b1a1b145bd31043762eb3d6f4471f825 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 02:47:53 +0100 Subject: [PATCH 10/10] docs: refresh error docs Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-cli/src/errors/registry.rs | 6 +++++- docs/errors/AUTHS-E5857.md | 9 +++++++++ docs/errors/AUTHS-E5858.md | 9 +++++++++ docs/errors/index.md | 2 ++ docs/errors/registry.lock | 2 ++ 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/errors/AUTHS-E5857.md create mode 100644 docs/errors/AUTHS-E5858.md diff --git a/crates/auths-cli/src/errors/registry.rs b/crates/auths-cli/src/errors/registry.rs index 82194bfa..4310453c 100644 --- a/crates/auths-cli/src/errors/registry.rs +++ b/crates/auths-cli/src/errors/registry.rs @@ -452,6 +452,8 @@ pub fn explain(code: &str) -> Option<&'static str> { "AUTHS-E5854" => Some("# AUTHS-E5854\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::AttestationFailed`\n\n## Message\n\nattestation creation failed: {0}\n\n## Suggestion\n\nCheck identity storage with `auths status`\n"), "AUTHS-E5855" => Some("# AUTHS-E5855\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::ResignFailed`\n\n## Message\n\nattestation re-signing failed: {0}\n"), "AUTHS-E5856" => Some("# AUTHS-E5856\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::InvalidCommitSha`\n\n## Message\n\ninvalid commit SHA: {0} (expected 40 or 64 hex characters)\n"), + "AUTHS-E5857" => Some("# AUTHS-E5857\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::DeviceRevoked`\n\n## Message\n\ndevice revoked: {0}\n"), + "AUTHS-E5858" => Some("# AUTHS-E5858\n\n**Crate:** `auths-sdk`\n\n**Type:** `ArtifactSigningError::KeyRotatedOut`\n\n## Message\n\nsigning key rotated out of the KEL: {0}\n"), // --- auths-sdk (SigningError) --- "AUTHS-E5901" => Some("# AUTHS-E5901\n\n**Crate:** `auths-sdk`\n\n**Type:** `SigningError::IdentityFrozen`\n\n## Message\n\nidentity is frozen: {0}\n\n## Suggestion\n\nTo unfreeze: auths emergency unfreeze\n"), @@ -839,6 +841,8 @@ pub fn all_codes() -> &'static [&'static str] { "AUTHS-E5854", "AUTHS-E5855", "AUTHS-E5856", + "AUTHS-E5857", + "AUTHS-E5858", "AUTHS-E5901", "AUTHS-E5902", "AUTHS-E5903", @@ -908,6 +912,6 @@ mod tests { #[test] fn all_codes_count_matches_registry() { - assert_eq!(all_codes().len(), 368); + assert_eq!(all_codes().len(), 370); } } diff --git a/docs/errors/AUTHS-E5857.md b/docs/errors/AUTHS-E5857.md new file mode 100644 index 00000000..fa86342e --- /dev/null +++ b/docs/errors/AUTHS-E5857.md @@ -0,0 +1,9 @@ +# AUTHS-E5857 + +**Crate:** `auths-sdk` + +**Type:** `ArtifactSigningError::DeviceRevoked` + +## Message + +device revoked: {0} diff --git a/docs/errors/AUTHS-E5858.md b/docs/errors/AUTHS-E5858.md new file mode 100644 index 00000000..56bea838 --- /dev/null +++ b/docs/errors/AUTHS-E5858.md @@ -0,0 +1,9 @@ +# AUTHS-E5858 + +**Crate:** `auths-sdk` + +**Type:** `ArtifactSigningError::KeyRotatedOut` + +## Message + +signing key rotated out of the KEL: {0} diff --git a/docs/errors/index.md b/docs/errors/index.md index 038ea818..617e0f17 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -338,6 +338,8 @@ All error codes emitted by the Auths CLI and libraries. Run `auths error ` | [AUTHS-E5854](AUTHS-E5854.md) | `auths-sdk` | `ArtifactSigningError::AttestationFailed` | attestation creation failed: {0} | | [AUTHS-E5855](AUTHS-E5855.md) | `auths-sdk` | `ArtifactSigningError::ResignFailed` | attestation re-signing failed: {0} | | [AUTHS-E5856](AUTHS-E5856.md) | `auths-sdk` | `ArtifactSigningError::InvalidCommitSha` | invalid commit SHA: {0} (expected 40 or 64 hex characters) | +| [AUTHS-E5857](AUTHS-E5857.md) | `auths-sdk` | `ArtifactSigningError::DeviceRevoked` | device revoked: {0} | +| [AUTHS-E5858](AUTHS-E5858.md) | `auths-sdk` | `ArtifactSigningError::KeyRotatedOut` | signing key rotated out of the KEL: {0} | | [AUTHS-E5901](AUTHS-E5901.md) | `auths-sdk` | `SigningError::IdentityFrozen` | identity is frozen: {0} | | [AUTHS-E5902](AUTHS-E5902.md) | `auths-sdk` | `SigningError::KeyResolution` | key resolution failed: {0} | | [AUTHS-E5903](AUTHS-E5903.md) | `auths-sdk` | `SigningError::SigningFailed` | signing operation failed: {0} | diff --git a/docs/errors/registry.lock b/docs/errors/registry.lock index 7a0156dc..7c0cbd9a 100644 --- a/docs/errors/registry.lock +++ b/docs/errors/registry.lock @@ -342,6 +342,8 @@ AUTHS-E5853 = auths-sdk::ArtifactSigningError::DigestFailed AUTHS-E5854 = auths-sdk::ArtifactSigningError::AttestationFailed AUTHS-E5855 = auths-sdk::ArtifactSigningError::ResignFailed AUTHS-E5856 = auths-sdk::ArtifactSigningError::InvalidCommitSha +AUTHS-E5857 = auths-sdk::ArtifactSigningError::DeviceRevoked +AUTHS-E5858 = auths-sdk::ArtifactSigningError::KeyRotatedOut AUTHS-E5901 = auths-sdk::SigningError::IdentityFrozen AUTHS-E5902 = auths-sdk::SigningError::KeyResolution AUTHS-E5903 = auths-sdk::SigningError::SigningFailed