1use 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#[derive(Debug, Clone)]
77pub struct AgentDaemonConfig {
78 pub socket_path: PathBuf,
80 pub pid_file: Option<PathBuf>,
83 pub default_ttl: Option<Duration>,
86}
87
88#[derive(Debug, Clone)]
96struct StoredKey {
97 key: PrivateKey,
98 expires_at: Option<Instant>,
99 confirm: bool,
100}
101
102#[derive(Debug, Default)]
104struct KeyStore {
105 keys: HashMap<String, StoredKey>,
108 lock: Option<String>,
113}
114
115impl KeyStore {
116 fn new() -> Self {
117 Self::default()
118 }
119
120 fn is_locked(&self) -> bool {
122 self.lock.is_some()
123 }
124
125 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#[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 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 return Err(AgentError::Failure);
220 }
221 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 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 }
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
313fn 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
345fn 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
411pub 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 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 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#[cfg(unix)]
525fn bind_unix_socket(path: &Path) -> Result<tokio::net::UnixListener, GitwayError> {
526 use std::os::unix::fs::PermissionsExt as _;
527 let _ = std::fs::remove_file(path);
529 let listener = tokio::net::UnixListener::bind(path)?;
530 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 #[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#[cfg(unix)]
564const SOCKET_MODE: u32 = 0o600;
565
566#[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 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 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 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 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 #[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 #[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}