Skip to main content

gitway_lib/agent/
daemon.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-04-21
3//! Long-lived SSH agent daemon.
4//!
5//! Implements the server side of the SSH agent wire protocol on top of
6//! [`ssh_agent_lib`]. Keys are held in-memory only, wrapped in types that
7//! zeroize on drop; nothing is ever persisted to disk. `SIGTERM` and
8//! `SIGINT` trigger graceful shutdown — the socket is unlinked, the pid
9//! file removed, and every stored key is zeroed before the process exits.
10//!
11//! # Signing support (v0.6)
12//!
13//! The daemon accepts `Add` for keys of every algorithm Gitway's
14//! `keygen` can produce (Ed25519, ECDSA P-256/384/521, RSA 2048..16384).
15//! The `Sign` handler, however, only covers **Ed25519** in v0.6;
16//! ECDSA and RSA sign requests return an `AgentError::Failure` with a
17//! `log::warn!` entry so callers see a clear error and operators can
18//! see that the unsupported path was hit. Supporting those algorithms
19//! is tracked as a follow-up within the v0.6.x series.
20//!
21//! # Example
22//!
23//! ```no_run
24//! use std::path::PathBuf;
25//! use gitway_lib::agent::daemon::{AgentDaemonConfig, run};
26//!
27//! # async fn doc() -> Result<(), gitway_lib::GitwayError> {
28//! let cfg = AgentDaemonConfig {
29//!     socket_path: PathBuf::from("/tmp/gitway-agent.sock"),
30//!     pid_file: None,
31//!     default_ttl: None,
32//! };
33//! run(cfg).await?;
34//! # Ok(())
35//! # }
36//! ```
37
38use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41use std::time::{Duration, Instant};
42
43use async_trait::async_trait;
44use ssh_agent_lib::agent::{listen, Session};
45use ssh_agent_lib::error::AgentError;
46use ssh_agent_lib::proto::{
47    AddIdentity, AddIdentityConstrained, Credential, Identity, KeyConstraint, RemoveIdentity,
48    SignRequest,
49};
50use ssh_key::{HashAlg, PrivateKey, Signature};
51use tokio::net::UnixListener;
52use tokio::sync::Mutex;
53
54use crate::GitwayError;
55
56// ── Public types ──────────────────────────────────────────────────────────────
57
58/// Configuration for [`run`].
59///
60/// `socket_path` must be on a filesystem that supports Unix domain sockets
61/// (`$XDG_RUNTIME_DIR` is conventional). The directory permissions are the
62/// caller's responsibility; the daemon will set the socket inode to 0600.
63#[derive(Debug, Clone)]
64pub struct AgentDaemonConfig {
65    /// Path to bind the agent socket on.
66    pub socket_path: PathBuf,
67    /// Optional pid-file location. If `Some`, the daemon writes its PID
68    /// here on startup and removes the file on shutdown.
69    pub pid_file: Option<PathBuf>,
70    /// Default lifetime applied to added keys when the client does not
71    /// specify one via `KeyConstraint::Lifetime`.
72    pub default_ttl: Option<Duration>,
73}
74
75// ── Internal state ────────────────────────────────────────────────────────────
76
77/// One key loaded into the daemon.
78///
79/// `PrivateKey` already zeroizes on drop (via its inner `KeypairData`).
80/// The struct only adds user-visible metadata — no additional secret
81/// material to worry about.
82#[derive(Debug, Clone)]
83struct StoredKey {
84    key: PrivateKey,
85    expires_at: Option<Instant>,
86    confirm: bool,
87}
88
89/// Daemon-wide key store + lock state, shared across connections.
90#[derive(Debug, Default)]
91struct KeyStore {
92    /// Keyed by SHA-256 fingerprint of the public key so remove-by-pubkey
93    /// is O(1).
94    keys: HashMap<String, StoredKey>,
95    /// Agent-wide lock state (`ssh-add -x`). When `Some`, all Session
96    /// methods that would return secret material or alter the store
97    /// error with `AgentError::Failure` until `unlock` is called with
98    /// the same passphrase.
99    lock: Option<String>,
100}
101
102impl KeyStore {
103    fn new() -> Self {
104        Self::default()
105    }
106
107    /// Returns `true` while the agent is locked.
108    fn is_locked(&self) -> bool {
109        self.lock.is_some()
110    }
111
112    /// Removes every key whose `expires_at` is in the past.
113    ///
114    /// Called from the TTL sweeper task every second.
115    fn evict_expired(&mut self, now: Instant) {
116        self.keys.retain(|_fp, k| match k.expires_at {
117            Some(t) => t > now,
118            None => true,
119        });
120    }
121}
122
123// ── Session impl ──────────────────────────────────────────────────────────────
124
125/// Per-connection session. Cloned by `ssh-agent-lib`'s accept loop; all
126/// state lives behind the shared `Arc<Mutex<KeyStore>>`.
127#[derive(Debug, Clone)]
128struct AgentSession {
129    store: Arc<Mutex<KeyStore>>,
130    default_ttl: Option<Duration>,
131}
132
133#[async_trait]
134impl Session for AgentSession {
135    async fn request_identities(&mut self) -> Result<Vec<Identity>, AgentError> {
136        let store = self.store.lock().await;
137        if store.is_locked() {
138            return Err(AgentError::Failure);
139        }
140        Ok(store
141            .keys
142            .values()
143            .map(|s| Identity {
144                pubkey: s.key.public_key().key_data().clone(),
145                comment: s.key.comment().to_owned(),
146            })
147            .collect())
148    }
149
150    async fn add_identity(&mut self, req: AddIdentity) -> Result<(), AgentError> {
151        self.add_inner(req, Vec::new()).await
152    }
153
154    async fn add_identity_constrained(
155        &mut self,
156        req: AddIdentityConstrained,
157    ) -> Result<(), AgentError> {
158        self.add_inner(req.identity, req.constraints).await
159    }
160
161    async fn remove_identity(&mut self, req: RemoveIdentity) -> Result<(), AgentError> {
162        let mut store = self.store.lock().await;
163        if store.is_locked() {
164            return Err(AgentError::Failure);
165        }
166        let pk = ssh_key::PublicKey::from(req.pubkey);
167        let fp = pk.fingerprint(HashAlg::Sha256).to_string();
168        if store.keys.remove(&fp).is_none() {
169            return Err(AgentError::Failure);
170        }
171        Ok(())
172    }
173
174    async fn remove_all_identities(&mut self) -> Result<(), AgentError> {
175        let mut store = self.store.lock().await;
176        if store.is_locked() {
177            return Err(AgentError::Failure);
178        }
179        store.keys.clear();
180        Ok(())
181    }
182
183    async fn sign(&mut self, req: SignRequest) -> Result<Signature, AgentError> {
184        let store = self.store.lock().await;
185        if store.is_locked() {
186            return Err(AgentError::Failure);
187        }
188        let pk = ssh_key::PublicKey::from(req.pubkey.clone());
189        let fp = pk.fingerprint(HashAlg::Sha256).to_string();
190        let stored = store.keys.get(&fp).ok_or(AgentError::Failure)?;
191
192        if stored.confirm {
193            // v0.6 does not implement interactive confirmation — the
194            // daemon would need a side-channel to the user. Reject
195            // rather than sign silently.
196            log::warn!(
197                "gitway-agent: sign request for confirm-required key {fp} rejected — \
198                 interactive confirmation not yet implemented"
199            );
200            return Err(AgentError::Failure);
201        }
202
203        sign_with_key(&stored.key, &req.data).map_err(|e| {
204            log::warn!("gitway-agent: sign failed for {fp}: {e}");
205            AgentError::Failure
206        })
207    }
208
209    async fn lock(&mut self, key: String) -> Result<(), AgentError> {
210        let mut store = self.store.lock().await;
211        if store.is_locked() {
212            return Err(AgentError::Failure);
213        }
214        store.lock = Some(key);
215        Ok(())
216    }
217
218    async fn unlock(&mut self, key: String) -> Result<(), AgentError> {
219        let mut store = self.store.lock().await;
220        match &store.lock {
221            Some(current) if *current == key => {
222                store.lock = None;
223                Ok(())
224            }
225            _ => Err(AgentError::Failure),
226        }
227    }
228}
229
230impl AgentSession {
231    async fn add_inner(
232        &mut self,
233        req: AddIdentity,
234        constraints: Vec<KeyConstraint>,
235    ) -> Result<(), AgentError> {
236        let mut store = self.store.lock().await;
237        if store.is_locked() {
238            return Err(AgentError::Failure);
239        }
240
241        let key = match req.credential {
242            Credential::Key { privkey, comment } => {
243                let mut pk = PrivateKey::try_from(privkey).map_err(|e| {
244                    log::warn!("gitway-agent: add failed to parse credential: {e}");
245                    AgentError::Failure
246                })?;
247                pk.set_comment(&comment);
248                pk
249            }
250            Credential::Cert { .. } => {
251                // Certificate-bound keys would need cert validation we
252                // have not wired up. Reject politely.
253                return Err(AgentError::Failure);
254            }
255        };
256
257        let mut expires_at = self.default_ttl.map(|d| Instant::now() + d);
258        let mut confirm = false;
259        for c in constraints {
260            match c {
261                KeyConstraint::Lifetime(secs) => {
262                    expires_at = Some(Instant::now() + Duration::from_secs(u64::from(secs)));
263                }
264                KeyConstraint::Confirm => {
265                    confirm = true;
266                }
267                KeyConstraint::Extension(_) => {
268                    // Silently ignore unknown extension-based constraints.
269                }
270            }
271        }
272
273        let fp = key.public_key().fingerprint(HashAlg::Sha256).to_string();
274        store.keys.insert(
275            fp,
276            StoredKey {
277                key,
278                expires_at,
279                confirm,
280            },
281        );
282        Ok(())
283    }
284}
285
286// ── Signing ───────────────────────────────────────────────────────────────────
287
288/// Signs `data` with `key`.
289///
290/// v0.6 implements Ed25519 directly via `ed25519-dalek`. ECDSA and RSA
291/// sign paths return an error so the caller sees a structured failure
292/// rather than silently-wrong signatures.
293fn sign_with_key(key: &PrivateKey, data: &[u8]) -> Result<Signature, GitwayError> {
294    use ssh_key::Algorithm;
295    match key.algorithm() {
296        Algorithm::Ed25519 => sign_ed25519(key, data),
297        other => Err(GitwayError::invalid_config(format!(
298            "agent daemon sign: algorithm {} not yet supported (Ed25519 only in v0.6)",
299            other.as_str()
300        ))),
301    }
302}
303
304fn sign_ed25519(key: &PrivateKey, data: &[u8]) -> Result<Signature, GitwayError> {
305    use ed25519_dalek::Signer as _;
306    use ssh_key::private::KeypairData;
307    let KeypairData::Ed25519(kp) = key.key_data() else {
308        return Err(GitwayError::invalid_config(
309            "internal: Ed25519 sign called on non-Ed25519 key",
310        ));
311    };
312    let sk = ed25519_dalek::SigningKey::from_bytes(&kp.private.to_bytes());
313    let sig = sk.sign(data);
314    Signature::new(ssh_key::Algorithm::Ed25519, sig.to_bytes().to_vec())
315        .map_err(|e| GitwayError::signing(format!("Ed25519 signature encode failed: {e}")))
316}
317
318// ── Public entry point ────────────────────────────────────────────────────────
319
320/// Runs the agent daemon until a termination signal arrives.
321///
322/// # Errors
323///
324/// Returns [`GitwayError`] if the socket cannot be bound, the pid file
325/// cannot be written, or the accept loop returns with an error.
326///
327/// # Termination
328///
329/// On `SIGTERM` or `SIGINT` the function returns `Ok(())` after unlinking
330/// the socket and removing the pid file. Every stored key is zeroed as
331/// the `KeyStore` drops.
332pub async fn run(config: AgentDaemonConfig) -> Result<(), GitwayError> {
333    let listener = bind_unix_socket(&config.socket_path)?;
334    write_pid_file(config.pid_file.as_deref())?;
335
336    let store = Arc::new(Mutex::new(KeyStore::new()));
337    let session = AgentSession {
338        store: Arc::clone(&store),
339        default_ttl: config.default_ttl,
340    };
341
342    // Background task: evict expired keys once per second.
343    let evict_store = Arc::clone(&store);
344    let evict_handle = tokio::spawn(async move {
345        let mut ticker = tokio::time::interval(Duration::from_secs(1));
346        loop {
347            ticker.tick().await;
348            let now = Instant::now();
349            let mut s = evict_store.lock().await;
350            s.evict_expired(now);
351        }
352    });
353
354    // Accept loop + shutdown race. `listen` runs until the listener errors
355    // out; we race it against SIGTERM/SIGINT so a signal always wins.
356    let shutdown = tokio::signal::ctrl_c();
357    #[cfg(unix)]
358    let sigterm = async {
359        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
360        term.recv().await;
361        Ok::<_, std::io::Error>(())
362    };
363
364    let accept_loop = listen(listener, session);
365
366    tokio::select! {
367        res = accept_loop => {
368            if let Err(e) = res {
369                log::warn!("gitway-agent: accept loop ended with error: {e}");
370            }
371        }
372        _ = shutdown => {
373            log::info!("gitway-agent: SIGINT received, shutting down");
374        }
375        _ = sigterm => {
376            log::info!("gitway-agent: SIGTERM received, shutting down");
377        }
378    }
379
380    evict_handle.abort();
381    cleanup(&config);
382    Ok(())
383}
384
385// ── Socket / pid plumbing ─────────────────────────────────────────────────────
386
387fn bind_unix_socket(path: &Path) -> Result<UnixListener, GitwayError> {
388    use std::os::unix::fs::PermissionsExt as _;
389    // Remove any stale socket file so bind() doesn't fail with "address in use".
390    let _ = std::fs::remove_file(path);
391    let listener = UnixListener::bind(path)?;
392    // Restrict the socket inode to the owning user only.
393    let mut perms = std::fs::metadata(path)?.permissions();
394    perms.set_mode(SOCKET_MODE);
395    std::fs::set_permissions(path, perms)?;
396    Ok(listener)
397}
398
399fn write_pid_file(path: Option<&Path>) -> Result<(), GitwayError> {
400    let Some(p) = path else {
401        return Ok(());
402    };
403    let pid = std::process::id();
404    std::fs::write(p, format!("{pid}\n"))?;
405    Ok(())
406}
407
408fn cleanup(config: &AgentDaemonConfig) {
409    let _ = std::fs::remove_file(&config.socket_path);
410    if let Some(ref p) = config.pid_file {
411        let _ = std::fs::remove_file(p);
412    }
413}
414
415/// Unix-mode bits for the agent socket (owner read/write only).
416const SOCKET_MODE: u32 = 0o600;
417
418// ── Tests ─────────────────────────────────────────────────────────────────────
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::keygen::{generate, KeyType};
424
425    #[test]
426    fn evict_expired_drops_past_keys_only() {
427        let key_now = generate(KeyType::Ed25519, None, "now").unwrap();
428        let key_later = generate(KeyType::Ed25519, None, "later").unwrap();
429        let fp_now = key_now
430            .public_key()
431            .fingerprint(HashAlg::Sha256)
432            .to_string();
433        let fp_later = key_later
434            .public_key()
435            .fingerprint(HashAlg::Sha256)
436            .to_string();
437        let mut store = KeyStore::new();
438        // Use checked_sub so clippy's unchecked-duration-subtraction lint
439        // is happy even though we know the test runs after process start.
440        let past = Instant::now()
441            .checked_sub(Duration::from_secs(1))
442            .expect("test runs after process start; Instant never underflows");
443        store.keys.insert(
444            fp_now.clone(),
445            StoredKey {
446                key: key_now,
447                expires_at: Some(past),
448                confirm: false,
449            },
450        );
451        store.keys.insert(
452            fp_later.clone(),
453            StoredKey {
454                key: key_later,
455                expires_at: Some(Instant::now() + Duration::from_secs(60)),
456                confirm: false,
457            },
458        );
459        store.evict_expired(Instant::now());
460        assert!(!store.keys.contains_key(&fp_now));
461        assert!(store.keys.contains_key(&fp_later));
462    }
463
464    #[test]
465    fn sign_ed25519_roundtrip_verifies_with_public_key() {
466        use ed25519_dalek::Verifier as _;
467        let key = generate(KeyType::Ed25519, None, "roundtrip").unwrap();
468        let data = b"hello gitway agent";
469        let sig = sign_with_key(&key, data).unwrap();
470        assert_eq!(sig.algorithm(), ssh_key::Algorithm::Ed25519);
471
472        // Cross-verify via ed25519-dalek directly.
473        let ssh_key::public::KeyData::Ed25519(pk) = key.public_key().key_data() else {
474            unreachable!()
475        };
476        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pk.0).unwrap();
477        let bytes: [u8; 64] = sig.as_bytes().try_into().unwrap();
478        let dalek_sig = ed25519_dalek::Signature::from_bytes(&bytes);
479        verifying.verify(data, &dalek_sig).unwrap();
480    }
481
482    #[test]
483    fn sign_ecdsa_is_not_supported_yet() {
484        let key = generate(KeyType::EcdsaP256, None, "nope").unwrap();
485        let err = sign_with_key(&key, b"data").unwrap_err();
486        assert!(err.to_string().contains("not yet supported"));
487    }
488}