git_sshripped_ssh_identity/
lib.rs1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::collections::HashSet;
6use std::io::IsTerminal;
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::time::Duration;
10
11use age::Decryptor;
12use age::Identity;
13use age::secrecy::SecretString;
14use age::ssh::Identity as SshIdentity;
15use anyhow::{Context, Result};
16use git_sshripped_ssh_identity_models::{IdentityDescriptor, IdentitySource};
17use wait_timeout::ChildExt;
18
19#[derive(Clone, Copy)]
20struct TerminalCallbacks;
21
22impl age::Callbacks for TerminalCallbacks {
23 fn display_message(&self, message: &str) {
24 eprintln!("{message}");
25 }
26
27 fn confirm(&self, _message: &str, _yes_string: &str, _no_string: Option<&str>) -> Option<bool> {
28 None
29 }
30
31 fn request_public_string(&self, _description: &str) -> Option<String> {
32 None
33 }
34
35 fn request_passphrase(&self, description: &str) -> Option<SecretString> {
36 if let Ok(passphrase) = std::env::var("GSC_SSH_KEY_PASSPHRASE")
37 && !passphrase.is_empty()
38 {
39 return Some(SecretString::from(passphrase));
40 }
41
42 rpassword::prompt_password(format!("{description}: "))
43 .ok()
44 .map(SecretString::from)
45 }
46}
47
48const MAX_PASSPHRASE_ATTEMPTS: u32 = 3;
50
51fn decrypt_encrypted_key(
64 enc: &age::ssh::EncryptedKey,
65 path: &std::path::Path,
66) -> Result<SshIdentity> {
67 if let Some(passphrase) = try_macos_keychain_passphrase(path)
69 && let Ok(decrypted) = enc.decrypt(passphrase)
70 {
71 return Ok(SshIdentity::from(decrypted));
72 }
73
74 for attempt in 1..=MAX_PASSPHRASE_ATTEMPTS {
76 let passphrase = if let Ok(p) = std::env::var("GSC_SSH_KEY_PASSPHRASE")
77 && !p.is_empty()
78 {
79 SecretString::from(p)
80 } else {
81 let prompt = format!("Enter passphrase for {}", path.display());
82 let p = rpassword::prompt_password(format!("{prompt}: "))
83 .context("failed to read passphrase from terminal")?;
84 SecretString::from(p)
85 };
86
87 match enc.decrypt(passphrase) {
88 Ok(decrypted) => return Ok(SshIdentity::from(decrypted)),
89 Err(_) if attempt < MAX_PASSPHRASE_ATTEMPTS => {
90 eprintln!("wrong passphrase, try again ({attempt}/{MAX_PASSPHRASE_ATTEMPTS})");
91 }
92 Err(_) => {
93 anyhow::bail!(
94 "failed to decrypt {} after {MAX_PASSPHRASE_ATTEMPTS} attempts",
95 path.display()
96 );
97 }
98 }
99 }
100 unreachable!()
101}
102
103fn try_macos_keychain_passphrase(key_path: &std::path::Path) -> Option<SecretString> {
112 if !cfg!(target_os = "macos") {
113 return None;
114 }
115
116 let user = std::env::var("USER").ok()?;
117 let service = format!("SSH: {}", key_path.display());
118
119 let output = Command::new("security")
120 .args(["find-generic-password", "-a", &user, "-s", &service, "-w"])
121 .stdin(Stdio::null())
122 .stdout(Stdio::piped())
123 .stderr(Stdio::null())
124 .output()
125 .ok()?;
126
127 if !output.status.success() {
128 return None;
129 }
130
131 let passphrase = String::from_utf8(output.stdout).ok()?;
132 let trimmed = passphrase.trim_end_matches('\n');
133 if trimmed.is_empty() {
134 return None;
135 }
136
137 Some(SecretString::from(trimmed.to_string()))
138}
139
140#[must_use]
141fn ssh_dir() -> Option<PathBuf> {
142 dirs::home_dir().map(|h| h.join(".ssh"))
143}
144
145#[must_use]
148pub fn discover_ssh_key_files() -> Vec<(PathBuf, PathBuf)> {
149 let Some(ssh_dir) = ssh_dir() else {
150 return Vec::new();
151 };
152
153 let Ok(entries) = std::fs::read_dir(&ssh_dir) else {
154 return Vec::new();
155 };
156
157 let mut pairs = Vec::new();
158 for entry in entries.flatten() {
159 let pub_path = entry.path();
160 if !pub_path.is_file() {
161 continue;
162 }
163 let Some(name) = pub_path.file_name().and_then(|n| n.to_str()) else {
164 continue;
165 };
166 if !std::path::Path::new(name)
167 .extension()
168 .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
169 {
170 continue;
171 }
172 let private_path = pub_path.with_extension("");
173 if private_path.is_file() {
174 pairs.push((private_path, pub_path));
175 }
176 }
177 pairs
178}
179
180#[must_use]
183pub fn identity_files_from_ssh_config() -> Vec<PathBuf> {
184 let Some(ssh_dir) = ssh_dir() else {
185 return Vec::new();
186 };
187
188 let config_path = ssh_dir.join("config");
189 let Ok(text) = std::fs::read_to_string(&config_path) else {
190 return Vec::new();
191 };
192
193 parse_identity_files_from_config(&text, dirs::home_dir().as_deref())
194}
195
196fn parse_identity_files_from_config(text: &str, home: Option<&std::path::Path>) -> Vec<PathBuf> {
197 text.lines()
198 .map(str::trim)
199 .filter(|line| {
200 !line.is_empty()
201 && !line.starts_with('#')
202 && line.len() > 12
203 && line[..12].eq_ignore_ascii_case("identityfile")
204 })
205 .filter_map(|line| {
206 let value =
207 line[12..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace());
208 if value.is_empty() {
209 return None;
210 }
211 let expanded = if value == "~" {
212 home?.to_path_buf()
213 } else if let Some(rest) = value.strip_prefix("~/") {
214 home?.join(rest)
215 } else {
216 PathBuf::from(value)
217 };
218 Some(expanded)
219 })
220 .collect()
221}
222
223#[must_use]
224pub fn default_public_key_candidates() -> Vec<PathBuf> {
225 let mut candidates = well_known_public_key_paths();
226
227 for private in identity_files_from_ssh_config() {
229 let public = private.with_extension("pub");
230 if !candidates.contains(&public) {
231 candidates.push(public);
232 }
233 }
234
235 for (_, pub_path) in discover_ssh_key_files() {
237 if !candidates.contains(&pub_path) {
238 candidates.push(pub_path);
239 }
240 }
241
242 candidates
243}
244
245#[must_use]
251pub fn well_known_public_key_paths() -> Vec<PathBuf> {
252 let mut candidates = Vec::new();
253 if let Some(ssh_dir) = ssh_dir() {
254 candidates.push(ssh_dir.join("id_ed25519.pub"));
255 candidates.push(ssh_dir.join("id_rsa.pub"));
256 }
257 candidates
258}
259
260#[must_use]
261pub fn default_private_key_candidates() -> Vec<PathBuf> {
262 let mut candidates = Vec::new();
263
264 if let Some(ssh_dir) = ssh_dir() {
266 candidates.push(ssh_dir.join("id_ed25519"));
267 candidates.push(ssh_dir.join("id_rsa"));
268 }
269
270 for path in identity_files_from_ssh_config() {
272 if !candidates.contains(&path) {
273 candidates.push(path);
274 }
275 }
276
277 for (private, _) in discover_ssh_key_files() {
279 if !candidates.contains(&private) {
280 candidates.push(private);
281 }
282 }
283
284 candidates
285}
286
287pub fn agent_public_keys() -> Result<Vec<String>> {
305 let Some(sock) = std::env::var_os("SSH_AUTH_SOCK") else {
306 return Ok(Vec::new());
307 };
308 let sock_path = std::path::Path::new(&sock);
309 let Ok(mut client) = ssh_agent_client_rs::Client::connect(sock_path) else {
310 return Ok(Vec::new());
311 };
312
313 let identities = client
314 .list_all_identities()
315 .context("failed to list SSH agent identities")?;
316
317 let mut keys = Vec::new();
318 for identity in identities {
319 let pubkey: &ssh_key::PublicKey = match &identity {
320 ssh_agent_client_rs::Identity::PublicKey(boxed_cow) => boxed_cow.as_ref(),
321 ssh_agent_client_rs::Identity::Certificate(_) => continue,
322 };
323 keys.push(pubkey.to_openssh().unwrap_or_default());
324 }
325 Ok(keys)
326}
327
328pub fn private_keys_matching_agent() -> Result<Vec<PathBuf>> {
335 let agent_keys = agent_public_keys()?;
336 if agent_keys.is_empty() {
337 return Ok(Vec::new());
338 }
339
340 let mut matches = Vec::new();
341 for public_candidate in default_public_key_candidates() {
342 if !public_candidate.exists() {
343 continue;
344 }
345
346 let public_line = std::fs::read_to_string(&public_candidate).with_context(|| {
347 format!(
348 "failed reading public key candidate {}",
349 public_candidate.display()
350 )
351 })?;
352
353 let pub_trimmed = public_line.trim();
354 let pub_key_data: String = pub_trimmed
357 .split_whitespace()
358 .take(2)
359 .collect::<Vec<_>>()
360 .join(" ");
361
362 let agent_match = agent_keys.iter().any(|agent_line| {
363 let agent_data: String = agent_line
364 .split_whitespace()
365 .take(2)
366 .collect::<Vec<_>>()
367 .join(" ");
368 agent_data == pub_key_data
369 });
370
371 if !agent_match {
372 continue;
373 }
374
375 if let Some(stem) = public_candidate.file_name().and_then(|s| s.to_str())
376 && let Some(private_name) = stem.strip_suffix(".pub")
377 {
378 let private_path = public_candidate
379 .parent()
380 .map_or_else(|| PathBuf::from(private_name), |p| p.join(private_name));
381 if private_path.exists() {
382 matches.push(private_path);
383 }
384 }
385 }
386
387 Ok(matches)
388}
389
390fn parse_helper_key_output(output: &[u8]) -> Result<Option<Vec<u8>>> {
391 if output.len() == 32 {
392 return Ok(Some(output.to_vec()));
393 }
394
395 let text = String::from_utf8(output.to_vec()).context("agent helper output was not utf8")?;
396 let trimmed = text.trim();
397 if trimmed.is_empty() {
398 return Ok(None);
399 }
400
401 if trimmed.len() == 64 {
402 let decoded = hex::decode(trimmed).context("agent helper output was invalid hex")?;
403 if decoded.len() == 32 {
404 return Ok(Some(decoded));
405 }
406 }
407
408 anyhow::bail!("agent helper output must be 32 raw bytes or 64-char hex-encoded key")
409}
410
411pub fn unwrap_repo_key_with_agent_helper(
418 wrapped_files: &[PathBuf],
419 helper_path: &std::path::Path,
420 timeout_ms: u64,
421) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
422 for wrapped in wrapped_files {
423 let mut child = Command::new(helper_path)
424 .arg(wrapped)
425 .stdout(Stdio::piped())
426 .stderr(Stdio::piped())
427 .spawn()
428 .with_context(|| {
429 format!(
430 "failed running agent helper '{}': {}",
431 helper_path.display(),
432 wrapped.display()
433 )
434 })?;
435
436 let timeout = Duration::from_millis(timeout_ms);
437 let status = child
438 .wait_timeout(timeout)
439 .context("failed waiting on agent helper process")?;
440
441 let output = if status.is_some() {
442 child
443 .wait_with_output()
444 .context("failed collecting agent helper output")?
445 } else {
446 let _ = child.kill();
447 let _ = child.wait();
448 anyhow::bail!(
449 "agent helper timed out after {}ms for {}",
450 timeout_ms,
451 wrapped.display()
452 );
453 };
454
455 if !output.status.success() {
456 continue;
457 }
458
459 let Some(key) = parse_helper_key_output(&output.stdout)? else {
460 continue;
461 };
462
463 return Ok(Some((
464 key,
465 IdentityDescriptor {
466 source: IdentitySource::SshAgent,
467 label: format!("{} ({})", helper_path.display(), wrapped.display()),
468 },
469 )));
470 }
471
472 Ok(None)
473}
474
475pub fn detect_identity() -> Result<IdentityDescriptor> {
482 if std::env::var_os("SSH_AUTH_SOCK").is_some() {
483 return Ok(IdentityDescriptor {
484 source: IdentitySource::SshAgent,
485 label: "SSH agent".to_string(),
486 });
487 }
488
489 for candidate in default_public_key_candidates() {
490 if candidate.exists() {
491 return Ok(IdentityDescriptor {
492 source: IdentitySource::IdentityFile,
493 label: candidate.display().to_string(),
494 });
495 }
496 }
497
498 Ok(IdentityDescriptor {
499 source: IdentitySource::IdentityFile,
500 label: "unresolved".to_string(),
501 })
502}
503
504pub fn unwrap_repo_key_from_wrapped_files<S: ::std::hash::BuildHasher>(
511 wrapped_files: &[PathBuf],
512 identity_files: &[PathBuf],
513 interactive_identities: &HashSet<PathBuf, S>,
514) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
515 let mut identities: Vec<(SshIdentity, PathBuf)> = Vec::new();
516 let mut skipped_encrypted: Vec<(age::ssh::EncryptedKey, PathBuf)> = Vec::new();
517
518 for identity_file in identity_files {
519 if !identity_file.exists() {
520 continue;
521 }
522 let content = std::fs::read(identity_file)
523 .with_context(|| format!("failed reading identity file {}", identity_file.display()))?;
524 let filename = Some(identity_file.display().to_string());
525 let identity = SshIdentity::from_buffer(std::io::Cursor::new(&content), filename)
526 .with_context(|| format!("failed parsing identity file {}", identity_file.display()))?;
527 if let SshIdentity::Encrypted(ref enc) = identity {
528 if !interactive_identities.contains(identity_file) {
529 if let Some(passphrase) = try_macos_keychain_passphrase(identity_file)
531 && let Ok(decrypted) = enc.decrypt(passphrase)
532 {
533 identities.push((SshIdentity::from(decrypted), identity_file.clone()));
534 continue;
535 }
536 skipped_encrypted.push((enc.clone(), identity_file.clone()));
538 continue;
539 }
540 let decrypted = decrypt_encrypted_key(enc, identity_file)?;
542 identities.push((decrypted, identity_file.clone()));
543 } else {
544 identities.push((identity, identity_file.clone()));
545 }
546 }
547
548 if let Some(result) = try_decrypt_wrapped_files(wrapped_files, &identities)? {
550 return Ok(Some(result));
551 }
552
553 if !skipped_encrypted.is_empty() && std::io::stdin().is_terminal() {
556 return try_interactive_encrypted_key(wrapped_files, &skipped_encrypted);
557 }
558
559 Ok(None)
560}
561
562fn try_decrypt_wrapped_files(
564 wrapped_files: &[PathBuf],
565 identities: &[(SshIdentity, PathBuf)],
566) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
567 for wrapped in wrapped_files {
568 let wrapped_bytes = std::fs::read(wrapped)
569 .with_context(|| format!("failed reading wrapped key {}", wrapped.display()))?;
570
571 for (identity, path) in identities {
572 let decryptor = Decryptor::new_buffered(std::io::Cursor::new(&wrapped_bytes))
573 .with_context(|| format!("invalid wrapped key format {}", wrapped.display()))?;
574 let decrypt_identity = identity.clone().with_callbacks(TerminalCallbacks);
575 let Ok(mut reader) =
576 decryptor.decrypt(std::iter::once(&decrypt_identity as &dyn Identity))
577 else {
578 continue;
579 };
580
581 let mut key = Vec::new();
582 std::io::Read::read_to_end(&mut reader, &mut key).with_context(|| {
583 format!("failed reading decrypted key from {}", wrapped.display())
584 })?;
585 return Ok(Some((
586 key,
587 IdentityDescriptor {
588 source: IdentitySource::IdentityFile,
589 label: path.display().to_string(),
590 },
591 )));
592 }
593 }
594 Ok(None)
595}
596
597fn try_interactive_encrypted_key(
599 wrapped_files: &[PathBuf],
600 skipped: &[(age::ssh::EncryptedKey, PathBuf)],
601) -> Result<Option<(Vec<u8>, IdentityDescriptor)>> {
602 let selected = if skipped.len() == 1 {
603 eprintln!("Trying encrypted key {}...", skipped[0].1.display());
604 Some(0)
605 } else {
606 prompt_key_selection(skipped)
607 };
608
609 let Some(idx) = selected else {
610 return Ok(None);
611 };
612
613 let (enc, path) = &skipped[idx];
614 let decrypted = decrypt_encrypted_key(enc, path)?;
615 let identities = vec![(decrypted, path.clone())];
616 try_decrypt_wrapped_files(wrapped_files, &identities)
617}
618
619fn prompt_key_selection(keys: &[(age::ssh::EncryptedKey, PathBuf)]) -> Option<usize> {
621 eprintln!("No passwordless unlock method available.");
622 eprintln!("The following encrypted keys were found:");
623 eprintln!();
624 for (i, (_, path)) in keys.iter().enumerate() {
625 eprintln!(" {}) {}", i + 1, path.display());
626 }
627 eprintln!();
628 eprint!("Enter the number of the key to try (or 'q' to cancel): ");
629
630 let mut input = String::new();
631 std::io::stdin().read_line(&mut input).ok()?;
632 let trimmed = input.trim();
633 if trimmed.eq_ignore_ascii_case("q") {
634 return None;
635 }
636 let num: usize = trimmed.parse().ok()?;
637 if num >= 1 && num <= keys.len() {
638 Some(num - 1)
639 } else {
640 None
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use std::path::Path;
648
649 #[test]
650 fn parse_config_extracts_identity_files_with_tilde() {
651 let config = "\
652Host github.com
653 User git
654 IdentityFile ~/.ssh/github
655
656Host *
657 ControlMaster auto
658";
659 let home = Path::new("/home/testuser");
660 let result = parse_identity_files_from_config(config, Some(home));
661 assert_eq!(result, vec![PathBuf::from("/home/testuser/.ssh/github")]);
662 }
663
664 #[test]
665 fn parse_config_extracts_multiple_identity_files() {
666 let config = "\
667Host work
668 IdentityFile ~/.ssh/work_key
669
670Host personal
671 IdentityFile ~/.ssh/personal_key
672
673Host github.com
674 IdentityFile ~/.ssh/github
675";
676 let home = Path::new("/Users/user");
677 let result = parse_identity_files_from_config(config, Some(home));
678 assert_eq!(
679 result,
680 vec![
681 PathBuf::from("/Users/user/.ssh/work_key"),
682 PathBuf::from("/Users/user/.ssh/personal_key"),
683 PathBuf::from("/Users/user/.ssh/github"),
684 ]
685 );
686 }
687
688 #[test]
689 fn parse_config_handles_absolute_paths() {
690 let config = "IdentityFile /opt/keys/deploy_key\n";
691 let home = Path::new("/home/user");
692 let result = parse_identity_files_from_config(config, Some(home));
693 assert_eq!(result, vec![PathBuf::from("/opt/keys/deploy_key")]);
694 }
695
696 #[test]
697 fn parse_config_skips_comments_and_blank_lines() {
698 let config = "\
699# This is a comment
700 # indented comment
701
702Host foo
703 # IdentityFile ~/.ssh/commented_out
704 IdentityFile ~/.ssh/real_key
705";
706 let home = Path::new("/home/user");
707 let result = parse_identity_files_from_config(config, Some(home));
708 assert_eq!(result, vec![PathBuf::from("/home/user/.ssh/real_key")]);
709 }
710
711 #[test]
712 fn parse_config_case_insensitive_directive() {
713 let config =
714 "identityfile ~/.ssh/lower\nIDENTITYFILE ~/.ssh/upper\nIdentityFile ~/.ssh/mixed\n";
715 let home = Path::new("/home/user");
716 let result = parse_identity_files_from_config(config, Some(home));
717 assert_eq!(
718 result,
719 vec![
720 PathBuf::from("/home/user/.ssh/lower"),
721 PathBuf::from("/home/user/.ssh/upper"),
722 PathBuf::from("/home/user/.ssh/mixed"),
723 ]
724 );
725 }
726
727 #[test]
728 fn parse_config_handles_equals_separator() {
729 let config = "IdentityFile=~/.ssh/equals_key\n";
730 let home = Path::new("/home/user");
731 let result = parse_identity_files_from_config(config, Some(home));
732 assert_eq!(result, vec![PathBuf::from("/home/user/.ssh/equals_key")]);
733 }
734
735 #[test]
736 fn parse_config_empty_input() {
737 let result = parse_identity_files_from_config("", Some(Path::new("/home/user")));
738 assert!(result.is_empty());
739 }
740
741 #[test]
742 fn parse_config_no_home_skips_tilde_paths() {
743 let config = "IdentityFile ~/.ssh/key\nIdentityFile /abs/key\n";
744 let result = parse_identity_files_from_config(config, None);
745 assert_eq!(result, vec![PathBuf::from("/abs/key")]);
746 }
747
748 #[test]
749 fn discover_keys_in_temp_dir() {
750 let temp = tempfile::TempDir::new().expect("temp dir should create");
751 let ssh_dir = temp.path();
752
753 std::fs::write(ssh_dir.join("id_ed25519"), "private").unwrap();
755 std::fs::write(ssh_dir.join("id_ed25519.pub"), "ssh-ed25519 AAAA...").unwrap();
756
757 std::fs::write(ssh_dir.join("github"), "private").unwrap();
759 std::fs::write(ssh_dir.join("github.pub"), "ssh-ed25519 BBBB...").unwrap();
760
761 std::fs::write(ssh_dir.join("orphan.pub"), "ssh-rsa CCCC...").unwrap();
763
764 std::fs::write(ssh_dir.join("known_hosts"), "stuff").unwrap();
766
767 std::fs::create_dir(ssh_dir.join("agent.pub")).unwrap();
769
770 let entries = std::fs::read_dir(ssh_dir).unwrap();
772 let mut pairs = Vec::new();
773 for entry in entries.flatten() {
774 let pub_path = entry.path();
775 if !pub_path.is_file() {
776 continue;
777 }
778 let Some(name) = pub_path.file_name().and_then(|n| n.to_str()) else {
779 continue;
780 };
781 if !std::path::Path::new(name)
782 .extension()
783 .is_some_and(|ext| ext.eq_ignore_ascii_case("pub"))
784 {
785 continue;
786 }
787 let private_path = pub_path.with_extension("");
788 if private_path.is_file() {
789 pairs.push((private_path, pub_path));
790 }
791 }
792
793 pairs.sort();
794 assert_eq!(pairs.len(), 2);
795
796 let names: Vec<&str> = pairs
797 .iter()
798 .map(|(p, _)| p.file_name().unwrap().to_str().unwrap())
799 .collect();
800 assert!(names.contains(&"github"));
801 assert!(names.contains(&"id_ed25519"));
802 }
803}