1use std::path::Path;
29
30use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
31use hmac::{Hmac, Mac};
32use rand_core::{OsRng, RngCore};
33use sha1::Sha1;
34
35use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
36use crate::error::AnvilError;
37use crate::ssh_config::lexer::wildcard_match;
38
39pub const DEFAULT_GITHUB_HOST: &str = "github.com";
43
44pub const GITHUB_FALLBACK_HOST: &str = "ssh.github.com";
48
49pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
51
52pub const GITLAB_FALLBACK_HOST: &str = "altssh.gitlab.com";
56
57pub const DEFAULT_CODEBERG_HOST: &str = "codeberg.org";
59
60pub const DEFAULT_PORT: u16 = 22;
66
67pub const FALLBACK_PORT: u16 = 443;
69
70#[deprecated(since = "0.2.0", note = "use GITHUB_FALLBACK_HOST instead")]
75pub const FALLBACK_HOST: &str = GITHUB_FALLBACK_HOST;
76
77pub const GITHUB_FINGERPRINTS: &[&str] = &[
88 "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", ];
92
93pub const GITLAB_FINGERPRINTS: &[&str] = &[
102 "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8", "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw", "SHA256:ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ", ];
106
107pub const CODEBERG_FINGERPRINTS: &[&str] = &[
116 "SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g", "SHA256:T9FYDEHELhVkulEKKwge5aVhVTbqCW0MIRwAfpARs/E", "SHA256:6QQmYi4ppFS4/+zSZ5S4IU+4sa6rwvQ4PbhCtPEBekQ", ];
120
121fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result<Vec<String>, AnvilError> {
132 let content = std::fs::read_to_string(path)?;
133 let mut fps = Vec::new();
134
135 for line in content.lines() {
136 let line = line.trim();
137 if line.is_empty() || line.starts_with('#') {
138 continue;
139 }
140 let mut parts = line.splitn(2, ' ');
141 let Some(host_part) = parts.next() else {
142 continue;
143 };
144 let Some(fp_part) = parts.next() else {
145 continue;
146 };
147 if host_part == hostname {
148 fps.push(fp_part.trim().to_owned());
149 }
150 }
151
152 Ok(fps)
153}
154
155#[must_use]
166pub fn default_known_hosts_path() -> Option<std::path::PathBuf> {
167 dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
168}
169
170pub fn fingerprints_for_host(
184 host: &str,
185 custom_path: &Option<std::path::PathBuf>,
186) -> Result<Vec<String>, AnvilError> {
187 let mut fps: Vec<String> = match host {
189 "github.com" | "ssh.github.com" => {
190 GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
191 }
192 "gitlab.com" | "altssh.gitlab.com" => {
193 GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
194 }
195 "codeberg.org" => CODEBERG_FINGERPRINTS
196 .iter()
197 .map(|&s| s.to_owned())
198 .collect(),
199 _ => Vec::new(),
200 };
201
202 let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
206
207 if let Some(ref path) = known_hosts_path {
208 if path.exists() {
209 let extras = fingerprints_from_known_hosts(path, host)?;
210 fps.extend(extras);
211 }
212 }
213
214 if fps.is_empty() {
216 return Err(
217 AnvilError::invalid_config(format!("no fingerprints known for host '{host}'"))
218 .with_hint(format!(
219 "Gitway refuses to connect to hosts whose SSH fingerprint it can't \
220 verify (no trust-on-first-use). Either you typed the hostname \
221 wrong, or this is a self-hosted server and you need to pin its \
222 fingerprint: fetch it from the provider's docs (GitHub, GitLab, \
223 Codeberg publish them) and append one line to \
224 ~/.config/gitway/known_hosts:\n\
225 \n\
226 {host} SHA256:<base64-fingerprint>\n\
227 \n\
228 As a last resort, re-run with --insecure-skip-host-check (not \
229 recommended — this disables MITM protection)."
230 )),
231 );
232 }
233
234 Ok(fps)
235}
236
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
258pub struct HostKeyTrust {
259 pub fingerprints: Vec<String>,
260 pub cert_authorities: Vec<CertAuthority>,
261 pub revoked: Vec<RevokedEntry>,
262}
263
264pub fn host_key_trust(
279 host: &str,
280 custom_path: &Option<std::path::PathBuf>,
281) -> Result<HostKeyTrust, AnvilError> {
282 let mut trust = HostKeyTrust {
283 fingerprints: embedded_fingerprints(host),
284 cert_authorities: Vec::new(),
285 revoked: Vec::new(),
286 };
287
288 let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
289 let Some(path) = known_hosts_path else {
290 return Ok(trust);
291 };
292 if !path.exists() {
293 return Ok(trust);
294 }
295
296 let content = std::fs::read_to_string(&path).map_err(|e| {
297 AnvilError::invalid_config(format!(
298 "could not read known_hosts {}: {e}",
299 path.display(),
300 ))
301 })?;
302 let parsed: KnownHostsFile = parse_known_hosts(&content)?;
303
304 for direct in parsed.direct {
305 if wildcard_match(&direct.host_pattern, host) {
306 trust.fingerprints.push(direct.fingerprint);
307 }
308 }
309 for ca in parsed.cert_authorities {
310 if wildcard_match(&ca.host_pattern, host) {
311 trust.cert_authorities.push(ca);
312 }
313 }
314 for rev in parsed.revoked {
315 if wildcard_match(&rev.host_pattern, host) {
316 trust.revoked.push(rev);
317 }
318 }
319
320 Ok(trust)
321}
322
323fn embedded_fingerprints(host: &str) -> Vec<String> {
327 match host {
328 "github.com" | "ssh.github.com" => {
329 GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
330 }
331 "gitlab.com" | "altssh.gitlab.com" => {
332 GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
333 }
334 "codeberg.org" => CODEBERG_FINGERPRINTS
335 .iter()
336 .map(|&s| s.to_owned())
337 .collect(),
338 _ => Vec::new(),
339 }
340}
341
342pub fn append_known_host(path: &Path, host: &str, fingerprint: &str) -> Result<(), AnvilError> {
360 use std::io::Write;
361
362 ensure_parent_exists(path)?;
363
364 let line = format!("{host} {fingerprint}\n");
365 let mut file = std::fs::OpenOptions::new()
366 .append(true)
367 .create(true)
368 .open(path)
369 .map_err(|e| {
370 AnvilError::invalid_config(format!(
371 "could not open known_hosts {} for append: {e}",
372 path.display(),
373 ))
374 })?;
375 file.write_all(line.as_bytes()).map_err(|e| {
376 AnvilError::invalid_config(format!(
377 "could not write to known_hosts {}: {e}",
378 path.display(),
379 ))
380 })?;
381
382 Ok(())
383}
384
385pub fn append_known_host_hashed(
406 path: &Path,
407 host: &str,
408 fingerprint: &str,
409) -> Result<(), AnvilError> {
410 use std::io::Write;
411
412 ensure_parent_exists(path)?;
413
414 let mut salt = [0u8; 20];
416 OsRng.fill_bytes(&mut salt);
417
418 let mut mac = <Hmac<Sha1>>::new_from_slice(&salt).map_err(|_e| {
419 AnvilError::invalid_config(
423 "HMAC-SHA1 init failed unexpectedly; refusing to write hashed entry".to_owned(),
424 )
425 })?;
426 mac.update(host.as_bytes());
427 let hash = mac.finalize().into_bytes();
428
429 let line = format!(
430 "|1|{}|{} {fingerprint}\n",
431 BASE64.encode(salt),
432 BASE64.encode(hash.as_slice()),
433 );
434 let mut file = std::fs::OpenOptions::new()
435 .append(true)
436 .create(true)
437 .open(path)
438 .map_err(|e| {
439 AnvilError::invalid_config(format!(
440 "could not open known_hosts {} for append: {e}",
441 path.display(),
442 ))
443 })?;
444 file.write_all(line.as_bytes()).map_err(|e| {
445 AnvilError::invalid_config(format!(
446 "could not write to known_hosts {}: {e}",
447 path.display(),
448 ))
449 })?;
450
451 Ok(())
452}
453
454pub fn prepend_revoked(
481 path: &Path,
482 host_pattern: &str,
483 fingerprint: &str,
484) -> Result<(), AnvilError> {
485 use std::io::Write;
486
487 const MAX_FILE_BYTES: u64 = 1024 * 1024;
488
489 ensure_parent_exists(path)?;
490
491 let existing: Vec<u8> = if path.exists() {
493 let metadata = std::fs::metadata(path).map_err(|e| {
494 AnvilError::invalid_config(format!(
495 "could not stat known_hosts {} for revoke: {e}",
496 path.display(),
497 ))
498 })?;
499 if metadata.len() > MAX_FILE_BYTES {
500 return Err(AnvilError::invalid_config(format!(
501 "known_hosts {} is larger than {MAX_FILE_BYTES} bytes; refusing to load \
502 entire file into memory for revoke. Split the file or pass --known-hosts \
503 to point at a smaller one.",
504 path.display(),
505 )));
506 }
507 std::fs::read(path).map_err(|e| {
508 AnvilError::invalid_config(format!(
509 "could not read known_hosts {} for revoke: {e}",
510 path.display(),
511 ))
512 })?
513 } else {
514 Vec::new()
515 };
516
517 let mut suffix_bytes = [0u8; 8];
520 OsRng.fill_bytes(&mut suffix_bytes);
521 let suffix = BASE64
522 .encode(suffix_bytes)
523 .replace('/', "_")
524 .replace('+', "-");
525 let tmp_path = path.with_extension(format!("revoke.{suffix}.tmp"));
526
527 let mut tmp = std::fs::OpenOptions::new()
528 .write(true)
529 .create_new(true)
530 .open(&tmp_path)
531 .map_err(|e| {
532 AnvilError::invalid_config(format!(
533 "could not create temp file {} for revoke: {e}",
534 tmp_path.display(),
535 ))
536 })?;
537
538 let new_line = format!("@revoked {host_pattern} {fingerprint}\n");
539 tmp.write_all(new_line.as_bytes())
540 .map_err(|e| AnvilError::invalid_config(format!("could not write revoke header: {e}")))?;
541 tmp.write_all(&existing).map_err(|e| {
542 AnvilError::invalid_config(format!("could not copy existing known_hosts contents: {e}"))
543 })?;
544 tmp.sync_all().map_err(|e| {
545 AnvilError::invalid_config(format!("could not fsync temp file before rename: {e}"))
546 })?;
547 drop(tmp);
548
549 std::fs::rename(&tmp_path, path).map_err(|e| {
550 let _ = std::fs::remove_file(&tmp_path);
553 AnvilError::invalid_config(format!(
554 "could not rename {} -> {}: {e}",
555 tmp_path.display(),
556 path.display(),
557 ))
558 })?;
559
560 Ok(())
561}
562
563#[must_use]
570pub fn all_embedded() -> Vec<(String, String, &'static str)> {
571 const ALGS: [&str; 3] = ["ed25519", "ecdsa", "rsa"];
572 let mut out = Vec::with_capacity(9);
573 for (host, fps) in [
574 ("github.com", GITHUB_FINGERPRINTS),
575 ("gitlab.com", GITLAB_FINGERPRINTS),
576 ("codeberg.org", CODEBERG_FINGERPRINTS),
577 ] {
578 for (idx, fp) in fps.iter().enumerate() {
579 let alg = ALGS.get(idx).copied().unwrap_or("unknown");
580 out.push((host.to_owned(), (*fp).to_owned(), alg));
581 }
582 }
583 out
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum HashMode {
591 Empty,
593 Plaintext,
597 Hashed,
600}
601
602pub fn detect_hash_mode(path: &Path) -> Result<HashMode, AnvilError> {
619 if !path.exists() {
620 return Ok(HashMode::Empty);
621 }
622 let content = std::fs::read_to_string(path).map_err(|e| {
623 AnvilError::invalid_config(format!(
624 "could not read known_hosts {} for hash-mode detect: {e}",
625 path.display(),
626 ))
627 })?;
628 let mut saw_plaintext = false;
629 for raw in content.lines() {
630 let line = raw.trim();
631 if line.is_empty() || line.starts_with('#') || line.starts_with('@') {
632 continue;
633 }
634 let host_token = line.split_whitespace().next().unwrap_or("");
636 if host_token.starts_with("|1|") {
637 return Ok(HashMode::Hashed);
638 }
639 saw_plaintext = true;
640 }
641 if saw_plaintext {
642 Ok(HashMode::Plaintext)
643 } else {
644 Ok(HashMode::Empty)
645 }
646}
647
648fn ensure_parent_exists(path: &Path) -> Result<(), AnvilError> {
651 if let Some(parent) = path.parent() {
652 if !parent.as_os_str().is_empty() {
653 std::fs::create_dir_all(parent).map_err(|e| {
654 AnvilError::invalid_config(format!(
655 "could not create known_hosts parent {}: {e}",
656 parent.display(),
657 ))
658 })?;
659 }
660 }
661 Ok(())
662}
663
664#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
671 fn github_com_returns_three_fingerprints() {
672 let fps = fingerprints_for_host("github.com", &None).unwrap();
673 assert_eq!(fps.len(), 3);
674 }
675
676 #[test]
677 fn ssh_github_com_returns_same_fingerprints() {
678 let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
679 assert_eq!(fps.len(), 3);
680 }
681
682 #[test]
683 fn gitlab_com_returns_three_fingerprints() {
684 let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
685 assert_eq!(fps.len(), 3);
686 }
687
688 #[test]
689 fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
690 let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
691 let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
692 assert_eq!(primary, fallback);
693 }
694
695 #[test]
696 fn codeberg_org_returns_three_fingerprints() {
697 let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
698 assert_eq!(fps.len(), 3);
699 }
700
701 #[test]
702 fn all_github_fingerprints_start_with_sha256_prefix() {
703 for fp in GITHUB_FINGERPRINTS {
704 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
705 }
706 }
707
708 #[test]
709 fn all_gitlab_fingerprints_start_with_sha256_prefix() {
710 for fp in GITLAB_FINGERPRINTS {
711 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
712 }
713 }
714
715 #[test]
716 fn all_codeberg_fingerprints_start_with_sha256_prefix() {
717 for fp in CODEBERG_FINGERPRINTS {
718 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
719 }
720 }
721
722 #[test]
723 fn unknown_host_without_known_hosts_is_error() {
724 let result = fingerprints_for_host("git.example.com", &None);
725 assert!(result.is_err());
726 let err = result.unwrap_err();
727 assert!(err.to_string().contains("git.example.com"));
728 }
729
730 fn write_known_hosts(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
734 let dir = tempfile::tempdir().expect("tempdir");
735 let path = dir.path().join("known_hosts");
736 std::fs::write(&path, content).expect("write");
737 (dir, path)
738 }
739
740 #[test]
741 fn host_key_trust_embeds_well_known_fingerprints() {
742 let trust = host_key_trust("github.com", &None).expect("trust");
743 assert_eq!(trust.fingerprints.len(), 3);
744 assert!(trust.cert_authorities.is_empty());
745 assert!(trust.revoked.is_empty());
746 }
747
748 #[test]
749 fn host_key_trust_pattern_matches_cert_authority() {
750 let (_g, path) = write_known_hosts(
751 "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
752 );
753 let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
754 assert_eq!(trust.cert_authorities.len(), 1);
755 assert_eq!(trust.cert_authorities[0].host_pattern, "*.example.com");
756 }
757
758 #[test]
759 fn host_key_trust_pattern_excludes_non_match() {
760 let (_g, path) = write_known_hosts(
761 "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
762 );
763 let trust = host_key_trust("other.org", &Some(path)).expect("trust");
764 assert!(trust.cert_authorities.is_empty());
765 }
766
767 #[test]
768 fn host_key_trust_revoked_pattern_matches() {
769 let (_g, path) = write_known_hosts(
770 "@revoked *.example.com SHA256:revokedfp\n\
771 @revoked unrelated.com SHA256:other\n",
772 );
773 let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
774 assert_eq!(trust.revoked.len(), 1);
775 assert_eq!(trust.revoked[0].fingerprint, "SHA256:revokedfp");
776 }
777
778 #[test]
779 fn host_key_trust_combines_direct_and_embedded() {
780 let (_g, path) = write_known_hosts("github.com SHA256:extra-pin\n");
781 let trust = host_key_trust("github.com", &Some(path)).expect("trust");
782 assert_eq!(trust.fingerprints.len(), 4);
784 assert!(trust.fingerprints.contains(&"SHA256:extra-pin".to_owned()));
785 }
786
787 #[test]
788 fn host_key_trust_missing_file_returns_embedded_only() {
789 let trust = host_key_trust(
790 "github.com",
791 &Some(std::path::PathBuf::from("/this/path/does/not/exist")),
792 )
793 .expect("trust");
794 assert_eq!(trust.fingerprints.len(), 3);
795 assert!(trust.cert_authorities.is_empty());
796 assert!(trust.revoked.is_empty());
797 }
798
799 #[test]
800 fn host_key_trust_empty_for_unknown_host_no_file() {
801 let trust = host_key_trust("git.example.com", &None).expect("trust");
805 assert!(trust.fingerprints.is_empty());
806 assert!(trust.cert_authorities.is_empty());
807 assert!(trust.revoked.is_empty());
808 }
809}