diff --git a/crates/auths-cli/src/commands/agent/mod.rs b/crates/auths-cli/src/commands/agent/mod.rs index d2d827df..91d23cd9 100644 --- a/crates/auths-cli/src/commands/agent/mod.rs +++ b/crates/auths-cli/src/commands/agent/mod.rs @@ -380,9 +380,14 @@ fn run_agent_foreground( timeout, )); + let authorizer = build_sign_authorizer({ + use std::io::IsTerminal; + std::io::stdin().is_terminal() + }); + let rt = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?; let result = rt.block_on(async { - auths_sdk::agent_core::start_agent_listener_with_handle(handle.clone()).await + auths_sdk::agent_core::start_agent_listener_with_handle(handle.clone(), authorizer).await }); cleanup_stale_files(&[pid_path, env_path, socket]); @@ -390,6 +395,55 @@ fn run_agent_foreground( result.map_err(|e| anyhow!("Agent error: {}", e)) } +/// Builds the per-request signing authorizer for the agent. An interactive agent gates +/// each new connecting process behind an approval prompt (so an unlocked agent does not +/// grant silent signing to every same-user process); a daemonized / non-interactive +/// agent is permissive, since there is no human present to approve. +/// +/// Args: +/// * `interactive`: whether the agent has a terminal to prompt on. +/// +/// Usage: +/// ```ignore +/// let auth = build_sign_authorizer(std::io::stdin().is_terminal()); +/// ``` +#[cfg(unix)] +fn build_sign_authorizer( + interactive: bool, +) -> std::sync::Arc { + if interactive { + std::sync::Arc::new(auths_sdk::agent_core::PerCallerAuthorizer::new( + approve_caller, + )) + } else { + std::sync::Arc::new(auths_sdk::agent_core::AllowAllSigning) + } +} + +/// Prompts the operator to approve signing for a newly connected caller. Returns true to +/// approve and pin that caller for the unlock window. +/// +/// Args: +/// * `peer`: the connecting process's identity. +/// +/// Usage: +/// ```ignore +/// let approved = approve_caller(&peer); +/// ``` +#[cfg(unix)] +fn approve_caller(peer: &auths_sdk::agent_core::PeerIdentity) -> bool { + let who = match peer.pid { + Some(pid) => format!("process pid {pid} (uid {})", peer.uid), + None => format!("a process (uid {})", peer.uid), + }; + eprintln!("\nauths agent: a new caller — {who} — is requesting a signature."); + dialoguer::Confirm::new() + .with_prompt("Approve signing for this caller?") + .default(false) + .interact() + .unwrap_or(false) +} + #[cfg(not(unix))] fn run_agent_foreground( _socket: &std::path::Path, diff --git a/crates/auths-core/src/agent/mod.rs b/crates/auths-core/src/agent/mod.rs index 6765e1eb..0652e944 100644 --- a/crates/auths-core/src/agent/mod.rs +++ b/crates/auths-core/src/agent/mod.rs @@ -16,6 +16,8 @@ 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; +pub use session::{ + AgentSession, AllowAllSigning, PeerIdentity, PerCallerAuthorizer, SignAuthorizer, +}; diff --git a/crates/auths-core/src/agent/session.rs b/crates/auths-core/src/agent/session.rs index 67bbdae1..054fd012 100644 --- a/crates/auths-core/src/agent/session.rs +++ b/crates/auths-core/src/agent/session.rs @@ -19,20 +19,156 @@ use std::io; use std::sync::Arc; use zeroize::Zeroizing; +/// The identity of a process connected to the agent socket, read from the peer +/// credentials of the connection. +/// +/// Args (fields): `uid`, `pid`. +/// +/// Usage: +/// ```ignore +/// let peer = PeerIdentity { uid: 1000, pid: Some(4242) }; +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PeerIdentity { + /// Effective user id of the connecting process. + pub uid: u32, + /// Process id of the connecting process, when the platform exposes it (Linux via + /// `SO_PEERCRED`; `None` on macOS, whose `getpeereid` returns no pid). + pub pid: Option, +} + +impl PeerIdentity { + /// The agent's own process — used for in-process / default sessions where no + /// external peer applies. + pub fn local() -> Self { + Self { uid: 0, pid: None } + } +} + +/// Decides whether a connected peer may make the agent sign *right now*. +/// +/// Connection-level UID authorization (same user only) is enforced separately. This is +/// the per-request gate that lets the host require approval — e.g. a per-caller +/// biometric — before each signature, so an unlocked agent does not grant silent +/// signing to every same-user process. +pub trait SignAuthorizer: Send + Sync { + /// Returns true iff `peer` is allowed to obtain a signature now. + /// + /// Args: + /// * `peer`: the connecting process's identity. + fn authorize_sign(&self, peer: &PeerIdentity) -> bool; +} + +/// The permissive authorizer: every signature is allowed. Used for in-process/default +/// sessions and for explicitly non-interactive (headless/automation) contexts. +/// +/// Usage: +/// ```ignore +/// let auth = std::sync::Arc::new(AllowAllSigning); +/// ``` +pub struct AllowAllSigning; + +impl SignAuthorizer for AllowAllSigning { + fn authorize_sign(&self, _peer: &PeerIdentity) -> bool { + true + } +} + +/// Per-caller signing approval — the policy for #354. The first time a given peer +/// process asks to sign, the injected `approve` function is consulted (e.g. a +/// biometric / user prompt). Approved peers are pinned for the life of this authorizer +/// (the unlock window), so the legitimate caller is not re-prompted on every signature, +/// while a *different* process triggers a fresh approval. +/// +/// Peers are keyed by `(uid, pid)`. On platforms without a peer pid (macOS), `pid` is +/// `None`, so callers collapse to per-uid; there the host's `approve` function should +/// apply a time-bucketed re-auth rather than pinning forever. +/// +/// Usage: +/// ```ignore +/// let auth = PerCallerAuthorizer::new(|peer| prompt_biometric_for(peer)); +/// ``` +pub struct PerCallerAuthorizer { + approve: Box bool + Send + Sync>, + approved: std::sync::Mutex)>>, +} + +impl PerCallerAuthorizer { + /// Builds a per-caller authorizer that consults `approve` once per new peer and + /// pins peers it approves. + /// + /// Args: + /// * `approve`: called for a not-yet-approved peer; returning true pins it. + pub fn new(approve: impl Fn(&PeerIdentity) -> bool + Send + Sync + 'static) -> Self { + Self { + approve: Box::new(approve), + approved: std::sync::Mutex::new(std::collections::HashSet::new()), + } + } +} + +impl SignAuthorizer for PerCallerAuthorizer { + fn authorize_sign(&self, peer: &PeerIdentity) -> bool { + let key = (peer.uid, peer.pid); + let mut approved = self + .approved + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if approved.contains(&key) { + return true; + } + if (self.approve)(peer) { + approved.insert(key); + true + } else { + false + } + } +} + /// Wraps an `AgentHandle` to implement the `ssh_agent_lib::agent::Session` trait. /// -/// Each `AgentSession` holds a reference to an `AgentHandle`, enabling multiple -/// independent agent instances to coexist. +/// Each `AgentSession` holds a reference to an `AgentHandle`, the identity of the peer +/// it serves, and the per-request `SignAuthorizer` consulted before each signature. #[derive(Clone)] pub struct AgentSession { /// Reference to the agent handle handle: Arc, + /// The connecting peer this session serves. + peer: PeerIdentity, + /// Per-request gate consulted before every signature. + authorizer: Arc, } impl AgentSession { - /// Creates a new AgentSession wrapping the given AgentHandle. + /// Creates a session that allows every signature (in-process / default use). + /// + /// Args: + /// * `handle`: the agent handle holding the unlocked keys. pub fn new(handle: Arc) -> Self { - Self { handle } + Self { + handle, + peer: PeerIdentity::local(), + authorizer: Arc::new(AllowAllSigning), + } + } + + /// Creates a session for a specific peer, gated by `authorizer` on every sign. + /// + /// Args: + /// * `handle`: the agent handle holding the unlocked keys. + /// * `peer`: the connecting process's identity. + /// * `authorizer`: the per-request signing gate. + pub fn with_authorizer( + handle: Arc, + peer: PeerIdentity, + authorizer: Arc, + ) -> Self { + Self { + handle, + peer, + authorizer, + } } /// Returns a reference to the underlying agent handle. @@ -105,6 +241,14 @@ impl Session for AgentSession { } async fn sign(&mut self, request: SignRequest) -> Result { + if !self.authorizer.authorize_sign(&self.peer) { + warn!( + "Sign request refused: peer not authorized to sign (uid={}, pid={:?})", + self.peer.uid, self.peer.pid + ); + return Err(SSHAgentError::Failure); + } + debug!( "Handling sign request for key type: {:?}", request.pubkey.algorithm() @@ -362,6 +506,7 @@ impl Session for MaybeAuthorized { pub(crate) struct PeerAuthorizedAgent { handle: Arc, owner_uid: u32, + authorizer: Arc, } #[cfg(unix)] @@ -374,10 +519,18 @@ impl PeerAuthorizedAgent { /// /// Usage: /// ```ignore - /// let agent = PeerAuthorizedAgent::new(handle, owner_uid); + /// let agent = PeerAuthorizedAgent::new(handle, owner_uid, authorizer); /// ``` - pub(crate) fn new(handle: Arc, owner_uid: u32) -> Self { - Self { handle, owner_uid } + pub(crate) fn new( + handle: Arc, + owner_uid: u32, + authorizer: Arc, + ) -> Self { + Self { + handle, + owner_uid, + authorizer, + } } } @@ -386,7 +539,15 @@ 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())) + let peer = PeerIdentity { + uid: cred.uid(), + pid: cred.pid(), + }; + MaybeAuthorized::Authorized(AgentSession::with_authorizer( + self.handle.clone(), + peer, + self.authorizer.clone(), + )) } Ok(cred) => { warn!( @@ -419,6 +580,109 @@ mod tests { pkcs8_doc.as_ref().to_vec() } + /// A session whose per-request authorizer denies the peer must refuse to sign, + /// even with the agent unlocked and the key present. This is the #354 gate: an + /// unlocked agent does not grant silent signing to an unapproved same-user caller. + #[tokio::test] + async fn agent_refuses_to_sign_when_authorizer_denies() { + struct DenyAll; + impl SignAuthorizer for DenyAll { + fn authorize_sign(&self, _peer: &PeerIdentity) -> bool { + false + } + } + + let seed: [u8; 32] = { + let pkcs8 = generate_test_pkcs8(); + let mut s = [0u8; 32]; + s.copy_from_slice(&pkcs8[16..48]); + s + }; + let ssh_keypair = SshEd25519Keypair::from_seed(&seed); + let pubkey_bytes = ssh_keypair.public.0; + + let handle = Arc::new(AgentHandle::new(PathBuf::from("/tmp/test-deny.sock"))); + let peer = PeerIdentity { + uid: 1000, + pid: Some(4242), + }; + let mut session = AgentSession::with_authorizer(handle.clone(), peer, Arc::new(DenyAll)); + + session + .add_identity(AddIdentity { + credential: Credential::Key { + privkey: KeypairData::Ed25519(ssh_keypair), + comment: "test-key".to_string(), + }, + }) + .await + .unwrap(); + assert_eq!(handle.key_count().unwrap(), 1); + + let request = SignRequest { + pubkey: KeyData::Ed25519(Ed25519PublicKey(pubkey_bytes)), + data: b"unauthorized payload".to_vec(), + flags: 0, + }; + let result = session.sign(request).await; + assert!( + result.is_err(), + "a denied peer must not obtain a signature even when the agent is unlocked and the key is present" + ); + } + + /// The per-caller policy: an approved caller is pinned (not re-prompted), and a + /// *different* process — even at the same uid — triggers its own approval. + #[test] + fn per_caller_pins_approved_and_reprompts_a_different_process() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let prompts = Arc::new(AtomicUsize::new(0)); + let p = prompts.clone(); + let auth = PerCallerAuthorizer::new(move |peer: &PeerIdentity| { + p.fetch_add(1, Ordering::SeqCst); + peer.uid == 1000 // approve only the owner uid + }); + + let git = PeerIdentity { + uid: 1000, + pid: Some(11), + }; + let malware = PeerIdentity { + uid: 1000, + pid: Some(22), + }; + let stranger = PeerIdentity { + uid: 1001, + pid: Some(33), + }; + + assert!( + auth.authorize_sign(&git), + "first request from git is approved" + ); + assert!(auth.authorize_sign(&git), "git is now pinned"); + assert_eq!( + prompts.load(Ordering::SeqCst), + 1, + "an approved caller is not re-prompted per signature" + ); + + assert!( + !auth.authorize_sign(&stranger), + "an unapproved peer is refused" + ); + assert!( + auth.authorize_sign(&malware), + "a different same-uid process is approved by this fn, but only via its own prompt" + ); + assert_eq!( + prompts.load(Ordering::SeqCst), + 3, + "a different process triggers a fresh approval; pinning is per-(uid,pid)" + ); + } + #[test] fn test_agent_session_new() { let handle = Arc::new(AgentHandle::new(PathBuf::from("/tmp/test.sock"))); @@ -597,7 +861,7 @@ mod tests { 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 agent = PeerAuthorizedAgent::new(handle, owner_uid, Arc::new(AllowAllSigning)); let mut session = agent.new_session(&a); assert!( session.sign(request).await.is_ok(), @@ -617,7 +881,8 @@ mod tests { // 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 agent = + PeerAuthorizedAgent::new(handle, peer_uid.wrapping_add(1), Arc::new(AllowAllSigning)); let mut session = agent.new_session(&a); 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 86f809e6..fcafb43a 100644 --- a/crates/auths-core/src/api/runtime.rs +++ b/crates/auths-core/src/api/runtime.rs @@ -946,7 +946,10 @@ async fn run_idle_monitor(handle: Arc, interval: std::time::Duratio /// - `Err(AgentError)` if binding/setup fails or the listener loop exits with an error. #[cfg(unix)] #[allow(clippy::disallowed_methods)] // INVARIANT: Unix socket lifecycle — socket dir creation and cleanup is inherently I/O -pub async fn start_agent_listener_with_handle(handle: Arc) -> Result<(), AgentError> { +pub async fn start_agent_listener_with_handle( + handle: Arc, + authorizer: Arc, +) -> Result<(), AgentError> { let socket_path = handle.socket_path(); info!("Attempting to start agent listener at {:?}", socket_path); @@ -1011,7 +1014,9 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul } // --- Create the peer-authorizing session factory --- - let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid()); + // Per-request signing is gated by the injected SignAuthorizer; the host chooses the + // policy (per-caller approval for an interactive agent, permissive for headless). + let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid(), authorizer); // --- Start the main listener loop from ssh_agent_lib --- let result = listen(listener, agent).await; @@ -1049,7 +1054,7 @@ pub async fn start_agent_listener_with_handle(handle: Arc) -> Resul pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentError> { use std::path::PathBuf; let handle = Arc::new(AgentHandle::new(PathBuf::from(&socket_path_str))); - start_agent_listener_with_handle(handle).await + start_agent_listener_with_handle(handle, Arc::new(crate::agent::AllowAllSigning)).await } #[cfg(all(test, unix))] diff --git a/crates/auths-core/tests/cases/agent_socket.rs b/crates/auths-core/tests/cases/agent_socket.rs index 9e3ba7ef..0f41fbc5 100644 --- a/crates/auths-core/tests/cases/agent_socket.rs +++ b/crates/auths-core/tests/cases/agent_socket.rs @@ -50,7 +50,10 @@ async fn locked_agent_refuses_signing_over_the_socket() { 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())); + let server = tokio::spawn(start_agent_listener_with_handle( + handle.clone(), + Arc::new(auths_core::agent::AllowAllSigning), + )); wait_until_running(&socket_path).await; let pkcs8 = generate_ed25519_pkcs8(); diff --git a/crates/auths-sdk/src/agent_core.rs b/crates/auths-sdk/src/agent_core.rs index 9a289224..94af18b7 100644 --- a/crates/auths-sdk/src/agent_core.rs +++ b/crates/auths-sdk/src/agent_core.rs @@ -9,7 +9,8 @@ pub use auths_core::AgentHandle; #[cfg(unix)] pub use auths_core::agent::{ - AgentStatus, add_identity, agent_sign, check_agent_status, remove_all_identities, + AgentStatus, AllowAllSigning, PeerIdentity, PerCallerAuthorizer, SignAuthorizer, add_identity, + agent_sign, check_agent_status, remove_all_identities, }; #[cfg(unix)] pub use auths_core::api::start_agent_listener_with_handle;