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/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/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 4ee93953..7e5ca525 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 = { 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/agent/handle.rs b/crates/auths-core/src/agent/handle.rs index 4ad0c6e2..65ef3d91 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,25 @@ 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 +367,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 +408,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 +657,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 e8a4bea8..6765e1eb 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(crate) use session::PeerAuthorizedAgent; diff --git a/crates/auths-core/src/agent/session.rs b/crates/auths-core/src/agent/session.rs index 10ad9168..67bbdae1 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}; @@ -132,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| { @@ -153,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); @@ -282,6 +283,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 */ } +/// ``` +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(crate) 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(crate) 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(crate) 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 +561,176 @@ 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()); + } + + #[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() { + 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 154b288a..86f809e6 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}; @@ -788,11 +788,153 @@ fn register_keys_with_macos_agent_internal( Ok(results) } +/// 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 the socket lives in. +/// +/// 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::{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. +/// +/// 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)) +} + +/// 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)] +fn current_euid() -> u32 { + // SAFETY: `geteuid` takes no arguments, has no preconditions, and always succeeds. + unsafe { libc::geteuid() } +} + +/// 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, + max_unlock_ttl: std::time::Duration, +) -> Option { + use std::time::Duration; + 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 +/// 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, -/// 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. /// @@ -808,15 +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 --- - if let Some(parent) = socket_path.parent() - && !parent.exists() - { - 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); - 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) --- @@ -842,6 +983,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() @@ -855,11 +1005,16 @@ 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()); + // --- 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)); + } + + // --- 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); @@ -896,3 +1051,118 @@ 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::{ + 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; + use std::sync::Arc; + use std::time::Duration; + use tempfile::TempDir; + + fn mode_of(path: &std::path::Path) -> u32 { + match std::fs::metadata(path) { + Ok(meta) => meta.permissions().mode() & 0o777, + Err(e) => panic!("metadata({path:?}): {e}"), + } + } + + #[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" + ); + } + + #[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, Duration::ZERO).is_none()); + } + + #[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"); + 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" + ); + } +} diff --git a/crates/auths-core/src/crypto/encryption.rs b/crates/auths-core/src/crypto/encryption.rs index a40be428..29d8caae 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,25 @@ 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. + 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-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..9e3ba7ef --- /dev/null +++ b/crates/auths-core/tests/cases/agent_socket.rs @@ -0,0 +1,87 @@ +//! 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-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/crates/auths-id/tests/cases/rotation_edge_cases.rs b/crates/auths-id/tests/cases/rotation_edge_cases.rs index 85e2cbe2..2b3ea154 100644 --- a/crates/auths-id/tests/cases/rotation_edge_cases.rs +++ b/crates/auths-id/tests/cases/rotation_edge_cases.rs @@ -352,3 +352,58 @@ 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..c8543559 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,88 @@ 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| 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( + 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 +623,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..6ad96119 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,63 @@ 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)); + + // 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 { + 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(|_| ()) + }; + + // The current key signs before rotation. + assert!( + try_sign(&root_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"); + + // 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(_))), + "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 { 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 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