Skip to content

Commit b3f961e

Browse files
feat(hostkey): M19.2 — hashed-emit + revoke writers (FR-84 write, FR-86) (#23)
Adds the write-side counterparts to M19.1's read-side `HashedHost`, plus the `@revoked` prepend helper FR-86 needs. Also promotes two previously-private helpers to `pub` so the upcoming `gitway hosts` verb family (M19.4) can drive them without re-export shims. src/hostkey.rs: - `pub fn append_known_host(path, host, fp)` — promoted from `pub(crate)`. Plaintext append; creates parent dir; no locking (M19 explicit non-goal). - `pub fn append_known_host_hashed(path, host, fp)` (new) — emits `|1|<base64-salt>|<base64-hmac-sha1> <fp>` with a fresh 20-byte OS-RNG salt per call. Round-trippable through `cert_authority::parse_known_hosts` + `HashedHost::matches(host)`, pinned by `tests/test_hostkey_writes.rs::append_known_host_hashed_writes_round_trippable_entry`. - `pub fn prepend_revoked(path, host_pattern, fp)` (new) — atomic prepend via tempfile + `std::fs::rename`. 1 MiB cap on input file (refused with a clear `tips-thinking`-style message pointing at `--known-hosts` for splitting). Trust-merger treats `@revoked` as a hard reject regardless of position; the prepend is purely a readability convention. - `pub fn all_embedded()` (new) — returns `(host, fingerprint, alg)` triples for the `gitway hosts list` embedded section. 3 hosts × 3 algorithms = 9 entries, alg in {"ed25519", "ecdsa", "rsa"}. - `pub enum HashMode { Empty, Plaintext, Hashed }` and `pub fn detect_hash_mode(path) -> Result<HashMode, AnvilError>` (new) — inspect-existing-file decision for `gitway hosts add`'s default hashed/plaintext choice. Short-circuits on the first hashed token seen. - `pub fn default_known_hosts_path()` — promoted from private. `~/.config/gitway/known_hosts` via `dirs::config_dir()`. - New private `ensure_parent_exists` helper extracted from the existing `mkdir -p` block so all four writers share one error message shape. Cargo.toml: - Adds `rand_core = { version = "0.6", features = ["std", "getrandom"] }` is already a runtime dep; M19.2 just uses it directly via `OsRng.fill_bytes`. tests/test_hostkey_writes.rs (new, ~13 tests): - Plaintext append: file creation, append-to-existing. - Hashed append: round-trip parse + match, distinct-salt-per-call (privacy property of `HashKnownHosts yes`). - Revoke prepend: file creation, prepend-position assertion, oversize refusal. - detect_hash_mode: missing file, comments-only, plaintext-only, short-circuit on first hashed token. - all_embedded: count + algorithm-tag completeness. - default_known_hosts_path: ends-with assertion. Stacks on M19.1 (#22, merged). Plan: M19.2 of anvil-gitway-milestone-plan.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70b1140 commit b3f961e

2 files changed

Lines changed: 524 additions & 23 deletions

File tree

src/hostkey.rs

Lines changed: 308 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
2828
use 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+
3035
use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
3136
use crate::error::AnvilError;
3237
use 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

Comments
 (0)