kovra_agent/lib.rs
1//! `kovra-agent` — the governed ssh-agent (KOV-13). A thin face over
2//! `kovra-core`, mirroring `kovra-wrapper` and `kovra-cli`: kovra **is** the
3//! ssh-agent. It speaks the ssh-agent wire protocol on `$SSH_AUTH_SOCK` and
4//! answers each sign request by signing **in its own memory** with a custodied
5//! [`Keypair`](kovra_core::SecretRecord) (KOV-12). The private key never leaves
6//! kovra and never hits disk (I7).
7//!
8//! What a plain agent cannot do, and this one does:
9//! - **Per-signature policy.** A `high`/`prod` key confirms via the broker /
10//! biometric on **every** signature and is audited (I3/I15/I12); `low`/
11//! `medium` keys sign silently (still audited).
12//! - **Scope (I13).** The agent serves keys under an [`AgentScope`] read from a
13//! config file (`<root>/agent.toml`, see [`config`]); an out-of-scope key is
14//! neither listed nor signable.
15//!
16//! **Honest limit (spec §16).** This governs the *authentication event* — the
17//! moment `ssh` asks the agent to sign the session challenge — **not** the SSH
18//! session that opens afterward. Once a signature is approved and the session is
19//! established, kovra has no further control over what flows through it, exactly
20//! as Vault/1Password/etc. cannot. Per-signature confirmation makes each new
21//! auth an attended, audited act; it does not contain the live session. Do not
22//! overclaim this in docs or UAT notes.
23//!
24//! ## Layering
25//! `agent → core` only (like `wrapper → core`). `core` never depends on this
26//! crate. Free core (§20): this is `crates/agent`, NOT `enterprise/`. All
27//! cryptography lives in `core` ([`kovra_core::sign_ssh_agent`]); this crate only
28//! parses/encodes the wire protocol and orchestrates policy/scope/audit. The
29//! untrusted parser is isolated in [`protocol`] (a Phase-4 fuzz target).
30
31pub mod config;
32pub mod daemon;
33pub mod error;
34pub mod protocol;
35pub mod session;
36
37use std::path::PathBuf;
38use std::time::Duration;
39
40pub use config::{AGENT_CONFIG_FILE, config_path, load_scope};
41pub use daemon::{SessionOwned, default_socket_path};
42pub use error::AgentError;
43pub use session::{KeypairEntry, Session};
44
45use kovra_core::{AgentScope, AuditSink, Clock, Confirmer};
46
47/// Everything `run_agent` needs, built by the face (the CLI) from its `Ctx`.
48/// The custodied keys are provided by a closure so the daemon can re-read them
49/// per request (a key added/removed while the daemon runs is reflected).
50pub struct AgentConfig {
51 /// The socket path to bind (published as `$SSH_AUTH_SOCK`).
52 pub socket_path: PathBuf,
53 /// The agent's capability scope (I13), from `agent.toml` or the safe default.
54 pub scope: AgentScope,
55 /// How long a `high`/`prod` confirmation may block before failing safe.
56 pub confirm_timeout: Duration,
57 /// The observed requesting process for the I16 prompt line, if any.
58 pub requesting_process: Option<String>,
59}
60
61/// Provider of the live session inputs per request: the custodied keypairs, a
62/// fresh confirmer, audit sink, and clock. Implemented by the CLI over its
63/// `Ctx`; behind a trait so the daemon stays face-agnostic and testable.
64pub trait SessionProvider {
65 /// Load the custodied keypairs that have a private half. Out-of-scope
66 /// filtering is applied by the session against the agent's scope (I13).
67 fn load_keys(&self) -> Result<Vec<KeypairEntry>, AgentError>;
68 /// A fresh confirmation broker (biometric / file fallback).
69 fn confirmer(&self) -> Box<dyn Confirmer>;
70 /// The append-only audit sink (I12).
71 fn audit(&self) -> Box<dyn AuditSink>;
72 /// The clock for audit timestamps.
73 fn clock(&self) -> Box<dyn Clock>;
74}
75
76/// Run the governed ssh-agent in the **foreground** (decision Q4) until Ctrl-C.
77///
78/// Refuses to start if `$SSH_AUTH_SOCK` is already set (decision Q5: never
79/// hijack/chain an existing agent — the error carries the how-to-proceed
80/// guidance). Binds the socket `0600`, prints the `export SSH_AUTH_SOCK=…` hint,
81/// serves connections, and removes the socket on exit.
82///
83/// The socket peer and a real `ssh` client are `[host]` — validated by the human
84/// on the M4. The protocol/session logic this drives is mock-tested.
85pub fn run_agent<P: SessionProvider>(config: AgentConfig, provider: P) -> Result<(), AgentError> {
86 daemon::ensure_no_existing_agent()?;
87
88 let listener = daemon::bind(&config.socket_path)?;
89 let path_display = config.socket_path.display().to_string();
90
91 // Best-effort socket teardown on SIGINT/SIGTERM (foreground lifecycle). We
92 // install a tiny signal handler that removes the socket and exits; the accept
93 // loop otherwise blocks. (Native signal wiring is a `[host]` concern; the
94 // core remove is exercised by `daemon::cleanup`.)
95 install_signal_cleanup(&config.socket_path);
96
97 eprintln!(
98 "kovra ssh-agent listening on {path_display}\n\
99 Export it in the shells that should use kovra as their agent:\n\
100 \n export SSH_AUTH_SOCK={path_display}\n\
101 \nServing in the foreground — press Ctrl-C to stop. \
102 (Governs the auth event, not the SSH session that follows — spec §16.)"
103 );
104
105 let confirm_timeout = config.confirm_timeout;
106 let scope = config.scope;
107 let requesting_process = config.requesting_process;
108
109 let result = daemon::serve(&listener, || {
110 Ok(SessionOwned {
111 keys: provider.load_keys()?,
112 scope: scope.clone(),
113 confirmer: provider.confirmer(),
114 audit: provider.audit(),
115 clock: provider.clock(),
116 confirm_timeout,
117 requesting_process: requesting_process.clone(),
118 })
119 });
120
121 daemon::cleanup(&config.socket_path);
122 result
123}
124
125/// Install a best-effort SIGINT/SIGTERM handler that removes the socket and
126/// exits cleanly. Uses only libc (already a transitive dep via `kovra-wrapper`).
127#[cfg(unix)]
128fn install_signal_cleanup(path: &std::path::Path) {
129 use std::sync::OnceLock;
130 static SOCK: OnceLock<PathBuf> = OnceLock::new();
131 let _ = SOCK.set(path.to_path_buf());
132
133 extern "C" fn handler(_sig: i32) {
134 if let Some(p) = SOCK.get() {
135 // Direct unlink in the handler (async-signal-safe via libc).
136 if let Ok(c) = std::ffi::CString::new(p.as_os_str().to_string_lossy().as_bytes()) {
137 unsafe {
138 libc::unlink(c.as_ptr());
139 }
140 }
141 }
142 // Exit without unwinding (handler context).
143 std::process::exit(130);
144 }
145
146 let handler_ptr = handler as *const () as libc::sighandler_t;
147 unsafe {
148 libc::signal(libc::SIGINT, handler_ptr);
149 libc::signal(libc::SIGTERM, handler_ptr);
150 }
151}
152
153#[cfg(not(unix))]
154fn install_signal_cleanup(_path: &std::path::Path) {}