1use 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#[derive(Debug, Clone)]
64pub struct AgentDaemonConfig {
65 pub socket_path: PathBuf,
67 pub pid_file: Option<PathBuf>,
70 pub default_ttl: Option<Duration>,
73}
74
75#[derive(Debug, Clone)]
83struct StoredKey {
84 key: PrivateKey,
85 expires_at: Option<Instant>,
86 confirm: bool,
87}
88
89#[derive(Debug, Default)]
91struct KeyStore {
92 keys: HashMap<String, StoredKey>,
95 lock: Option<String>,
100}
101
102impl KeyStore {
103 fn new() -> Self {
104 Self::default()
105 }
106
107 fn is_locked(&self) -> bool {
109 self.lock.is_some()
110 }
111
112 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#[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 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 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 }
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
286fn 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
318pub 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 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 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
385fn bind_unix_socket(path: &Path) -> Result<UnixListener, GitwayError> {
388 use std::os::unix::fs::PermissionsExt as _;
389 let _ = std::fs::remove_file(path);
391 let listener = UnixListener::bind(path)?;
392 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
415const SOCKET_MODE: u32 = 0o600;
417
418#[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 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 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}