Skip to main content

gitway_lib/agent/
daemon.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
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.
8//!
9//! # Transports
10//!
11//! - **Unix** — binds a Unix domain socket at `config.socket_path`
12//!   with mode `0600`. `SIGTERM` and `SIGINT` trigger graceful
13//!   shutdown.
14//! - **Windows** — creates a named pipe at `config.socket_path`
15//!   (conventionally `\\.\pipe\gitway-agent`). `Ctrl+C` triggers
16//!   graceful shutdown; the pipe object is released automatically
17//!   when the server handle drops.
18//!
19//! On shutdown the stored keys are zeroed via `KeyStore`'s `Drop`, the
20//! pid file is removed, and (on Unix) the socket inode is unlinked.
21//!
22//! # Signing support
23//!
24//! The daemon accepts `Add` for keys of every algorithm Gitway's
25//! `keygen` can produce (Ed25519, ECDSA P-256/384/521, RSA 2048..16384)
26//! and signs with all of them. Ed25519 and the three ECDSA curves go
27//! through `ssh-key`'s built-in `Signer<Signature>` trait; RSA routes
28//! directly to `rsa::pkcs1v15::SigningKey<ShaN>` with the digest picked
29//! from `SignRequest.flags` — `rsa-sha2-512` when `RSA_SHA2_512` is set,
30//! `rsa-sha2-256` when `RSA_SHA2_256` is set. Requests with neither
31//! flag (legacy SHA-1 `ssh-rsa`) are rejected: OpenSSH 8.2+ and modern
32//! Git hosts always request SHA-2.
33//!
34//! # Example
35//!
36//! ```no_run
37//! use std::path::PathBuf;
38//! use gitway_lib::agent::daemon::{AgentDaemonConfig, run};
39//!
40//! # async fn doc() -> Result<(), gitway_lib::GitwayError> {
41//! let cfg = AgentDaemonConfig {
42//!     socket_path: PathBuf::from("/tmp/gitway-agent.sock"),
43//!     pid_file: None,
44//!     default_ttl: None,
45//! };
46//! run(cfg).await?;
47//! # Ok(())
48//! # }
49//! ```
50
51use std::collections::HashMap;
52use std::path::{Path, PathBuf};
53use std::sync::Arc;
54use std::time::{Duration, Instant};
55
56use async_trait::async_trait;
57use ssh_agent_lib::agent::{listen, Session};
58use ssh_agent_lib::error::AgentError;
59use ssh_agent_lib::proto::{
60    signature as proto_signature, AddIdentity, AddIdentityConstrained, Credential, Identity,
61    KeyConstraint, RemoveIdentity, SignRequest,
62};
63use ssh_key::private::KeypairData;
64use ssh_key::{Algorithm, HashAlg, PrivateKey, Signature};
65use tokio::sync::Mutex;
66
67use crate::GitwayError;
68
69// ── Public types ──────────────────────────────────────────────────────────────
70
71/// Configuration for [`run`].
72///
73/// `socket_path` must be on a filesystem that supports Unix domain sockets
74/// (`$XDG_RUNTIME_DIR` is conventional). The directory permissions are the
75/// caller's responsibility; the daemon will set the socket inode to 0600.
76#[derive(Debug, Clone)]
77pub struct AgentDaemonConfig {
78    /// Path to bind the agent socket on.
79    pub socket_path: PathBuf,
80    /// Optional pid-file location. If `Some`, the daemon writes its PID
81    /// here on startup and removes the file on shutdown.
82    pub pid_file: Option<PathBuf>,
83    /// Default lifetime applied to added keys when the client does not
84    /// specify one via `KeyConstraint::Lifetime`.
85    pub default_ttl: Option<Duration>,
86}
87
88// ── Internal state ────────────────────────────────────────────────────────────
89
90/// One key loaded into the daemon.
91///
92/// `PrivateKey` already zeroizes on drop (via its inner `KeypairData`).
93/// The struct only adds user-visible metadata — no additional secret
94/// material to worry about.
95#[derive(Debug, Clone)]
96struct StoredKey {
97    key: PrivateKey,
98    expires_at: Option<Instant>,
99    confirm: bool,
100}
101
102/// Daemon-wide key store + lock state, shared across connections.
103#[derive(Debug, Default)]
104struct KeyStore {
105    /// Keyed by SHA-256 fingerprint of the public key so remove-by-pubkey
106    /// is O(1).
107    keys: HashMap<String, StoredKey>,
108    /// Agent-wide lock state (`ssh-add -x`). When `Some`, all Session
109    /// methods that would return secret material or alter the store
110    /// error with `AgentError::Failure` until `unlock` is called with
111    /// the same passphrase.
112    lock: Option<String>,
113}
114
115impl KeyStore {
116    fn new() -> Self {
117        Self::default()
118    }
119
120    /// Returns `true` while the agent is locked.
121    fn is_locked(&self) -> bool {
122        self.lock.is_some()
123    }
124
125    /// Removes every key whose `expires_at` is in the past.
126    ///
127    /// Called from the TTL sweeper task every second.
128    fn evict_expired(&mut self, now: Instant) {
129        self.keys.retain(|_fp, k| match k.expires_at {
130            Some(t) => t > now,
131            None => true,
132        });
133    }
134}
135
136// ── Session impl ──────────────────────────────────────────────────────────────
137
138/// Per-connection session. Cloned by `ssh-agent-lib`'s accept loop; all
139/// state lives behind the shared `Arc<Mutex<KeyStore>>`.
140#[derive(Debug, Clone)]
141struct AgentSession {
142    store: Arc<Mutex<KeyStore>>,
143    default_ttl: Option<Duration>,
144}
145
146#[async_trait]
147impl Session for AgentSession {
148    async fn request_identities(&mut self) -> Result<Vec<Identity>, AgentError> {
149        let store = self.store.lock().await;
150        if store.is_locked() {
151            return Err(AgentError::Failure);
152        }
153        Ok(store
154            .keys
155            .values()
156            .map(|s| Identity {
157                pubkey: s.key.public_key().key_data().clone(),
158                comment: s.key.comment().to_owned(),
159            })
160            .collect())
161    }
162
163    async fn add_identity(&mut self, req: AddIdentity) -> Result<(), AgentError> {
164        self.add_inner(req, Vec::new()).await
165    }
166
167    async fn add_identity_constrained(
168        &mut self,
169        req: AddIdentityConstrained,
170    ) -> Result<(), AgentError> {
171        self.add_inner(req.identity, req.constraints).await
172    }
173
174    async fn remove_identity(&mut self, req: RemoveIdentity) -> Result<(), AgentError> {
175        let mut store = self.store.lock().await;
176        if store.is_locked() {
177            return Err(AgentError::Failure);
178        }
179        let pk = ssh_key::PublicKey::from(req.pubkey);
180        let fp = pk.fingerprint(HashAlg::Sha256).to_string();
181        if store.keys.remove(&fp).is_none() {
182            return Err(AgentError::Failure);
183        }
184        Ok(())
185    }
186
187    async fn remove_all_identities(&mut self) -> Result<(), AgentError> {
188        let mut store = self.store.lock().await;
189        if store.is_locked() {
190            return Err(AgentError::Failure);
191        }
192        store.keys.clear();
193        Ok(())
194    }
195
196    async fn sign(&mut self, req: SignRequest) -> Result<Signature, AgentError> {
197        let pk = ssh_key::PublicKey::from(req.pubkey.clone());
198        let fp = pk.fingerprint(HashAlg::Sha256).to_string();
199
200        // Take a clone of the StoredKey and release the store lock
201        // before any potentially slow work (askpass round-trip, the
202        // signing itself). Holding the lock across an askpass wait
203        // would block every other client for up to ASKPASS_TIMEOUT.
204        // `PrivateKey`'s inner `KeypairData` zeroizes on drop, so the
205        // extra copy is safe — just two zeroed-at-end values instead
206        // of one.
207        let stored = {
208            let store = self.store.lock().await;
209            if store.is_locked() {
210                return Err(AgentError::Failure);
211            }
212            store.keys.get(&fp).ok_or(AgentError::Failure)?.clone()
213        };
214
215        if stored.confirm {
216            let prompt = format!("Allow use of SSH key {fp} ({})?", stored.key.comment());
217            if !super::askpass::confirm(&prompt).await {
218                // askpass::confirm already logs the specific reason.
219                return Err(AgentError::Failure);
220            }
221            // Re-check the key is still present — it may have expired
222            // via the TTL sweeper or been removed by another client
223            // while the user was deciding.
224            let store = self.store.lock().await;
225            if !store.keys.contains_key(&fp) {
226                return Err(AgentError::Failure);
227            }
228        }
229
230        sign_with_key(&stored.key, &req.data, req.flags).map_err(|e| {
231            log::warn!("gitway-agent: sign failed for {fp}: {e}");
232            AgentError::Failure
233        })
234    }
235
236    async fn lock(&mut self, key: String) -> Result<(), AgentError> {
237        let mut store = self.store.lock().await;
238        if store.is_locked() {
239            return Err(AgentError::Failure);
240        }
241        store.lock = Some(key);
242        Ok(())
243    }
244
245    async fn unlock(&mut self, key: String) -> Result<(), AgentError> {
246        let mut store = self.store.lock().await;
247        match &store.lock {
248            Some(current) if *current == key => {
249                store.lock = None;
250                Ok(())
251            }
252            _ => Err(AgentError::Failure),
253        }
254    }
255}
256
257impl AgentSession {
258    async fn add_inner(
259        &mut self,
260        req: AddIdentity,
261        constraints: Vec<KeyConstraint>,
262    ) -> Result<(), AgentError> {
263        let mut store = self.store.lock().await;
264        if store.is_locked() {
265            return Err(AgentError::Failure);
266        }
267
268        let key = match req.credential {
269            Credential::Key { privkey, comment } => {
270                let mut pk = PrivateKey::try_from(privkey).map_err(|e| {
271                    log::warn!("gitway-agent: add failed to parse credential: {e}");
272                    AgentError::Failure
273                })?;
274                pk.set_comment(&comment);
275                pk
276            }
277            Credential::Cert { .. } => {
278                // Certificate-bound keys would need cert validation we
279                // have not wired up. Reject politely.
280                return Err(AgentError::Failure);
281            }
282        };
283
284        let mut expires_at = self.default_ttl.map(|d| Instant::now() + d);
285        let mut confirm = false;
286        for c in constraints {
287            match c {
288                KeyConstraint::Lifetime(secs) => {
289                    expires_at = Some(Instant::now() + Duration::from_secs(u64::from(secs)));
290                }
291                KeyConstraint::Confirm => {
292                    confirm = true;
293                }
294                KeyConstraint::Extension(_) => {
295                    // Silently ignore unknown extension-based constraints.
296                }
297            }
298        }
299
300        let fp = key.public_key().fingerprint(HashAlg::Sha256).to_string();
301        store.keys.insert(
302            fp,
303            StoredKey {
304                key,
305                expires_at,
306                confirm,
307            },
308        );
309        Ok(())
310    }
311}
312
313// ── Signing ───────────────────────────────────────────────────────────────────
314
315/// Signs `data` with `key`, honoring the agent protocol `flags` field.
316///
317/// Ed25519 and the three ECDSA curves (NIST P-256, P-384, P-521) use
318/// `ssh-key`'s built-in `Signer<Signature>` impl, which picks the right
319/// inner crypto crate (`ed25519-dalek`, `p256`, `p384`, `p521`) and
320/// emits the SSH wire format the agent protocol expects.
321///
322/// RSA is routed directly through `rsa::pkcs1v15::SigningKey<ShaN>`
323/// because the agent protocol's `SignRequest.flags` chooses between
324/// SHA-256 and SHA-512 at call time, and the generic `Signer` impl on
325/// `PrivateKey` has no way to see that flag. `flags & RSA_SHA2_512`
326/// selects `rsa-sha2-512` and `flags & RSA_SHA2_256` selects
327/// `rsa-sha2-256`. The legacy SHA-1 `ssh-rsa` algorithm is rejected:
328/// OpenSSH 8.2+ (Jan 2020) always requests SHA-2 for RSA, GitHub
329/// dropped SHA-1 support in 2022, and there is no modern client that
330/// needs the downgrade.
331fn sign_with_key(key: &PrivateKey, data: &[u8], flags: u32) -> Result<Signature, GitwayError> {
332    use signature::Signer;
333    match key.algorithm() {
334        Algorithm::Ed25519 | Algorithm::Ecdsa { .. } => key
335            .try_sign(data)
336            .map_err(|e| GitwayError::signing(format!("sign failed: {e}"))),
337        Algorithm::Rsa { .. } => sign_rsa(key, data, flags),
338        other => Err(GitwayError::invalid_config(format!(
339            "agent daemon sign: algorithm {} not supported",
340            other.as_str()
341        ))),
342    }
343}
344
345/// RSA sign path, driven by the agent protocol's `flags`.
346///
347/// `ssh-key` 0.6.7's own `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey`
348/// has a bug where it uses `p` twice instead of `[p, q]`, so we have
349/// to reconstruct the `rsa::RsaPrivateKey` ourselves from the raw
350/// components. The fix is present in `ssh-key` 0.7; until then this
351/// inline build stays.
352fn sign_rsa(key: &PrivateKey, data: &[u8], flags: u32) -> Result<Signature, GitwayError> {
353    use rsa::pkcs1v15::SigningKey;
354    use rsa::signature::{RandomizedSigner, SignatureEncoding};
355    use sha2::{Sha256, Sha512};
356
357    let KeypairData::Rsa(rsa_keypair) = key.key_data() else {
358        return Err(GitwayError::signing(
359            "sign_rsa invoked on non-RSA key".to_string(),
360        ));
361    };
362
363    let private = rsa::RsaPrivateKey::from_components(
364        rsa::BigUint::try_from(&rsa_keypair.public.n)
365            .map_err(|e| GitwayError::signing(format!("rsa modulus parse: {e}")))?,
366        rsa::BigUint::try_from(&rsa_keypair.public.e)
367            .map_err(|e| GitwayError::signing(format!("rsa exponent parse: {e}")))?,
368        rsa::BigUint::try_from(&rsa_keypair.private.d)
369            .map_err(|e| GitwayError::signing(format!("rsa private exponent parse: {e}")))?,
370        vec![
371            rsa::BigUint::try_from(&rsa_keypair.private.p)
372                .map_err(|e| GitwayError::signing(format!("rsa prime p parse: {e}")))?,
373            rsa::BigUint::try_from(&rsa_keypair.private.q)
374                .map_err(|e| GitwayError::signing(format!("rsa prime q parse: {e}")))?,
375        ],
376    )
377    .map_err(|e| GitwayError::signing(format!("rsa from_components: {e}")))?;
378
379    let mut rng = rand_core::OsRng;
380    let (algorithm, sig_bytes) = if flags & proto_signature::RSA_SHA2_512 != 0 {
381        let signing = SigningKey::<Sha512>::new(private);
382        let sig = signing.sign_with_rng(&mut rng, data);
383        (
384            Algorithm::Rsa {
385                hash: Some(HashAlg::Sha512),
386            },
387            sig.to_bytes().into_vec(),
388        )
389    } else if flags & proto_signature::RSA_SHA2_256 != 0 {
390        let signing = SigningKey::<Sha256>::new(private);
391        let sig = signing.sign_with_rng(&mut rng, data);
392        (
393            Algorithm::Rsa {
394                hash: Some(HashAlg::Sha256),
395            },
396            sig.to_bytes().into_vec(),
397        )
398    } else {
399        return Err(GitwayError::signing(
400            "rsa sign: SHA-1 `ssh-rsa` requested but not supported — \
401             client must request rsa-sha2-256 or rsa-sha2-512 \
402             (OpenSSH has done so since 8.2)"
403                .to_string(),
404        ));
405    };
406
407    Signature::new(algorithm, sig_bytes)
408        .map_err(|e| GitwayError::signing(format!("ssh signature encode: {e}")))
409}
410
411// ── Public entry point ────────────────────────────────────────────────────────
412
413/// Runs the agent daemon until a termination signal arrives.
414///
415/// # Errors
416///
417/// Returns [`GitwayError`] if the socket cannot be bound, the pid file
418/// cannot be written, or the accept loop returns with an error.
419///
420/// # Termination
421///
422/// On `SIGTERM` or `SIGINT` the function returns `Ok(())` after unlinking
423/// the socket and removing the pid file. Every stored key is zeroed as
424/// the `KeyStore` drops.
425pub async fn run(config: AgentDaemonConfig) -> Result<(), GitwayError> {
426    write_pid_file(config.pid_file.as_deref())?;
427
428    let store = Arc::new(Mutex::new(KeyStore::new()));
429    let session = AgentSession {
430        store: Arc::clone(&store),
431        default_ttl: config.default_ttl,
432    };
433
434    // Background task: evict expired keys once per second.
435    let evict_store = Arc::clone(&store);
436    let evict_handle = tokio::spawn(async move {
437        let mut ticker = tokio::time::interval(Duration::from_secs(1));
438        loop {
439            ticker.tick().await;
440            let now = Instant::now();
441            let mut s = evict_store.lock().await;
442            s.evict_expired(now);
443        }
444    });
445
446    // Platform-split accept loop. On Unix we bind a `UnixListener` and
447    // race its `listen()` against SIGTERM/SIGINT. On Windows we bind a
448    // `NamedPipeListener` and race against Ctrl+C — there is no
449    // SIGTERM equivalent for console apps, and services get their own
450    // shutdown notification via the service control manager (out of
451    // scope for v0.6.x).
452    accept_until_shutdown(&config.socket_path, session).await;
453
454    evict_handle.abort();
455    cleanup(&config);
456    Ok(())
457}
458
459#[cfg(unix)]
460async fn accept_until_shutdown(socket_path: &Path, session: AgentSession) {
461    let listener = match bind_unix_socket(socket_path) {
462        Ok(l) => l,
463        Err(e) => {
464            log::warn!("gitway-agent: bind failed: {e}");
465            return;
466        }
467    };
468
469    let ctrl_c = tokio::signal::ctrl_c();
470    let sigterm = async {
471        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
472        term.recv().await;
473        Ok::<_, std::io::Error>(())
474    };
475    let accept_loop = listen(listener, session);
476
477    tokio::select! {
478        res = accept_loop => {
479            if let Err(e) = res {
480                log::warn!("gitway-agent: accept loop ended with error: {e}");
481            }
482        }
483        _ = ctrl_c => {
484            log::info!("gitway-agent: SIGINT received, shutting down");
485        }
486        _ = sigterm => {
487            log::info!("gitway-agent: SIGTERM received, shutting down");
488        }
489    }
490}
491
492#[cfg(windows)]
493async fn accept_until_shutdown(socket_path: &Path, session: AgentSession) {
494    use ssh_agent_lib::agent::NamedPipeListener;
495
496    let listener = match NamedPipeListener::bind(socket_path.as_os_str()) {
497        Ok(l) => l,
498        Err(e) => {
499            log::warn!(
500                "gitway-agent: named-pipe bind failed for {}: {e}",
501                socket_path.display()
502            );
503            return;
504        }
505    };
506
507    let ctrl_c = tokio::signal::ctrl_c();
508    let accept_loop = listen(listener, session);
509
510    tokio::select! {
511        res = accept_loop => {
512            if let Err(e) = res {
513                log::warn!("gitway-agent: accept loop ended with error: {e}");
514            }
515        }
516        _ = ctrl_c => {
517            log::info!("gitway-agent: Ctrl+C received, shutting down");
518        }
519    }
520}
521
522// ── Socket / pid plumbing ─────────────────────────────────────────────────────
523
524#[cfg(unix)]
525fn bind_unix_socket(path: &Path) -> Result<tokio::net::UnixListener, GitwayError> {
526    use std::os::unix::fs::PermissionsExt as _;
527    // Remove any stale socket file so bind() doesn't fail with "address in use".
528    let _ = std::fs::remove_file(path);
529    let listener = tokio::net::UnixListener::bind(path)?;
530    // Restrict the socket inode to the owning user only.
531    let mut perms = std::fs::metadata(path)?.permissions();
532    perms.set_mode(SOCKET_MODE);
533    std::fs::set_permissions(path, perms)?;
534    Ok(listener)
535}
536
537fn write_pid_file(path: Option<&Path>) -> Result<(), GitwayError> {
538    let Some(p) = path else {
539        return Ok(());
540    };
541    let pid = std::process::id();
542    std::fs::write(p, format!("{pid}\n"))?;
543    Ok(())
544}
545
546fn cleanup(config: &AgentDaemonConfig) {
547    // On Windows, named pipes are refcounted kernel objects rather
548    // than filesystem entries — once the server handle drops, the pipe
549    // is gone. `remove_file` would fail harmlessly on `\\.\pipe\...`,
550    // so skip it.
551    #[cfg(unix)]
552    {
553        let _ = std::fs::remove_file(&config.socket_path);
554    }
555    if let Some(ref p) = config.pid_file {
556        let _ = std::fs::remove_file(p);
557    }
558}
559
560/// Unix-mode bits for the agent socket (owner read/write only). On
561/// Windows the equivalent access control comes from the default pipe
562/// ACL, which restricts access to the creating user's SID.
563#[cfg(unix)]
564const SOCKET_MODE: u32 = 0o600;
565
566// ── Tests ─────────────────────────────────────────────────────────────────────
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::keygen::{generate, KeyType};
572
573    #[test]
574    fn evict_expired_drops_past_keys_only() {
575        let key_now = generate(KeyType::Ed25519, None, "now").unwrap();
576        let key_later = generate(KeyType::Ed25519, None, "later").unwrap();
577        let fp_now = key_now
578            .public_key()
579            .fingerprint(HashAlg::Sha256)
580            .to_string();
581        let fp_later = key_later
582            .public_key()
583            .fingerprint(HashAlg::Sha256)
584            .to_string();
585        let mut store = KeyStore::new();
586        // Use checked_sub so clippy's unchecked-duration-subtraction lint
587        // is happy even though we know the test runs after process start.
588        let past = Instant::now()
589            .checked_sub(Duration::from_secs(1))
590            .expect("test runs after process start; Instant never underflows");
591        store.keys.insert(
592            fp_now.clone(),
593            StoredKey {
594                key: key_now,
595                expires_at: Some(past),
596                confirm: false,
597            },
598        );
599        store.keys.insert(
600            fp_later.clone(),
601            StoredKey {
602                key: key_later,
603                expires_at: Some(Instant::now() + Duration::from_secs(60)),
604                confirm: false,
605            },
606        );
607        store.evict_expired(Instant::now());
608        assert!(!store.keys.contains_key(&fp_now));
609        assert!(store.keys.contains_key(&fp_later));
610    }
611
612    #[test]
613    fn sign_ed25519_roundtrip_verifies_with_public_key() {
614        use ed25519_dalek::Verifier as _;
615        let key = generate(KeyType::Ed25519, None, "roundtrip").unwrap();
616        let data = b"hello gitway agent";
617        let sig = sign_with_key(&key, data, 0).unwrap();
618        assert_eq!(sig.algorithm(), ssh_key::Algorithm::Ed25519);
619
620        // Cross-verify via ed25519-dalek directly.
621        let ssh_key::public::KeyData::Ed25519(pk) = key.public_key().key_data() else {
622            unreachable!()
623        };
624        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pk.0).unwrap();
625        let bytes: [u8; 64] = sig.as_bytes().try_into().unwrap();
626        let dalek_sig = ed25519_dalek::Signature::from_bytes(&bytes);
627        verifying.verify(data, &dalek_sig).unwrap();
628    }
629
630    /// Verifies that `sign_with_key` produces a signature that
631    /// `ssh_key::PublicKey::verify` (which delegates to the underlying
632    /// `RustCrypto` verifier for this algorithm) accepts. Parameterised
633    /// over `KeyType` so one helper covers Ed25519 + the three ECDSA
634    /// curves.
635    fn sign_verify_roundtrip(kind: KeyType) {
636        use signature::Verifier;
637        let key = generate(kind, None, "roundtrip").unwrap();
638        let data = b"hello gitway agent";
639        let sig = sign_with_key(&key, data, 0).unwrap();
640        key.public_key()
641            .key_data()
642            .verify(data, &sig)
643            .unwrap_or_else(|e| panic!("verify failed for {kind:?}: {e}"));
644    }
645
646    #[test]
647    fn sign_ecdsa_p256_roundtrip() {
648        sign_verify_roundtrip(KeyType::EcdsaP256);
649    }
650
651    #[test]
652    fn sign_ecdsa_p384_roundtrip() {
653        sign_verify_roundtrip(KeyType::EcdsaP384);
654    }
655
656    #[test]
657    fn sign_ecdsa_p521_roundtrip() {
658        sign_verify_roundtrip(KeyType::EcdsaP521);
659    }
660
661    /// RSA roundtrip for both SHA-2 flag variants, since the agent
662    /// protocol picks the digest at call time rather than baking it
663    /// into the key.
664    fn sign_rsa_roundtrip(flags: u32, expected_hash: HashAlg) {
665        use signature::Verifier;
666        let key = generate(KeyType::Rsa, Some(2048), "rsa-roundtrip").unwrap();
667        let data = b"hello gitway agent";
668        let sig = sign_with_key(&key, data, flags).unwrap();
669        assert_eq!(
670            sig.algorithm(),
671            Algorithm::Rsa {
672                hash: Some(expected_hash)
673            }
674        );
675        key.public_key()
676            .key_data()
677            .verify(data, &sig)
678            .expect("rsa roundtrip verify");
679    }
680
681    #[test]
682    fn sign_rsa_sha256_roundtrip() {
683        sign_rsa_roundtrip(proto_signature::RSA_SHA2_256, HashAlg::Sha256);
684    }
685
686    #[test]
687    fn sign_rsa_sha512_roundtrip() {
688        sign_rsa_roundtrip(proto_signature::RSA_SHA2_512, HashAlg::Sha512);
689    }
690
691    /// Flag precedence: `RSA_SHA2_512` wins when both flags are set.
692    /// Matches the explicit order in OpenSSH's `ssh_agent_sign` and the
693    /// ssh-agent-lib examples.
694    #[test]
695    fn sign_rsa_prefers_sha512_when_both_flags_set() {
696        sign_rsa_roundtrip(
697            proto_signature::RSA_SHA2_256 | proto_signature::RSA_SHA2_512,
698            HashAlg::Sha512,
699        );
700    }
701
702    /// Flags=0 means the client asked for the legacy SHA-1 `ssh-rsa`
703    /// wire algorithm. We reject it instead of downgrading silently.
704    #[test]
705    fn sign_rsa_rejects_sha1_request() {
706        let key = generate(KeyType::Rsa, Some(2048), "rsa-sha1").unwrap();
707        let err = sign_with_key(&key, b"data", 0).unwrap_err();
708        assert!(err.to_string().contains("SHA-1"), "unexpected error: {err}");
709    }
710}