1use crate::agent::AgentCore;
9use crate::agent::AgentHandle;
10#[cfg(unix)]
11use crate::agent::PeerAuthorizedAgent;
12use crate::crypto::provider_bridge;
13use crate::crypto::signer::extract_seed_from_key_bytes;
14use crate::crypto::signer::{decrypt_keypair, encrypt_keypair};
15use crate::error::AgentError;
16use crate::signing::{PassphraseProvider, PrefilledPassphraseProvider};
17use crate::storage::keychain::{KeyAlias, KeyRole, KeyStorage};
18use log::{debug, error, info, warn};
19#[cfg(target_os = "macos")]
20use p256::pkcs8::DecodePrivateKey;
21#[cfg(target_os = "macos")]
22use pkcs8::PrivateKeyInfo;
23#[cfg(target_os = "macos")]
24use pkcs8::der::Decode;
25#[cfg(target_os = "macos")]
26use pkcs8::der::asn1::OctetString;
27use serde::Serialize;
28#[cfg(unix)]
29use ssh_agent_lib;
30#[cfg(unix)]
31use ssh_agent_lib::agent::listen;
32use ssh_key::private::{Ed25519Keypair as SshEdKeypair, KeypairData};
33use ssh_key::{
34 self, LineEnding, PrivateKey as SshPrivateKey, PublicKey as SshPublicKey,
35 public::Ed25519PublicKey as SshEd25519PublicKey,
36};
37#[cfg(unix)]
38use std::io;
39#[cfg(unix)]
40use std::sync::Arc;
41#[cfg(unix)]
42use tokio::net::UnixListener;
43use zeroize::Zeroizing;
44
45#[cfg(target_os = "macos")]
46use std::io::Write;
47
48#[cfg(target_os = "macos")]
49use {
50 std::fs::{self, Permissions},
51 std::os::unix::fs::PermissionsExt,
52 tempfile::Builder as TempFileBuilder,
53};
54
55#[cfg(target_os = "macos")]
56#[derive(Debug)]
57enum SshRegError {
58 Agent(crate::ports::ssh_agent::SshAgentError),
59 Io(std::io::Error),
60 Conversion(String),
61 BadSeedLength(usize),
62}
63
64#[cfg(target_os = "macos")]
65impl std::fmt::Display for SshRegError {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::Agent(e) => write!(f, "ssh agent error: {e}"),
69 Self::Io(e) => write!(f, "I/O error: {e}"),
70 Self::Conversion(s) => write!(f, "key conversion failed: {s}"),
71 Self::BadSeedLength(n) => {
72 write!(f, "invalid PKCS#8 seed length: expected 32 bytes, got {n}")
73 }
74 }
75 }
76}
77
78#[cfg(target_os = "macos")]
80fn compute_ssh_fingerprint(pubkey_bytes: &[u8], curve: auths_crypto::CurveType) -> String {
81 let key_data = match curve {
82 auths_crypto::CurveType::Ed25519 => SshEd25519PublicKey::try_from(pubkey_bytes)
83 .map(ssh_key::public::KeyData::Ed25519)
84 .ok(),
85 auths_crypto::CurveType::P256 => {
86 ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes)
87 .ok()
88 .map(ssh_key::public::KeyData::Ecdsa)
89 }
90 };
91 match key_data {
92 Some(kd) => SshPublicKey::new(kd, "")
93 .fingerprint(Default::default())
94 .to_string(),
95 None => {
96 warn!("Could not build public key for fingerprint computation");
97 "unknown_fingerprint".to_string()
98 }
99 }
100}
101
102#[cfg(target_os = "macos")]
104fn build_ssh_keypair_data(
105 pkcs8_bytes: &[u8],
106 curve: auths_crypto::CurveType,
107) -> Result<KeypairData, SshRegError> {
108 match curve {
109 auths_crypto::CurveType::Ed25519 => {
110 let private_key_info = PrivateKeyInfo::from_der(pkcs8_bytes)
111 .map_err(|e| SshRegError::Conversion(e.to_string()))?;
112 let seed_octet_string = OctetString::from_der(private_key_info.private_key)
113 .map_err(|e| SshRegError::Conversion(e.to_string()))?;
114 let seed_bytes = seed_octet_string.as_bytes();
115 if seed_bytes.len() != 32 {
116 return Err(SshRegError::BadSeedLength(seed_bytes.len()));
117 }
118 #[allow(clippy::expect_used)]
119 let seed_array: [u8; 32] = seed_bytes.try_into().expect("Length checked");
121 Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(&seed_array)))
122 }
123 auths_crypto::CurveType::P256 => {
124 use p256::elliptic_curve::sec1::ToEncodedPoint;
125 use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
126
127 let secret = p256::SecretKey::from_pkcs8_der(pkcs8_bytes)
128 .map_err(|e| SshRegError::Conversion(format!("P-256 PKCS#8 parse failed: {e}")))?;
129 let public = secret.public_key();
130 Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
131 public: public.to_encoded_point(false),
132 private: EcdsaPrivateKey::from(secret),
133 }))
134 }
135 }
136}
137
138#[derive(Serialize, Debug, Clone)]
142pub struct KeyLoadStatus {
143 pub alias: KeyAlias,
145 pub loaded: bool,
147 pub error: Option<String>,
149}
150
151#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
153pub enum RegistrationOutcome {
154 Added,
156 AlreadyExists,
158 AgentNotFound,
160 CommandFailed,
162 UnsupportedKeyType,
164 ConversionFailed,
166 IoError,
168 InternalError,
170}
171
172#[derive(Serialize, Debug, Clone)]
174pub struct KeyRegistrationStatus {
175 pub fingerprint: String,
177 pub status: RegistrationOutcome,
179 pub message: Option<String>,
181}
182
183pub fn clear_agent_keys_with_handle(handle: &AgentHandle) -> Result<(), AgentError> {
201 info!("Clearing all keys from agent handle.");
202 let mut agent_guard = handle.lock()?;
203 agent_guard.clear_keys();
204 debug!("Agent keys cleared.");
205 Ok(())
206}
207
208pub fn load_keys_into_agent_with_handle(
224 handle: &AgentHandle,
225 aliases: Vec<String>,
226 passphrase_provider: &dyn PassphraseProvider,
227 keychain: &(dyn KeyStorage + Send + Sync),
228) -> Result<Vec<KeyLoadStatus>, AgentError> {
229 info!(
230 "Attempting to load keys into agent handle for aliases: {:?}",
231 aliases
232 );
233 if aliases.is_empty() {
234 warn!("load_keys_into_agent_with_handle called with empty alias list. Clearing agent.");
235 clear_agent_keys_with_handle(handle)?;
236 return Ok(vec![]);
237 }
238
239 let mut load_statuses = Vec::new();
240 let mut temp_unlocked_core = AgentCore::default();
241
242 for alias in aliases {
243 debug!("Processing alias for agent load: {}", alias);
244 let key_alias = KeyAlias::new_unchecked(&alias);
245 let mut status = KeyLoadStatus {
246 alias: key_alias.clone(),
247 loaded: false,
248 error: None,
249 };
250
251 let load_result = || -> Result<Zeroizing<Vec<u8>>, AgentError> {
252 if keychain.is_hardware_backend() {
253 return Err(AgentError::HardwareKeyNotExportable {
254 operation: "agent key load".to_string(),
255 });
256 }
257 let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
258 let prompt = format!(
259 "Enter passphrase to unlock key '{}' for agent session:",
260 key_alias
261 );
262 let passphrase = passphrase_provider.get_passphrase(&prompt)?;
263 let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, &passphrase)?;
264 let _ = extract_seed_from_key_bytes(&pkcs8_bytes).map_err(|e| {
265 AgentError::KeyDeserializationError(format!(
266 "Failed to parse key for alias '{}' after decryption: {}",
267 key_alias, e
268 ))
269 })?;
270 Ok(pkcs8_bytes)
271 }();
272
273 match load_result {
274 Ok(pkcs8_bytes) => {
275 info!("Successfully unlocked key for alias '{}'", key_alias);
276 match temp_unlocked_core.register_key(pkcs8_bytes) {
277 Ok(()) => status.loaded = true,
278 Err(e) => {
279 error!(
280 "Failed to register key '{}' in agent core state after successful unlock/parse: {}",
281 key_alias, e
282 );
283 status.error = Some(format!(
284 "Internal error: Failed to register key in agent core state: {}",
285 e
286 ));
287 }
288 }
289 }
290 Err(e) => {
291 error!(
292 "Failed to load/decrypt key for alias '{}': {}",
293 key_alias, e
294 );
295 match e {
296 AgentError::IncorrectPassphrase => {
297 status.error = Some("Incorrect passphrase".to_string())
298 }
299 AgentError::KeyNotFound => status.error = Some("Key not found".to_string()),
300 AgentError::UserInputCancelled => {
301 status.error = Some("Operation cancelled by user".to_string())
302 }
303 AgentError::KeyDeserializationError(_) => {
304 status.error = Some(format!("Failed to parse key after decryption: {}", e))
305 }
306 _ => status.error = Some(e.to_string()),
307 }
308 }
309 }
310 load_statuses.push(status);
311 }
312
313 let mut agent_guard = handle.lock()?;
315 info!(
316 "Replacing agent core with {} unlocked keys ({} aliases attempted).",
317 temp_unlocked_core.key_count(),
318 load_statuses.len()
319 );
320 *agent_guard = temp_unlocked_core;
321
322 Ok(load_statuses)
323}
324
325pub fn rotate_key(
348 alias: &str,
349 new_passphrase: &str,
350 keychain: &(dyn KeyStorage + Send + Sync),
351) -> Result<(), AgentError> {
352 info!(
353 "[API] Attempting secure storage key rotation for local alias: {}",
354 alias
355 );
356 if alias.trim().is_empty() {
357 return Err(AgentError::InvalidInput(
358 "Alias cannot be empty".to_string(),
359 ));
360 }
361 if new_passphrase.is_empty() {
362 return Err(AgentError::InvalidInput(
363 "New passphrase cannot be empty".to_string(),
364 ));
365 }
366
367 let key_alias = KeyAlias::new_unchecked(alias);
369 let existing_did = keychain.get_identity_for_alias(&key_alias)?;
370 info!(
371 "Found existing key for alias '{}', associated with Controller DID '{}'. Proceeding with rotation.",
372 alias, existing_did
373 );
374
375 let (seed, pubkey) = provider_bridge::generate_ed25519_keypair_sync()
377 .map_err(|e| AgentError::CryptoError(format!("Failed to generate new keypair: {}", e)))?;
378 let new_pkcs8_bytes = auths_crypto::build_ed25519_pkcs8_v2(seed.as_bytes(), &pubkey);
380 debug!("Generated new keypair via CryptoProvider.");
381
382 let encrypted_new_key = encrypt_keypair(&new_pkcs8_bytes, new_passphrase)?;
384 debug!("Encrypted new keypair with provided passphrase.");
385
386 keychain.store_key(
389 &key_alias,
390 &existing_did,
391 KeyRole::Primary,
392 &encrypted_new_key,
393 )?;
394 info!(
395 "Successfully overwrote secure storage for alias '{}' with new encrypted key.",
396 alias
397 );
398
399 warn!(
400 "Secure storage key rotated for alias '{}'. This did NOT update any Git identity representation. The running agent may still hold the old decrypted key. Consider reloading keys into the agent.",
401 alias
402 );
403 Ok(())
404}
405
406pub fn agent_sign_with_handle(
421 handle: &AgentHandle,
422 pubkey: &[u8],
423 data: &[u8],
424) -> Result<Vec<u8>, AgentError> {
425 debug!(
426 "Agent sign request for pubkey starting with: {:x?}...",
427 &pubkey[..core::cmp::min(pubkey.len(), 8)]
428 );
429
430 handle.sign(pubkey, data)
432}
433
434pub fn export_key_openssh_pem(
446 alias: &str,
447 passphrase: &str,
448 keychain: &(dyn KeyStorage + Send + Sync),
449) -> Result<Zeroizing<String>, AgentError> {
450 info!("Exporting PEM for local alias: {}", alias);
451 if alias.trim().is_empty() {
452 return Err(AgentError::InvalidInput(
453 "Alias cannot be empty".to_string(),
454 ));
455 }
456 if keychain.is_hardware_backend() {
457 return Err(AgentError::HardwareKeyNotExportable {
458 operation: "OpenSSH private key export".to_string(),
459 });
460 }
461 let key_alias = KeyAlias::new_unchecked(alias);
463 let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
464
465 let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, passphrase)?;
467
468 let parsed = auths_crypto::parse_key_material(&pkcs8_bytes[..]).map_err(|e| {
470 AgentError::KeyDeserializationError(format!(
471 "Failed to parse key material for alias '{}': {}",
472 alias, e
473 ))
474 })?;
475
476 let keypair_data = build_openssh_keypair_data(&parsed).map_err(|e| {
477 AgentError::CryptoError(format!(
478 "Failed to build SSH keypair for alias '{}': {}",
479 alias, e
480 ))
481 })?;
482
483 let ssh_private_key = SshPrivateKey::new(keypair_data, "").map_err(|e| {
484 AgentError::CryptoError(format!(
485 "Failed to create ssh_key::PrivateKey for alias '{}': {}",
486 alias, e
487 ))
488 })?;
489
490 let pem = ssh_private_key.to_openssh(LineEnding::LF).map_err(|e| {
491 AgentError::CryptoError(format!(
492 "Failed to encode OpenSSH PEM for alias '{}': {}",
493 alias, e
494 ))
495 })?;
496
497 debug!("Successfully generated PEM for alias '{}'", alias);
498 Ok(pem)
499}
500
501fn build_openssh_keypair_data(parsed: &auths_crypto::ParsedKey) -> Result<KeypairData, String> {
503 match parsed.seed.curve() {
504 auths_crypto::CurveType::Ed25519 => Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(
505 parsed.seed.as_bytes(),
506 ))),
507 auths_crypto::CurveType::P256 => {
508 use p256::elliptic_curve::sec1::ToEncodedPoint;
509 use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
510
511 let secret = p256::SecretKey::from_slice(parsed.seed.as_bytes())
512 .map_err(|e| format!("P-256 secret key parse: {e}"))?;
513 let public = secret.public_key();
514 Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
515 public: public.to_encoded_point(false),
516 private: EcdsaPrivateKey::from(secret),
517 }))
518 }
519 }
520}
521
522pub fn export_key_openssh_pub(
535 alias: &str,
536 passphrase: &str,
537 keychain: &(dyn KeyStorage + Send + Sync),
538) -> Result<String, AgentError> {
539 info!("Exporting OpenSSH public key for local alias: {}", alias);
540 if alias.trim().is_empty() {
541 return Err(AgentError::InvalidInput(
542 "Alias cannot be empty".to_string(),
543 ));
544 }
545 let key_alias = KeyAlias::new_unchecked(alias);
547 let passphrase_provider = PrefilledPassphraseProvider::new(passphrase);
548 let (pubkey_bytes, curve) = crate::storage::keychain::extract_public_key_bytes(
549 keychain,
550 &key_alias,
551 &passphrase_provider,
552 )?;
553 let key_data = match curve {
554 auths_crypto::CurveType::Ed25519 => {
555 let pk = SshEd25519PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|e| {
556 AgentError::CryptoError(format!(
557 "Failed to create Ed25519PublicKey from bytes: {}",
558 e
559 ))
560 })?;
561 ssh_key::public::KeyData::Ed25519(pk)
562 }
563 auths_crypto::CurveType::P256 => {
564 let pk = ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes.as_slice())
565 .map_err(|e| {
566 AgentError::CryptoError(format!(
567 "Failed to create EcdsaPublicKey from bytes: {}",
568 e
569 ))
570 })?;
571 ssh_key::public::KeyData::Ecdsa(pk)
572 }
573 };
574
575 let ssh_pub_key = SshPublicKey::new(key_data, ""); let pubkey_base = ssh_pub_key.to_openssh().map_err(|e| {
580 AgentError::CryptoError(format!("Failed to format OpenSSH pubkey base: {}", e))
582 })?;
583
584 let formatted_pubkey = format!("{} {}", pubkey_base, alias);
586
587 debug!(
588 "Successfully generated OpenSSH public key string for alias '{}'",
589 alias
590 );
591 Ok(formatted_pubkey)
592}
593
594pub fn get_agent_key_count_with_handle(handle: &AgentHandle) -> Result<usize, AgentError> {
602 handle.key_count()
603}
604
605#[cfg(target_os = "macos")]
624#[allow(clippy::disallowed_methods)]
625#[allow(clippy::disallowed_types)]
627pub fn register_keys_with_macos_agent_with_handle(
628 handle: &AgentHandle,
629 ssh_agent_socket: Option<&std::path::Path>,
630 ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
631) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
632 info!("Attempting to register keys from agent handle with system ssh-agent...");
633 if ssh_agent_socket.is_none() {
634 warn!("SSH_AUTH_SOCK not configured. System ssh-agent may not be running or configured.");
635 }
636
637 let keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)> = {
638 let agent_guard = handle.lock()?;
639 agent_guard
640 .keys
641 .iter()
642 .filter_map(|(pubkey, stored)| {
643 let typed_seed = match stored.curve {
644 auths_crypto::CurveType::Ed25519 => {
645 auths_crypto::TypedSeed::Ed25519(*stored.seed.as_bytes())
646 }
647 auths_crypto::CurveType::P256 => {
648 auths_crypto::TypedSeed::P256(*stored.seed.as_bytes())
649 }
650 };
651 let signer = auths_crypto::TypedSignerKey::from_seed(typed_seed).ok()?;
652 let pkcs8 = signer.to_pkcs8().ok()?;
653 Some((
654 pubkey.clone(),
655 stored.curve,
656 Zeroizing::new(pkcs8.as_ref().to_vec()),
657 ))
658 })
659 .collect()
660 };
661
662 register_keys_with_macos_agent_internal(keys_to_register, ssh_agent)
663}
664
665#[cfg(not(target_os = "macos"))]
667pub fn register_keys_with_macos_agent_with_handle(
668 _handle: &AgentHandle,
669 _ssh_agent_socket: Option<&std::path::Path>,
670 _ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
671) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
672 info!("Not on macOS, skipping system ssh-agent registration.");
673 Ok(vec![])
674}
675
676#[cfg(target_os = "macos")]
681#[allow(clippy::too_many_lines)]
682fn register_keys_with_macos_agent_internal(
683 keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)>,
684 ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
685) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
686 use crate::ports::ssh_agent::SshAgentError;
687
688 if keys_to_register.is_empty() {
689 info!("No keys to register with system agent.");
690 return Ok(vec![]);
691 }
692 info!(
693 "Found {} keys to attempt registration with system agent.",
694 keys_to_register.len()
695 );
696
697 let mut results = Vec::with_capacity(keys_to_register.len());
698
699 for (pubkey_bytes, curve, pkcs8_bytes_zeroizing) in keys_to_register.into_iter() {
700 let fingerprint_str = compute_ssh_fingerprint(&pubkey_bytes, curve);
701
702 let mut status = KeyRegistrationStatus {
703 fingerprint: fingerprint_str.clone(),
704 status: RegistrationOutcome::InternalError,
705 message: None,
706 };
707
708 let result: Result<(), SshRegError> = (|| {
709 let pkcs8_bytes = pkcs8_bytes_zeroizing.as_ref();
710 let keypair_data = build_ssh_keypair_data(pkcs8_bytes, curve)?;
711 let ssh_private_key = SshPrivateKey::new(keypair_data, "")
712 .map_err(|e| SshRegError::Conversion(e.to_string()))?;
713 let pem_zeroizing = ssh_private_key
714 .to_openssh(LineEnding::LF)
715 .map_err(|e| SshRegError::Conversion(e.to_string()))?;
716 let pem_string = pem_zeroizing.to_string();
717
718 let mut temp_file_guard = TempFileBuilder::new()
719 .prefix("auths-key-")
720 .suffix(".pem")
721 .rand_bytes(5)
722 .tempfile()
723 .map_err(SshRegError::Io)?;
724 if let Err(e) =
725 fs::set_permissions(temp_file_guard.path(), Permissions::from_mode(0o600))
726 {
727 warn!(
728 "Failed to set 600 permissions on temp file {:?}: {}. Continuing...",
729 temp_file_guard.path(),
730 e
731 );
732 }
733 temp_file_guard
734 .write_all(pem_string.as_bytes())
735 .map_err(SshRegError::Io)?;
736 temp_file_guard.flush().map_err(SshRegError::Io)?;
737 let temp_file_path = temp_file_guard.path().to_path_buf();
738
739 debug!(
740 "Attempting ssh-add for temporary key file: {:?}",
741 temp_file_path
742 );
743 ssh_agent
744 .register_key(&temp_file_path)
745 .map_err(SshRegError::Agent)?;
746 debug!("ssh-add finished for {:?}", temp_file_path);
747 Ok(())
748 })();
749
750 match result {
751 Ok(()) => {
752 info!(
753 "ssh-add successful for {}: Identity added.",
754 fingerprint_str
755 );
756 status.status = RegistrationOutcome::Added;
757 status.message = Some("Identity added via ssh-agent port".to_string());
758 }
759 Err(e) => {
760 match &e {
761 SshRegError::Agent(SshAgentError::NotAvailable(_)) => {
762 status.status = RegistrationOutcome::AgentNotFound;
763 }
764 SshRegError::Agent(SshAgentError::CommandFailed(_)) => {
765 status.status = RegistrationOutcome::CommandFailed;
766 }
767 SshRegError::Agent(SshAgentError::IoError(_)) | SshRegError::Io(_) => {
768 status.status = RegistrationOutcome::IoError;
769 }
770 SshRegError::Conversion(_) | SshRegError::BadSeedLength(_) => {
771 status.status = RegistrationOutcome::ConversionFailed;
772 }
773 }
774 error!(
775 "Error during registration process for {}: {:?}",
776 fingerprint_str, e
777 );
778 status.message = Some(format!("Registration error: {}", e));
779 }
780 }
781 results.push(status);
782 }
783
784 info!(
785 "Finished attempting system agent registration for {} keys.",
786 results.len()
787 );
788 Ok(results)
789}
790
791#[cfg(unix)]
807fn harden_socket_dir(dir: &std::path::Path) -> std::io::Result<()> {
808 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
809
810 match std::fs::symlink_metadata(dir) {
811 Ok(meta) if meta.file_type().is_dir() => {
812 let mode = meta.permissions().mode() & 0o777;
813 if mode & 0o077 != 0 {
814 return Err(io::Error::new(
815 io::ErrorKind::PermissionDenied,
816 format!(
817 "socket directory {dir:?} is group/other-accessible (mode {mode:o}); refusing"
818 ),
819 ));
820 }
821 if meta.uid() != current_euid() {
822 return Err(io::Error::new(
823 io::ErrorKind::PermissionDenied,
824 format!("socket directory {dir:?} is not owned by this user; refusing"),
825 ));
826 }
827 Ok(())
828 }
829 Ok(_) => Err(io::Error::new(
830 io::ErrorKind::AlreadyExists,
831 format!("socket directory path {dir:?} exists but is not a directory; refusing"),
832 )),
833 Err(e) if e.kind() == io::ErrorKind::NotFound => {
834 std::fs::create_dir_all(dir)?;
835 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
836 }
837 Err(e) => Err(e),
838 }
839}
840
841#[cfg(unix)]
857fn harden_socket_file(socket_path: &std::path::Path) -> std::io::Result<()> {
858 use std::os::unix::fs::PermissionsExt as _;
859 std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))
860}
861
862#[cfg(unix)]
874fn socket_dir_to_harden(socket_path: &std::path::Path) -> Result<&std::path::Path, AgentError> {
875 match socket_path.parent() {
876 Some(parent) if !parent.as_os_str().is_empty() => Ok(parent),
877 _ => Err(AgentError::IO(io::Error::new(
878 io::ErrorKind::InvalidInput,
879 "agent socket path must include a directory that can be restricted to the owner",
880 ))),
881 }
882}
883
884#[cfg(unix)]
887fn current_euid() -> u32 {
888 unsafe { libc::geteuid() }
890}
891
892#[cfg(unix)]
895fn idle_monitor_interval(
896 idle_timeout: std::time::Duration,
897 max_unlock_ttl: std::time::Duration,
898) -> Option<std::time::Duration> {
899 use std::time::Duration;
900 let shortest = [idle_timeout, max_unlock_ttl]
901 .into_iter()
902 .filter(|d| !d.is_zero())
903 .min()?;
904 Some((shortest / 4).clamp(Duration::from_secs(1), Duration::from_secs(60)))
905}
906
907#[cfg(unix)]
921async fn run_idle_monitor(handle: Arc<AgentHandle>, interval: std::time::Duration) {
922 loop {
923 tokio::time::sleep(interval).await;
924 if !handle.is_running() {
925 break;
926 }
927 if let Err(e) = handle.check_idle_timeout() {
928 warn!("Idle timeout check failed: {e}");
929 }
930 }
931}
932
933#[cfg(unix)]
948#[allow(clippy::disallowed_methods)] pub async fn start_agent_listener_with_handle(
950 handle: Arc<AgentHandle>,
951 authorizer: Arc<dyn crate::agent::SignAuthorizer>,
952) -> Result<(), AgentError> {
953 let socket_path = handle.socket_path();
954 info!("Attempting to start agent listener at {:?}", socket_path);
955
956 let socket_dir = socket_dir_to_harden(socket_path)?;
958 if let Err(e) = harden_socket_dir(socket_dir) {
959 error!(
960 "Failed to prepare owner-only socket directory {:?}: {}",
961 socket_dir, e
962 );
963 return Err(AgentError::IO(e));
964 }
965
966 match std::fs::remove_file(socket_path) {
968 Ok(()) => info!("Removed existing socket file at {:?}", socket_path),
969 Err(e) if e.kind() == io::ErrorKind::NotFound => {
970 debug!(
971 "No existing socket file found at {:?}, proceeding.",
972 socket_path
973 );
974 }
975 Err(e) => {
976 warn!(
977 "Failed to remove existing socket file at {:?}: {}. Binding might fail.",
978 socket_path, e
979 );
980 }
981 }
982
983 let listener = UnixListener::bind(socket_path).map_err(|e| {
985 error!("Failed to bind listener socket at {:?}: {}", socket_path, e);
986 AgentError::IO(e)
987 })?;
988
989 if let Err(e) = harden_socket_file(socket_path) {
991 error!(
992 "Failed to restrict agent socket {:?} to owner-only: {}",
993 socket_path, e
994 );
995 return Err(AgentError::IO(e));
996 }
997
998 let actual_path = socket_path
1000 .canonicalize()
1001 .unwrap_or_else(|_| socket_path.to_path_buf());
1002 info!(
1003 "🚀 Agent listener started successfully at {:?}",
1004 actual_path
1005 );
1006 info!(" Set SSH_AUTH_SOCK={:?} to use this agent.", actual_path);
1007
1008 handle.set_running(true);
1010
1011 if let Some(interval) = idle_monitor_interval(handle.idle_timeout(), handle.max_unlock_ttl()) {
1013 tokio::spawn(run_idle_monitor(handle.clone(), interval));
1014 }
1015
1016 let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid(), authorizer);
1020
1021 let result = listen(listener, agent).await;
1023
1024 handle.set_running(false);
1026
1027 if let Err(e) = result {
1028 error!("SSH Agent listener failed: {:?}", e);
1029 return Err(AgentError::IO(io::Error::other(format!(
1030 "SSH Agent listener failed: {}",
1031 e
1032 ))));
1033 }
1034
1035 warn!("Agent listener loop exited unexpectedly without error.");
1036 Ok(())
1037}
1038
1039#[cfg(unix)]
1054pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentError> {
1055 use std::path::PathBuf;
1056 let handle = Arc::new(AgentHandle::new(PathBuf::from(&socket_path_str)));
1057 start_agent_listener_with_handle(handle, Arc::new(crate::agent::AllowAllSigning)).await
1058}
1059
1060#[cfg(all(test, unix))]
1061mod hardening_tests {
1062 use super::{
1063 AgentHandle, harden_socket_dir, harden_socket_file, idle_monitor_interval,
1064 run_idle_monitor, socket_dir_to_harden,
1065 };
1066 use std::os::unix::fs::PermissionsExt;
1067 use std::path::PathBuf;
1068 use std::sync::Arc;
1069 use std::time::Duration;
1070 use tempfile::TempDir;
1071
1072 fn mode_of(path: &std::path::Path) -> u32 {
1073 match std::fs::metadata(path) {
1074 Ok(meta) => meta.permissions().mode() & 0o777,
1075 Err(e) => panic!("metadata({path:?}): {e}"),
1076 }
1077 }
1078
1079 #[test]
1080 fn socket_dir_is_owner_only_after_harden() {
1081 let tmp = TempDir::new().unwrap();
1082 let dir = tmp.path().join("auths-agent");
1083 harden_socket_dir(&dir).expect("harden dir");
1084 assert!(dir.is_dir(), "directory must be created");
1085 assert_eq!(
1086 mode_of(&dir),
1087 0o700,
1088 "agent socket directory must be owner-only (0o700)"
1089 );
1090 }
1091
1092 #[test]
1093 fn socket_file_is_owner_only_after_harden() {
1094 let tmp = TempDir::new().unwrap();
1095 let sock = tmp.path().join("agent.sock");
1096 let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
1097 harden_socket_file(&sock).expect("harden socket");
1098 assert_eq!(
1099 mode_of(&sock),
1100 0o600,
1101 "agent socket must be owner-only (0o600) so no other process can connect"
1102 );
1103 }
1104
1105 #[test]
1106 fn refuses_preexisting_group_accessible_socket_dir() {
1107 let tmp = TempDir::new().unwrap();
1108 let dir = tmp.path().join("loose");
1109 std::fs::create_dir(&dir).unwrap();
1110 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1111 assert!(harden_socket_dir(&dir).is_err());
1113 assert_eq!(
1114 mode_of(&dir),
1115 0o755,
1116 "must not silently chmod a directory it does not own"
1117 );
1118 }
1119
1120 #[test]
1121 fn accepts_preexisting_owner_only_socket_dir() {
1122 let tmp = TempDir::new().unwrap();
1123 let dir = tmp.path().join("tight");
1124 std::fs::create_dir(&dir).unwrap();
1125 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1126 assert!(harden_socket_dir(&dir).is_ok());
1127 }
1128
1129 #[test]
1130 fn socket_with_no_restrictable_directory_is_rejected() {
1131 assert!(socket_dir_to_harden(std::path::Path::new("agent.sock")).is_err());
1134 assert!(socket_dir_to_harden(std::path::Path::new("")).is_err());
1135 let dir = socket_dir_to_harden(std::path::Path::new("/home/u/.auths/agent.sock"))
1136 .expect("an absolute socket path has a directory to harden");
1137 assert_eq!(dir, std::path::Path::new("/home/u/.auths"));
1138 }
1139
1140 #[test]
1141 fn idle_monitor_disabled_when_timeout_is_zero() {
1142 assert!(idle_monitor_interval(Duration::ZERO, Duration::ZERO).is_none());
1143 }
1144
1145 #[test]
1146 fn idle_monitor_interval_is_bounded() {
1147 let interval = idle_monitor_interval(
1148 Duration::from_secs(30 * 60),
1149 Duration::from_secs(8 * 60 * 60),
1150 )
1151 .expect("some interval");
1152 assert!(interval >= Duration::from_secs(1));
1153 assert!(interval <= Duration::from_secs(60));
1154 }
1155
1156 #[tokio::test]
1157 async fn idle_monitor_locks_idle_agent() {
1158 let handle = Arc::new(AgentHandle::with_timeout(
1159 PathBuf::from("/tmp/idle-monitor.sock"),
1160 Duration::from_millis(80),
1161 ));
1162 handle.set_running(true);
1163 assert!(!handle.is_agent_locked());
1164
1165 tokio::spawn(run_idle_monitor(handle.clone(), Duration::from_millis(20)));
1166 tokio::time::sleep(Duration::from_millis(300)).await;
1167
1168 assert!(
1169 handle.is_agent_locked(),
1170 "an agent left idle past its timeout must auto-lock"
1171 );
1172 }
1173}