2727
2828use std:: path:: Path ;
2929
30+ use base64:: { engine:: general_purpose:: STANDARD as BASE64 , Engine as _} ;
31+ use hmac:: { Hmac , Mac } ;
32+ use rand_core:: { OsRng , RngCore } ;
33+ use sha1:: Sha1 ;
34+
3035use crate :: cert_authority:: { parse_known_hosts, CertAuthority , KnownHostsFile , RevokedEntry } ;
3136use crate :: error:: AnvilError ;
3237use crate :: ssh_config:: lexer:: wildcard_match;
@@ -147,8 +152,18 @@ fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result<Vec<Stri
147152 Ok ( fps)
148153}
149154
150- /// Returns the default known-hosts path: `~/.config/gitway/known_hosts`.
151- fn default_known_hosts_path ( ) -> Option < std:: path:: PathBuf > {
155+ /// Returns the default known-hosts path: `~/.config/gitway/known_hosts`
156+ /// (or the platform-equivalent `dirs::config_dir()` location).
157+ ///
158+ /// Returns `None` when `dirs::config_dir()` cannot resolve a config
159+ /// directory (extremely rare — typically only on misconfigured CI
160+ /// runners with no `HOME` / `XDG_CONFIG_HOME` and no fallback).
161+ ///
162+ /// Promoted from crate-private to public in M19 (PRD §5.8.8) so the
163+ /// `gitway hosts` subcommand family can target the same path the
164+ /// rest of Anvil reads from by default.
165+ #[ must_use]
166+ pub fn default_known_hosts_path ( ) -> Option < std:: path:: PathBuf > {
152167 dirs:: config_dir ( ) . map ( |d| d. join ( "gitway" ) . join ( "known_hosts" ) )
153168}
154169
@@ -324,38 +339,98 @@ fn embedded_fingerprints(host: &str) -> Vec<String> {
324339 }
325340}
326341
327- /// Appends `host SHA256:<fingerprint>` as a new line to the `known_hosts`
328- /// file at `path`, creating the file (and any missing parent directories)
329- /// if needed.
342+ /// Appends `host SHA256:<fingerprint>` as a new plaintext line to
343+ /// the `known_hosts` file at `path`, creating the file (and any
344+ /// missing parent directories) if needed.
345+ ///
346+ /// Promoted from crate-private to public in M19 (PRD §5.8.8 FR-85)
347+ /// so the `gitway hosts add` verb can drive the write side without a
348+ /// re-export shim. Used internally by
349+ /// [`crate::ssh_config::StrictHostKeyChecking::AcceptNew`] for the
350+ /// first-connection TOFU path.
330351///
331- /// Used by [`crate::ssh_config::StrictHostKeyChecking::AcceptNew`] to
332- /// record the fingerprint of an otherwise-unknown host on first
333- /// connection. This is the minimum write surface — file locking and
334- /// duplicate-detection are deferred to the post-M12 TOFU UX.
352+ /// File locking and duplicate-detection are deferred to a post-M19
353+ /// polish pass — see PRD §5.8.8 risks.
335354///
336355/// # Errors
337356///
338- /// Returns an error if the parent directory cannot be created, or if
339- /// the file cannot be opened for append, or if the write fails.
340- pub ( crate ) fn append_known_host (
357+ /// Returns an error if the parent directory cannot be created, the
358+ /// file cannot be opened for append, or the write fails.
359+ pub 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+
385+ /// Appends `|1|<base64-salt>|<base64-hmac-sha1> SHA256:<fingerprint>`
386+ /// to the `known_hosts` file at `path`, generating a fresh 20-byte
387+ /// random salt for this entry.
388+ ///
389+ /// This is the M19 (PRD §5.8.8 FR-84) write-side counterpart to
390+ /// [`crate::cert_authority::HashedHost::matches`]. The encoding is
391+ /// bit-for-bit identical to what `ssh-keygen -H` would write — see
392+ /// the `tests/test_hostkey_writes.rs` round-trip test that proves it
393+ /// re-parses through [`crate::cert_authority::parse_known_hosts`] +
394+ /// [`crate::cert_authority::HashedHost::matches(host)`] cleanly.
395+ ///
396+ /// `host` is what gets HMAC-SHA1'd; pass exactly the hostname the
397+ /// caller wants the hash to match (no implicit lower-casing — that
398+ /// policy lives in the caller, mirroring OpenSSH's
399+ /// `hostfile.c::lowercase` flag handling).
400+ ///
401+ /// # Errors
402+ ///
403+ /// Returns an error if the parent directory cannot be created, the
404+ /// file cannot be opened for append, or the write fails.
405+ pub fn append_known_host_hashed (
341406 path : & Path ,
342407 host : & str ,
343408 fingerprint : & str ,
344409) -> Result < ( ) , AnvilError > {
345410 use std:: io:: Write ;
346411
347- if let Some ( parent) = path. parent ( ) {
348- if !parent. as_os_str ( ) . is_empty ( ) {
349- std:: fs:: create_dir_all ( parent) . map_err ( |e| {
350- AnvilError :: invalid_config ( format ! (
351- "could not create known_hosts parent {}: {e}" ,
352- parent. display( ) ,
353- ) )
354- } ) ?;
355- }
356- }
412+ ensure_parent_exists ( path) ?;
357413
358- let line = format ! ( "{host} {fingerprint}\n " ) ;
414+ // Fresh 20-byte salt per entry, sourced from the OS RNG.
415+ 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+ // `_e` is the InvalidLength variant; HMAC-SHA1 does not
420+ // enforce key-length restrictions in practice, so this
421+ // branch is effectively dead. Discarded by design.
422+ 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+ ) ;
359434 let mut file = std:: fs:: OpenOptions :: new ( )
360435 . append ( true )
361436 . create ( true )
@@ -376,6 +451,216 @@ pub(crate) fn append_known_host(
376451 Ok ( ( ) )
377452}
378453
454+ /// Prepends `@revoked <host_pattern> <fingerprint>` to the
455+ /// `known_hosts` file at `path`, atomically via a sibling tempfile +
456+ /// rename. Creates the file (and missing parents) if it does not
457+ /// yet exist.
458+ ///
459+ /// M19 (PRD §5.8.8 FR-86): the `@revoked` line is written **first**
460+ /// in the file so it surfaces ahead of any direct pin during
461+ /// human inspection. The trust-merger ([`host_key_trust`])
462+ /// already treats `@revoked` as a hard reject regardless of position,
463+ /// so the prepend is purely a readability convention.
464+ ///
465+ /// # Atomicity
466+ ///
467+ /// Reads the existing file into memory (capped at 1 MiB), prepends
468+ /// the new line, writes to `<path>.tmp.<random>`, then
469+ /// [`std::fs::rename`] over the original. POSIX `rename` is atomic
470+ /// within a filesystem; on Windows, `MoveFileEx` with
471+ /// `MOVEFILE_REPLACE_EXISTING` is the closest equivalent and is what
472+ /// `std::fs::rename` uses. A crash mid-rename leaves either the old
473+ /// file or the new one — never a torn write.
474+ ///
475+ /// # Errors
476+ ///
477+ /// Returns an error if the file is larger than 1 MiB, the parent
478+ /// directory cannot be created, the tempfile cannot be opened, or
479+ /// the rename fails.
480+ pub 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+ // Read the existing file (or treat missing as empty).
492+ 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+ // Build the temp path with a random suffix so concurrent revokes
518+ // don't collide on the same temp name.
519+ 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+ // Best-effort cleanup of the orphaned tempfile; ignore the
551+ // result because we're already in an error path.
552+ 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+ /// Returns the embedded fingerprint catalogue as `(host, fingerprint,
564+ /// algorithm)` triples for surfacing in `gitway hosts list`.
565+ ///
566+ /// The algorithm tag is one of `"ed25519"`, `"ecdsa"`, `"rsa"` —
567+ /// matches the per-index ordering inside [`GITHUB_FINGERPRINTS`],
568+ /// [`GITLAB_FINGERPRINTS`], and [`CODEBERG_FINGERPRINTS`].
569+ #[ must_use]
570+ pub 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+ /// Per-file format detected by [`detect_hash_mode`]. Drives whether
587+ /// `gitway hosts add` should emit a hashed or plaintext entry by
588+ /// default.
589+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
590+ pub enum HashMode {
591+ /// File does not exist, or contains no recognizable host lines.
592+ Empty ,
593+ /// At least one direct line uses the plaintext `host SHA256:fp`
594+ /// shape; no hashed entries seen. New entries default to
595+ /// plaintext.
596+ Plaintext ,
597+ /// At least one direct line uses the `|1|salt|hash SHA256:fp`
598+ /// shape. New entries default to hashed.
599+ Hashed ,
600+ }
601+
602+ /// Inspects the existing `known_hosts` file at `path` and decides
603+ /// whether new entries should be hashed (matches OpenSSH's
604+ /// `HashKnownHosts yes` behaviour) or plaintext.
605+ ///
606+ /// - Returns [`HashMode::Empty`] if the file does not exist or is
607+ /// empty / contains only comments + `@`-marker lines.
608+ /// - Returns [`HashMode::Hashed`] if **any** non-comment direct line
609+ /// starts with `|1|` (matches OpenSSH's `_ssh_host_hashed_p` check).
610+ /// - Returns [`HashMode::Plaintext`] otherwise.
611+ ///
612+ /// Cheap — reads the file once line-by-line and short-circuits on
613+ /// the first hashed token seen.
614+ ///
615+ /// # Errors
616+ ///
617+ /// Returns an error only if the file exists but cannot be read.
618+ pub 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+ // Direct line. Inspect the first whitespace-delimited token.
635+ 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+
648+ /// Internal helper — `mkdir -p` for the parent of `path`. Used by
649+ /// every M19 writer so they share the same error-message shape.
650+ fn 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+
379664// ── Tests ─────────────────────────────────────────────────────────────────────
380665
381666#[ cfg( test) ]
0 commit comments