Skip to content

Commit 70b1140

Browse files
feat(cert_authority): M19.1 — parse hashed known_hosts entries (FR-84 read) (#22)
Replaces the M14-era "skip hashed entries" stub with a real parser for OpenSSH's `HashKnownHosts yes` format (PRD §5.8.8 FR-84 read side). src/cert_authority.rs (~120 lines net): - New pub struct `HashedHost { salt: [u8;20], hash: [u8;20], fingerprint: String }` with `pub fn matches(&self, host: &str) -> bool` running HMAC-SHA1(self.salt, host.as_bytes()) and comparing in constant time via `Hmac::verify_slice`. - New `KnownHostsFile.hashed: Vec<HashedHost>` field, populated by the parser alongside the existing direct/cert_authorities/revoked classes. Backward-compatible (additive) — existing consumers that destructure `KnownHostsFile { direct, cert_authorities, revoked }` get an `unused field` warning at most; pattern matches with `..` keep working. - New private `parse_hashed_token(token) -> Option<([u8;20], [u8;20])>` decodes a single `|1|<base64-salt>|<base64-hash>` token. Returns None for any deviation from the expected form (missing prefix, missing inner `|`, base64 decode failure, wrong byte length) — callers warn-and-skip on None instead of erroring. - The line-level skip at the top of parse_known_hosts is gone; `|1|...` detection moved into the per-token loop so a comma- separated host column can mix hashed and plaintext tokens (each token is classified independently and lands in either `hashed` or `direct` with a shared fingerprint). Cargo.toml: - Direct deps: `hmac = "0.12"`, `sha1 = "0.10"`, `base64 = "0.22"`. All three were already pulled transitively via ssh-key/russh; declaring them keeps intent legible in cargo tree. - Dev-deps: same three crates so the integration test builder can construct fixtures programmatically. tests/test_hashed_hosts.rs (new, ~180 lines): - 8 hermetic tests, all using a programmatic fixture builder that runs the same HMAC-SHA1 computation Anvil uses at runtime. This proves the parser+matcher round-trip without committing pre-generated golden bytes that future maintainers can't audit. - Covers: single-line parse, single-host match, mismatch / empty-string / substring rejection, mixed file with hashed + plaintext + comments, malformed-token warn-and-skip, multi- host column with one hash per token, Clone+Eq derive stability, and case-sensitivity-by-design (no implicit case-folding — callers lower-case before matches() if they want OpenSSH's hostfile.c::lowercase semantics). The HMAC-SHA1 here is a privacy primitive (file-readable host enumeration resistance), NOT a security primitive. SHA-1 collisions don't matter — salt is per-line and 160 bits, input is a low-entropy hostname, and the threat model is exactly OpenSSH's: hide the host list from a casual file reader. Documented inline in cert_authority.rs's module doc-comment. Plan: M19.1 of anvil-gitway-milestone-plan.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 746ae4b commit 70b1140

4 files changed

Lines changed: 312 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ tracing = "0.1"
3333
tracing-log = "0.2"
3434
dirs = "6"
3535
zeroize = "1.7"
36+
# M19: HMAC-SHA1 + base64 for OpenSSH `HashKnownHosts yes` semantics
37+
# (PRD §5.8.8 FR-84). These crates are already pulled transitively
38+
# via `ssh-key`/`russh`; declaring them directly keeps intent legible
39+
# in `cargo tree` and prevents a future `ssh-key` minor bump from
40+
# silently dropping them.
41+
hmac = "0.12"
42+
sha1 = "0.10"
43+
base64 = "0.22"
3644

3745
# Pure-Rust OpenSSH key format and SSHSIG (for keygen / sign / verify).
3846
# `ssh-key`'s RustCrypto stack (ed25519-dalek 2.x, rsa 0.9, p256/384/521) is
@@ -66,6 +74,14 @@ criterion = { version = "0.5", features = ["async_tokio"] }
6674
# never ships in the published crate's runtime tree.
6775
serde = { version = "1", features = ["derive"] }
6876
serde_yml = "0.0.12"
77+
# M19.1: integration tests in `tests/test_hashed_hosts.rs` construct
78+
# OpenSSH-format `|1|salt|hash` fixtures programmatically (rather
79+
# than committing pre-generated golden bytes that future maintainers
80+
# can't audit). hmac/sha1/base64 are already runtime deps; declaring
81+
# them here lets the integration test crate access them too.
82+
hmac = "0.12"
83+
sha1 = "0.10"
84+
base64 = "0.22"
6985

7086
[[bench]]
7187
name = "throughput"

src/cert_authority.rs

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,26 @@
2929
//!
3030
//! Multiple comma-separated host patterns on one line are split into
3131
//! multiple entries. Comment lines (`#`) and blanks are skipped.
32-
//! Hashed entries (`|1|...|...`) are skipped with a debug log; full
33-
//! support is documented as a follow-up.
34-
32+
//!
33+
//! ## Hashed-host support (M19, FR-84)
34+
//!
35+
//! OpenSSH's `HashKnownHosts yes` setting replaces the plaintext host
36+
//! column with `|1|<base64-salt>|<base64-hmac-sha1>` so that an attacker
37+
//! who reads the file cannot enumerate which hosts the user has
38+
//! connected to. Anvil parses these into [`HashedHost`] values and
39+
//! stores them on [`KnownHostsFile::hashed`]; the per-entry
40+
//! [`HashedHost::matches`] method runs HMAC-SHA1 against a candidate
41+
//! hostname to test for membership at lookup time. HMAC-SHA1 here is
42+
//! a *privacy* primitive (file-readable enumeration resistance), not a
43+
//! *security* primitive — SHA-1 collisions don't matter because the
44+
//! salt is per-line and 160 bits, the input is a low-entropy hostname,
45+
//! and the threat model is exactly OpenSSH's: hide the hostname list
46+
//! from a casual file reader.
47+
48+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
49+
use hmac::{Hmac, Mac};
3550
use russh::keys::{ssh_key::PublicKey, HashAlg};
51+
use sha1::Sha1;
3652

3753
use crate::error::AnvilError;
3854

@@ -86,6 +102,50 @@ pub struct DirectHostKey {
86102
pub fingerprint: String,
87103
}
88104

105+
/// One `HashKnownHosts yes` entry (M19, FR-84).
106+
///
107+
/// OpenSSH replaces the plaintext host column with `|1|salt|hash` so a
108+
/// casual reader of the `known_hosts` file cannot enumerate connected
109+
/// hosts. The salt is 20 random bytes (matching HMAC-SHA1's output
110+
/// width); the hash is `HMAC-SHA1(key=salt, data=hostname)`. Use
111+
/// [`HashedHost::matches`] to test a candidate hostname against the
112+
/// stored hash.
113+
///
114+
/// Multiple comma-separated `|1|...` tokens on one source line produce
115+
/// one [`HashedHost`] per token, all sharing the same `fingerprint`.
116+
#[derive(Debug, Clone, PartialEq, Eq)]
117+
pub struct HashedHost {
118+
/// Per-line random salt used as the HMAC-SHA1 key. Always 20 bytes.
119+
pub salt: [u8; 20],
120+
/// `HMAC-SHA1(salt, hostname)` for the hostname this entry covers.
121+
/// Always 20 bytes.
122+
pub hash: [u8; 20],
123+
/// Fingerprint of the host key, e.g. `SHA256:uNiVztksCs...`.
124+
/// Shared across every [`HashedHost`] derived from the same source
125+
/// line.
126+
pub fingerprint: String,
127+
}
128+
129+
impl HashedHost {
130+
/// Returns `true` iff `host` is the hostname this entry encodes.
131+
///
132+
/// Runs `HMAC-SHA1(self.salt, host.as_bytes())` and compares against
133+
/// the stored hash with constant-time equality (via `HMAC::verify`,
134+
/// which uses [`subtle`] internally). False on mismatch — never
135+
/// errors.
136+
#[must_use]
137+
pub fn matches(&self, host: &str) -> bool {
138+
let Ok(mut mac) = <Hmac<Sha1>>::new_from_slice(&self.salt) else {
139+
// `new_from_slice` only fails on key-length restrictions
140+
// that HMAC-SHA1 does not enforce, so this branch is
141+
// effectively dead. Defensive return rather than panic.
142+
return false;
143+
};
144+
mac.update(host.as_bytes());
145+
mac.verify_slice(&self.hash).is_ok()
146+
}
147+
}
148+
89149
/// Fully-parsed view of one `known_hosts`-style file.
90150
///
91151
/// Returned by [`parse_known_hosts`]. Empty vectors are the natural
@@ -95,6 +155,10 @@ pub struct KnownHostsFile {
95155
pub direct: Vec<DirectHostKey>,
96156
pub cert_authorities: Vec<CertAuthority>,
97157
pub revoked: Vec<RevokedEntry>,
158+
/// Hashed direct entries (M19, FR-84). Use
159+
/// [`HashedHost::matches`] to query by candidate hostname; the
160+
/// vector itself preserves source order.
161+
pub hashed: Vec<HashedHost>,
98162
}
99163

100164
/// Parses `content` (the contents of a `known_hosts`-style file) into
@@ -119,13 +183,6 @@ pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
119183
}
120184
let line_no = idx + 1;
121185

122-
if line.starts_with("|1|") {
123-
log::debug!(
124-
"known_hosts: line {line_no} is a hashed entry; skipping (not yet supported)"
125-
);
126-
continue;
127-
}
128-
129186
if let Some(rest) = strip_marker_ci(line, "@cert-authority") {
130187
parse_cert_authority_line(rest, line_no, &mut out)?;
131188
continue;
@@ -135,7 +192,9 @@ pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
135192
continue;
136193
}
137194

138-
// Plain direct line: `host[,host2,…] SHA256:fp`.
195+
// Direct line (plaintext or hashed): `host[,host2,…] SHA256:fp`.
196+
// Each comma-separated host token is classified independently
197+
// — `|1|salt|hash` tokens land in `hashed`, others in `direct`.
139198
let mut parts = line.splitn(2, char::is_whitespace);
140199
let Some(host_part) = parts.next() else {
141200
continue;
@@ -147,17 +206,51 @@ pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
147206
if fp.is_empty() {
148207
continue;
149208
}
150-
for host in split_host_patterns(host_part) {
151-
out.direct.push(DirectHostKey {
152-
host_pattern: host,
153-
fingerprint: fp.to_owned(),
154-
});
209+
for host_token in split_host_patterns(host_part) {
210+
if host_token.starts_with("|1|") {
211+
match parse_hashed_token(&host_token) {
212+
Some((salt, hash)) => {
213+
out.hashed.push(HashedHost {
214+
salt,
215+
hash,
216+
fingerprint: fp.to_owned(),
217+
});
218+
}
219+
None => {
220+
log::warn!(
221+
"known_hosts: line {line_no}: malformed hashed token '{host_token}'; \
222+
skipping (expected '|1|<base64-salt>|<base64-hash>')",
223+
);
224+
}
225+
}
226+
} else {
227+
out.direct.push(DirectHostKey {
228+
host_pattern: host_token,
229+
fingerprint: fp.to_owned(),
230+
});
231+
}
155232
}
156233
}
157234

158235
Ok(out)
159236
}
160237

238+
/// Decodes a single `|1|<base64-salt>|<base64-hash>` token into its
239+
/// 20-byte salt + 20-byte hash components.
240+
///
241+
/// Returns `None` for any deviation from the expected form: missing
242+
/// `|1|` prefix, missing inner `|` separator, base64 decode failure,
243+
/// or wrong byte length. Callers log + skip on `None`.
244+
fn parse_hashed_token(token: &str) -> Option<([u8; 20], [u8; 20])> {
245+
let rest = token.strip_prefix("|1|")?;
246+
let (salt_b64, hash_b64) = rest.split_once('|')?;
247+
let salt_bytes = BASE64.decode(salt_b64.as_bytes()).ok()?;
248+
let hash_bytes = BASE64.decode(hash_b64.as_bytes()).ok()?;
249+
let salt: [u8; 20] = salt_bytes.try_into().ok()?;
250+
let hash: [u8; 20] = hash_bytes.try_into().ok()?;
251+
Some((salt, hash))
252+
}
253+
161254
/// Returns the rest of `line` after `marker`, but only if `marker`
162255
/// appears at the start of `line` followed by whitespace
163256
/// (case-insensitive on the marker itself, matching OpenSSH).

0 commit comments

Comments
 (0)