1use crate::AuthError;
9use crate::backend::{AuthInteraction, IpcUnlockStrategy, UnlockOutcome, VaultAuthBackend};
10use crate::ssh_types::{EnrollmentBlob, SshKeyType};
11use core_crypto::SecureBytes;
12use core_types::AuthFactorId;
13use core_types::TrustProfileName;
14use ssh_agent_client_rs::Identity;
15use std::collections::BTreeMap;
16use std::path::Path;
17use zeroize::Zeroize;
18
19fn identity_fingerprint(id: &Identity<'_>) -> String {
21 match id {
22 Identity::PublicKey(cow) => cow.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
23 Identity::Certificate(cow) => cow
24 .public_key()
25 .fingerprint(ssh_key::HashAlg::Sha256)
26 .to_string(),
27 }
28}
29
30fn identity_algorithm(id: &Identity<'_>) -> ssh_key::Algorithm {
32 match id {
33 Identity::PublicKey(cow) => cow.algorithm(),
34 Identity::Certificate(cow) => cow.algorithm(),
35 }
36}
37
38fn connect_agent() -> Option<ssh_agent_client_rs::Client> {
48 if let Some(sock_path) = std::env::var_os("SSH_AUTH_SOCK") {
50 let path = std::path::Path::new(&sock_path);
51 if let Ok(client) = ssh_agent_client_rs::Client::connect(path) {
52 return Some(client);
53 }
54 }
55
56 if let Some(home) = std::env::var_os("HOME") {
61 let fallback = std::path::Path::new(&home).join(".ssh/agent.sock");
62 if let Ok(client) = ssh_agent_client_rs::Client::connect(&fallback) {
63 return Some(client);
64 }
65 }
66
67 None
68}
69
70pub struct SshAgentBackend;
76
77impl SshAgentBackend {
78 #[must_use]
79 pub fn new() -> Self {
80 Self
81 }
82
83 fn enrollment_path(config_dir: &Path, profile: &TrustProfileName) -> std::path::PathBuf {
85 config_dir
86 .join("vaults")
87 .join(format!("{profile}.ssh-enrollment"))
88 }
89}
90
91impl Default for SshAgentBackend {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97#[async_trait::async_trait]
98#[allow(clippy::unnecessary_literal_bound)]
99impl VaultAuthBackend for SshAgentBackend {
100 fn factor_id(&self) -> AuthFactorId {
101 AuthFactorId::SshAgent
102 }
103
104 fn name(&self) -> &str {
105 "SSH Agent"
106 }
107
108 fn backend_id(&self) -> &str {
109 "ssh-agent"
110 }
111
112 fn is_enrolled(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
113 Self::enrollment_path(config_dir, profile).exists()
114 }
115
116 async fn can_unlock(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
117 if !self.is_enrolled(profile, config_dir) {
118 return false;
119 }
120
121 let blob_path = Self::enrollment_path(config_dir, profile);
122 let Ok(blob_data) = std::fs::read(&blob_path) else {
123 return false;
124 };
125 let Ok(blob) = EnrollmentBlob::deserialize(&blob_data) else {
126 return false;
127 };
128
129 let fingerprint = blob.key_fingerprint.clone();
130 tokio::task::spawn_blocking(move || {
131 let Some(mut agent) = connect_agent() else {
132 return false;
133 };
134 let Ok(identities) = agent.list_all_identities() else {
135 return false;
136 };
137 identities
138 .iter()
139 .any(|id| identity_fingerprint(id) == fingerprint)
140 })
141 .await
142 .unwrap_or(false)
143 }
144
145 fn requires_interaction(&self) -> AuthInteraction {
146 AuthInteraction::None
147 }
148
149 async fn unlock(
150 &self,
151 profile: &TrustProfileName,
152 config_dir: &Path,
153 salt: &[u8],
154 ) -> Result<UnlockOutcome, AuthError> {
155 let blob_path = Self::enrollment_path(config_dir, profile);
157 let blob_data =
158 std::fs::read(&blob_path).map_err(|_| AuthError::NotEnrolled(profile.to_string()))?;
159 let blob = EnrollmentBlob::deserialize(&blob_data)?;
160
161 let profile_str = profile.to_string();
163 let challenge_ctx = format!("pds v2 ssh-challenge {profile_str}");
164 let challenge_bytes: [u8; 32] = blake3::derive_key(&challenge_ctx, salt);
165
166 let fingerprint = blob.key_fingerprint.clone();
171 let challenge = challenge_bytes.to_vec();
172 let sign_result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AuthError> {
173 let mut agent = connect_agent().ok_or_else(|| {
174 AuthError::AgentUnavailable("SSH_AUTH_SOCK not set or agent not running".into())
175 })?;
176
177 let identities = agent.list_all_identities().map_err(|e| {
178 AuthError::AgentProtocolError(format!("failed to list identities: {e}"))
179 })?;
180
181 let identity = identities
182 .into_iter()
183 .find(|id| identity_fingerprint(id) == fingerprint)
184 .ok_or(AuthError::NoEligibleKey)?;
185
186 let signature = agent
187 .sign(identity, &challenge)
188 .map_err(|e| AuthError::AgentProtocolError(format!("sign request failed: {e}")))?;
189
190 Ok(signature.as_bytes().to_vec())
193 })
194 .await
195 .map_err(|e| AuthError::AgentUnavailable(format!("spawn_blocking failed: {e}")))??;
196
197 let kek_ctx = format!("pds v2 ssh-vault-kek {profile_str}");
201 let mut kek_bytes: [u8; 32] = blake3::derive_key(&kek_ctx, &sign_result);
202 let mut sig_bytes = sign_result;
203 sig_bytes.zeroize();
204
205 let encryption_key = core_crypto::EncryptionKey::from_bytes(&kek_bytes)
207 .map_err(|_| AuthError::UnwrapFailed)?;
208 kek_bytes.zeroize();
209
210 let master_key_bytes = encryption_key
211 .decrypt(&blob.nonce, &blob.ciphertext)
212 .map_err(|_| AuthError::UnwrapFailed)?;
213
214 let mut audit_metadata = BTreeMap::new();
216 audit_metadata.insert("backend".into(), "ssh-agent".into());
217 audit_metadata.insert("ssh_fingerprint".into(), blob.key_fingerprint.clone());
218 audit_metadata.insert("key_type".into(), blob.key_type.wire_name().into());
219
220 Ok(UnlockOutcome {
221 master_key: master_key_bytes,
222 audit_metadata,
223 ipc_strategy: IpcUnlockStrategy::DirectMasterKey,
224 factor_id: AuthFactorId::SshAgent,
225 })
226 }
227
228 async fn enroll(
229 &self,
230 profile: &TrustProfileName,
231 master_key: &SecureBytes,
232 config_dir: &Path,
233 salt: &[u8],
234 selected_key_index: Option<usize>,
235 ) -> Result<(), AuthError> {
236 let profile_str = profile.to_string();
238 let challenge_ctx = format!("pds v2 ssh-challenge {profile_str}");
239 let challenge: [u8; 32] = blake3::derive_key(&challenge_ctx, salt);
240 let challenge_vec = challenge.to_vec();
241
242 let kek_ctx = format!("pds v2 ssh-vault-kek {profile_str}");
245 let (fingerprint, key_type, mut kek_bytes) = tokio::task::spawn_blocking(
246 move || -> Result<(String, SshKeyType, [u8; 32]), AuthError> {
247 let mut agent = connect_agent().ok_or_else(|| {
248 AuthError::AgentUnavailable("SSH_AUTH_SOCK not set or agent not running".into())
249 })?;
250
251 let identities = agent
252 .list_all_identities()
253 .map_err(|e| AuthError::AgentProtocolError(format!("list identities: {e}")))?;
254
255 let eligible: Vec<_> = identities
256 .into_iter()
257 .filter(|id| SshKeyType::from_algorithm(&identity_algorithm(id)).is_ok())
258 .collect();
259
260 if eligible.is_empty() {
261 return Err(AuthError::NoEligibleKey);
262 }
263
264 let idx = selected_key_index.ok_or(AuthError::NoEligibleKey)?;
265 if idx >= eligible.len() {
266 return Err(AuthError::NoEligibleKey);
267 }
268 let identity = eligible.into_iter().nth(idx).unwrap();
269 let fp = identity_fingerprint(&identity);
270 let algo = identity_algorithm(&identity);
271 let kt = SshKeyType::from_algorithm(&algo)?;
272
273 let sig = agent
274 .sign(identity, &challenge_vec)
275 .map_err(|e| AuthError::AgentProtocolError(format!("sign: {e}")))?;
276
277 let mut sig_bytes = sig.as_bytes().to_vec();
279 let kek: [u8; 32] = blake3::derive_key(&kek_ctx, &sig_bytes);
280 sig_bytes.zeroize();
281
282 Ok((fp, kt, kek))
283 },
284 )
285 .await
286 .map_err(|e| AuthError::AgentUnavailable(format!("spawn_blocking: {e}")))??;
287
288 let encryption_key = core_crypto::EncryptionKey::from_bytes(&kek_bytes)
290 .map_err(|_| AuthError::UnwrapFailed)?;
291 kek_bytes.zeroize();
292
293 let mut nonce_bytes = [0u8; 12];
294 getrandom::getrandom(&mut nonce_bytes)
295 .map_err(|e| AuthError::Io(std::io::Error::other(e)))?;
296
297 let ciphertext = encryption_key
298 .encrypt(&nonce_bytes, master_key.as_bytes())
299 .map_err(|_| AuthError::UnwrapFailed)?;
300
301 let blob = EnrollmentBlob {
303 version: crate::ENROLLMENT_VERSION,
304 key_fingerprint: fingerprint.clone(),
305 key_type,
306 nonce: nonce_bytes,
307 ciphertext,
308 };
309
310 let blob_path = Self::enrollment_path(config_dir, profile);
311 let vaults_dir = config_dir.join("vaults");
312 std::fs::create_dir_all(&vaults_dir)?;
313
314 let tmp_path = blob_path.with_extension("ssh-enrollment.tmp");
315 std::fs::write(&tmp_path, blob.serialize())?;
316
317 #[cfg(unix)]
320 {
321 use std::os::unix::fs::PermissionsExt;
322 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
323 }
324
325 std::fs::rename(&tmp_path, &blob_path)?;
326
327 tracing::info!(
328 profile = %profile,
329 fingerprint = %fingerprint,
330 key_type = %key_type.wire_name(),
331 "SSH enrollment created"
332 );
333
334 Ok(())
335 }
336
337 async fn revoke(&self, profile: &TrustProfileName, config_dir: &Path) -> Result<(), AuthError> {
338 let path = Self::enrollment_path(config_dir, profile);
339 if path.exists() {
340 let fingerprint = std::fs::read(&path)
342 .ok()
343 .and_then(|data| EnrollmentBlob::deserialize(&data).ok())
344 .map_or_else(|| "<unreadable>".into(), |blob| blob.key_fingerprint);
345
346 #[allow(clippy::cast_possible_truncation)]
348 let file_len = std::fs::metadata(&path)
349 .map(|m| m.len() as usize)
350 .unwrap_or(256);
351 let zeros = vec![0u8; file_len];
352 let _ = std::fs::write(&path, &zeros);
353 std::fs::remove_file(&path)?;
354
355 tracing::info!(
356 profile = %profile,
357 fingerprint = %fingerprint,
358 "SSH enrollment revoked"
359 );
360 }
361 Ok(())
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn test_profile() -> TrustProfileName {
370 TrustProfileName::try_from("test-profile").unwrap()
371 }
372
373 #[test]
374 fn is_enrolled_checks_file() {
375 let dir = tempfile::tempdir().unwrap();
376 let backend = SshAgentBackend::new();
377 let profile = test_profile();
378
379 assert!(!backend.is_enrolled(&profile, dir.path()));
380
381 let vaults = dir.path().join("vaults");
382 std::fs::create_dir_all(&vaults).unwrap();
383 std::fs::write(vaults.join("test-profile.ssh-enrollment"), b"blob").unwrap();
384
385 assert!(backend.is_enrolled(&profile, dir.path()));
386 }
387
388 #[tokio::test]
389 async fn can_unlock_false_when_not_enrolled() {
390 let backend = SshAgentBackend::new();
391 let profile = test_profile();
392 assert!(
393 !backend
394 .can_unlock(&profile, std::path::Path::new("/tmp"))
395 .await
396 );
397 }
398
399 #[tokio::test]
400 async fn unlock_fails_no_enrollment() {
401 let dir = tempfile::tempdir().unwrap();
402 let backend = SshAgentBackend::new();
403 let profile = test_profile();
404 let result = backend.unlock(&profile, dir.path(), &[0; 16]).await;
405 assert!(matches!(result, Err(AuthError::NotEnrolled(_))));
406 }
407
408 #[tokio::test]
409 async fn unlock_fails_invalid_blob() {
410 let dir = tempfile::tempdir().unwrap();
411 let backend = SshAgentBackend::new();
412 let profile = test_profile();
413
414 let vaults = dir.path().join("vaults");
415 std::fs::create_dir_all(&vaults).unwrap();
416 std::fs::write(vaults.join("test-profile.ssh-enrollment"), b"corrupt").unwrap();
417
418 let result = backend.unlock(&profile, dir.path(), &[0; 16]).await;
419 assert!(matches!(result, Err(AuthError::InvalidBlob(_))));
420 }
421
422 #[tokio::test]
423 async fn revoke_removes_blob() {
424 let dir = tempfile::tempdir().unwrap();
425 let backend = SshAgentBackend::new();
426 let profile = test_profile();
427
428 let vaults = dir.path().join("vaults");
429 std::fs::create_dir_all(&vaults).unwrap();
430 let blob_path = vaults.join("test-profile.ssh-enrollment");
431 std::fs::write(&blob_path, b"blob").unwrap();
432 assert!(blob_path.exists());
433
434 backend.revoke(&profile, dir.path()).await.unwrap();
435 assert!(!blob_path.exists());
436 }
437
438 #[tokio::test]
439 async fn revoke_noop_when_no_blob() {
440 let dir = tempfile::tempdir().unwrap();
441 let backend = SshAgentBackend::new();
442 let profile = test_profile();
443 backend.revoke(&profile, dir.path()).await.unwrap();
444 }
445
446 #[test]
447 fn requires_no_interaction() {
448 let backend = SshAgentBackend::new();
449 assert_eq!(backend.requires_interaction(), AuthInteraction::None);
450 }
451
452 #[test]
453 fn backend_id_is_ssh_agent() {
454 let backend = SshAgentBackend::new();
455 assert_eq!(backend.backend_id(), "ssh-agent");
456 }
457
458 #[test]
462 fn connect_to_nonexistent_socket_returns_error() {
463 let bad_path = std::path::Path::new("/tmp/nonexistent-ssh-agent-test.sock");
464 let result = ssh_agent_client_rs::Client::connect(bad_path);
465 assert!(result.is_err());
466 }
467
468 #[test]
469 fn challenge_is_deterministic() {
470 let salt = [0xAA; 16];
471 let ctx = "pds v2 ssh-challenge test-profile";
472 let c1: [u8; 32] = blake3::derive_key(ctx, &salt);
473 let c2: [u8; 32] = blake3::derive_key(ctx, &salt);
474 assert_eq!(c1, c2);
475 }
476
477 #[test]
478 fn different_profiles_produce_different_challenges() {
479 let salt = [0xAA; 16];
480 let c1: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge profile-a", &salt);
481 let c2: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge profile-b", &salt);
482 assert_ne!(c1, c2);
483 }
484
485 #[test]
486 fn different_salts_produce_different_challenges() {
487 let c1: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge test", &[0xAA; 16]);
488 let c2: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge test", &[0xBB; 16]);
489 assert_ne!(c1, c2);
490 }
491
492 #[test]
493 fn kek_derivation_is_deterministic() {
494 let sig = [0x42u8; 64];
495 let ctx = "pds v2 ssh-vault-kek test-profile";
496 let k1: [u8; 32] = blake3::derive_key(ctx, &sig);
497 let k2: [u8; 32] = blake3::derive_key(ctx, &sig);
498 assert_eq!(k1, k2);
499 }
500
501 #[test]
502 fn enrollment_blob_crypto_round_trip() {
503 let master_key_data = vec![0x42u8; 32];
504
505 let sig_bytes = [0xDE; 64]; let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
508 let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
509 let nonce = [0x11u8; 12];
510 let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
511
512 let blob = EnrollmentBlob {
513 version: crate::ENROLLMENT_VERSION,
514 key_fingerprint: "SHA256:test".into(),
515 key_type: SshKeyType::Ed25519,
516 nonce,
517 ciphertext: ciphertext.clone(),
518 };
519
520 let data = blob.serialize();
522 let parsed = EnrollmentBlob::deserialize(&data).unwrap();
523
524 let kek2: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
526 let enc_key2 = core_crypto::EncryptionKey::from_bytes(&kek2).unwrap();
527 let unwrapped = enc_key2.decrypt(&parsed.nonce, &parsed.ciphertext).unwrap();
528
529 assert_eq!(unwrapped.as_bytes(), &master_key_data);
530 }
531
532 #[test]
533 fn tampered_blob_fails_unwrap() {
534 let master_key_data = vec![0x42u8; 32];
535 let sig_bytes = [0xDE; 64];
536 let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
537 let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
538 let nonce = [0x11u8; 12];
539 let mut ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
540
541 ciphertext[0] ^= 0x01;
543
544 let result = enc_key.decrypt(&nonce, &ciphertext);
545 assert!(result.is_err());
546 }
547
548 #[test]
549 fn wrong_kek_fails_unwrap() {
550 let master_key_data = vec![0x42u8; 32];
551 let sig_bytes = [0xDE; 64];
552 let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
553 let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
554 let nonce = [0x11u8; 12];
555 let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
556
557 let wrong_sig = [0xAB; 64];
559 let wrong_kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &wrong_sig);
560 let wrong_enc_key = core_crypto::EncryptionKey::from_bytes(&wrong_kek).unwrap();
561 let result = wrong_enc_key.decrypt(&nonce, &ciphertext);
562 assert!(result.is_err());
563 }
564
565 #[test]
572 fn full_enrollment_unlock_round_trip() {
573 let dir = tempfile::tempdir().unwrap();
574 let profile = test_profile();
575 let salt = [0xBB; 16];
576 let master_key_data = vec![0x42u8; 32];
577 let simulated_sig = [0xAB; 64];
578
579 let challenge_ctx = format!("pds v2 ssh-challenge {profile}");
583 let challenge: [u8; 32] = blake3::derive_key(&challenge_ctx, &salt);
584
585 let kek_ctx = format!("pds v2 ssh-vault-kek {profile}");
587 let kek: [u8; 32] = blake3::derive_key(&kek_ctx, &simulated_sig);
588
589 let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
591 let nonce = [0x33u8; 12];
592 let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
593
594 let blob = EnrollmentBlob {
596 version: crate::ENROLLMENT_VERSION,
597 key_fingerprint: "SHA256:test-round-trip".into(),
598 key_type: SshKeyType::Ed25519,
599 nonce,
600 ciphertext,
601 };
602 let vaults_dir = dir.path().join("vaults");
603 std::fs::create_dir_all(&vaults_dir).unwrap();
604 let blob_path = vaults_dir.join(format!("{profile}.ssh-enrollment"));
605 std::fs::write(&blob_path, blob.serialize()).unwrap();
606
607 let blob_data = std::fs::read(&blob_path).unwrap();
611 let parsed = EnrollmentBlob::deserialize(&blob_data).unwrap();
612 assert_eq!(parsed.key_fingerprint, "SHA256:test-round-trip");
613 assert_eq!(parsed.key_type, SshKeyType::Ed25519);
614
615 let challenge2: [u8; 32] = blake3::derive_key(&challenge_ctx, &salt);
617 assert_eq!(challenge, challenge2);
618
619 let kek2: [u8; 32] = blake3::derive_key(&kek_ctx, &simulated_sig);
621 assert_eq!(kek, kek2);
622 let enc_key2 = core_crypto::EncryptionKey::from_bytes(&kek2).unwrap();
623 let unwrapped = enc_key2.decrypt(&parsed.nonce, &parsed.ciphertext).unwrap();
624
625 assert_eq!(unwrapped.as_bytes(), &master_key_data);
627 }
628}