Skip to content

Commit bd7d036

Browse files
feat(hostkey): M14.2 — host_key_trust API + @Revoked enforcement (FR-64) (#16)
Wires the M14.1 cert_authority parser into the connect-time host-key check. @Revoked entries become a hard, policy-overriding blocklist: even StrictHostKeyChecking::No cannot accept a key whose fingerprint appears in a matching @Revoked line. src/hostkey.rs: - New public type HostKeyTrust { fingerprints, cert_authorities, revoked } combining the embedded set, parsed direct pins, matching cert authorities, and matching revoked entries from one known_hosts pass. - New public fn host_key_trust(host, custom_path) -> HostKeyTrust: 1. Seeds with embedded fingerprints (GitHub / GitLab / Codeberg). 2. Reads the user-supplied or default known_hosts file. 3. Filters direct, cert-authority, and revoked entries by the host pattern using ssh_config::lexer::wildcard_match. 4. Returns an empty trust set without erroring (unlike fingerprints_for_host) — the caller's policy decides. - Existing fingerprints_for_host preserved verbatim for back-compat. - New embedded_fingerprints helper extracted from fingerprints_for_host so host_key_trust can share the well-known-host seeding logic. src/session.rs: - GitwayHandler gains revoked: Vec<String> field. - check_server_key consults `revoked` FIRST, before the StrictHostKeyChecking::No bypass and the fingerprint match path: any presented key whose SHA-256 fingerprint hits a revoked entry is rejected with a host_key_mismatch error and a hint mentioning the @Revoked entry. - build_handler_pieces switched from fingerprints_for_host to host_key_trust so direct pins, revocations, and cert authorities are pulled in one pass. The empty-fingerprint branch reproduces the long-form actionable hint that fingerprints_for_host emitted. Tests: +7 new hostkey unit tests: - host_key_trust embeds well-known fingerprints. - host_key_trust pattern matches @cert-authority by host glob. - host_key_trust excludes non-matching @cert-authority. - host_key_trust pattern matches @Revoked. - host_key_trust combines direct pins with embedded set. - host_key_trust tolerates a missing custom_path file. - host_key_trust returns empty for an unknown host with no file (caller decides whether the absence is fatal — the AcceptNew path relies on this). Total: 207 lib tests, 0 failures, 5 ignored (pre-existing). Plan: M14.2 of let-us-plan-on-bright-cosmos.md. Stacked on M14.1 (PR #15).
1 parent 17b59a2 commit bd7d036

2 files changed

Lines changed: 256 additions & 25 deletions

File tree

src/hostkey.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
2828
use std::path::Path;
2929

30+
use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
3031
use crate::error::AnvilError;
32+
use crate::ssh_config::lexer::wildcard_match;
3133

3234
// ── Well-known host constants ─────────────────────────────────────────────────
3335

@@ -217,6 +219,111 @@ pub fn fingerprints_for_host(
217219
Ok(fps)
218220
}
219221

222+
// ── M14: combined trust view (FR-60, FR-64) ──────────────────────────────────
223+
224+
/// Combined view of every `known_hosts` entry that bears on the
225+
/// connection target.
226+
///
227+
/// Returned by [`host_key_trust`]. A connection target's effective
228+
/// trust is the union of:
229+
///
230+
/// - `fingerprints` — direct SHA-256 pins (embedded + custom-file).
231+
/// Identical to what [`fingerprints_for_host`] returns.
232+
/// - `cert_authorities` — `@cert-authority` entries whose host pattern
233+
/// matches the target. Live cert verification (FR-61, FR-62, FR-63)
234+
/// is deferred until russh exposes the server's certificate; the
235+
/// field is populated today so `gitway config show --json` and
236+
/// audit tooling can surface CA identities.
237+
/// - `revoked` — `@revoked` entries whose host pattern matches.
238+
/// Enforced first in
239+
/// [`crate::session::AnvilSession::connect`]'s host-key check: any
240+
/// presented key whose fingerprint hits one of these is rejected
241+
/// regardless of `StrictHostKeyChecking` policy.
242+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
243+
pub struct HostKeyTrust {
244+
pub fingerprints: Vec<String>,
245+
pub cert_authorities: Vec<CertAuthority>,
246+
pub revoked: Vec<RevokedEntry>,
247+
}
248+
249+
/// Returns the [`HostKeyTrust`] for `host`, combining the embedded
250+
/// fingerprint set, any direct pins / `@cert-authority` / `@revoked`
251+
/// lines from the user-supplied or default `known_hosts` file, and
252+
/// pattern-matching for the cert-authority + revoked classes.
253+
///
254+
/// Unlike [`fingerprints_for_host`], an empty trust set is **not** an
255+
/// error — the caller decides whether the absence is fatal (the
256+
/// `StrictHostKeyChecking::AcceptNew` path tolerates an empty set; the
257+
/// `Yes` path does not).
258+
///
259+
/// # Errors
260+
/// [`AnvilError::invalid_config`] when the known-hosts file exists but
261+
/// fails to parse (a malformed `@cert-authority` line, for instance).
262+
/// File-not-found is silently treated as no entries.
263+
pub fn host_key_trust(
264+
host: &str,
265+
custom_path: &Option<std::path::PathBuf>,
266+
) -> Result<HostKeyTrust, AnvilError> {
267+
let mut trust = HostKeyTrust {
268+
fingerprints: embedded_fingerprints(host),
269+
cert_authorities: Vec::new(),
270+
revoked: Vec::new(),
271+
};
272+
273+
let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
274+
let Some(path) = known_hosts_path else {
275+
return Ok(trust);
276+
};
277+
if !path.exists() {
278+
return Ok(trust);
279+
}
280+
281+
let content = std::fs::read_to_string(&path).map_err(|e| {
282+
AnvilError::invalid_config(format!(
283+
"could not read known_hosts {}: {e}",
284+
path.display(),
285+
))
286+
})?;
287+
let parsed: KnownHostsFile = parse_known_hosts(&content)?;
288+
289+
for direct in parsed.direct {
290+
if wildcard_match(&direct.host_pattern, host) {
291+
trust.fingerprints.push(direct.fingerprint);
292+
}
293+
}
294+
for ca in parsed.cert_authorities {
295+
if wildcard_match(&ca.host_pattern, host) {
296+
trust.cert_authorities.push(ca);
297+
}
298+
}
299+
for rev in parsed.revoked {
300+
if wildcard_match(&rev.host_pattern, host) {
301+
trust.revoked.push(rev);
302+
}
303+
}
304+
305+
Ok(trust)
306+
}
307+
308+
/// Returns the embedded SHA-256 fingerprints for the listed
309+
/// well-known hosts. Internal helper used by both
310+
/// [`fingerprints_for_host`] and [`host_key_trust`].
311+
fn embedded_fingerprints(host: &str) -> Vec<String> {
312+
match host {
313+
"github.com" | "ssh.github.com" => {
314+
GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
315+
}
316+
"gitlab.com" | "altssh.gitlab.com" => {
317+
GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
318+
}
319+
"codeberg.org" => CODEBERG_FINGERPRINTS
320+
.iter()
321+
.map(|&s| s.to_owned())
322+
.collect(),
323+
_ => Vec::new(),
324+
}
325+
}
326+
220327
/// Appends `host SHA256:<fingerprint>` as a new line to the `known_hosts`
221328
/// file at `path`, creating the file (and any missing parent directories)
222329
/// if needed.
@@ -334,4 +441,84 @@ mod tests {
334441
let err = result.unwrap_err();
335442
assert!(err.to_string().contains("git.example.com"));
336443
}
444+
445+
// ── M14: host_key_trust ──────────────────────────────────────────────────
446+
447+
/// Helper: write `content` to a fresh temp file and return its path.
448+
fn write_known_hosts(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
449+
let dir = tempfile::tempdir().expect("tempdir");
450+
let path = dir.path().join("known_hosts");
451+
std::fs::write(&path, content).expect("write");
452+
(dir, path)
453+
}
454+
455+
#[test]
456+
fn host_key_trust_embeds_well_known_fingerprints() {
457+
let trust = host_key_trust("github.com", &None).expect("trust");
458+
assert_eq!(trust.fingerprints.len(), 3);
459+
assert!(trust.cert_authorities.is_empty());
460+
assert!(trust.revoked.is_empty());
461+
}
462+
463+
#[test]
464+
fn host_key_trust_pattern_matches_cert_authority() {
465+
let (_g, path) = write_known_hosts(
466+
"@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
467+
);
468+
let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
469+
assert_eq!(trust.cert_authorities.len(), 1);
470+
assert_eq!(trust.cert_authorities[0].host_pattern, "*.example.com");
471+
}
472+
473+
#[test]
474+
fn host_key_trust_pattern_excludes_non_match() {
475+
let (_g, path) = write_known_hosts(
476+
"@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
477+
);
478+
let trust = host_key_trust("other.org", &Some(path)).expect("trust");
479+
assert!(trust.cert_authorities.is_empty());
480+
}
481+
482+
#[test]
483+
fn host_key_trust_revoked_pattern_matches() {
484+
let (_g, path) = write_known_hosts(
485+
"@revoked *.example.com SHA256:revokedfp\n\
486+
@revoked unrelated.com SHA256:other\n",
487+
);
488+
let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
489+
assert_eq!(trust.revoked.len(), 1);
490+
assert_eq!(trust.revoked[0].fingerprint, "SHA256:revokedfp");
491+
}
492+
493+
#[test]
494+
fn host_key_trust_combines_direct_and_embedded() {
495+
let (_g, path) = write_known_hosts("github.com SHA256:extra-pin\n");
496+
let trust = host_key_trust("github.com", &Some(path)).expect("trust");
497+
// Three embedded + one extra direct.
498+
assert_eq!(trust.fingerprints.len(), 4);
499+
assert!(trust.fingerprints.contains(&"SHA256:extra-pin".to_owned()));
500+
}
501+
502+
#[test]
503+
fn host_key_trust_missing_file_returns_embedded_only() {
504+
let trust = host_key_trust(
505+
"github.com",
506+
&Some(std::path::PathBuf::from("/this/path/does/not/exist")),
507+
)
508+
.expect("trust");
509+
assert_eq!(trust.fingerprints.len(), 3);
510+
assert!(trust.cert_authorities.is_empty());
511+
assert!(trust.revoked.is_empty());
512+
}
513+
514+
#[test]
515+
fn host_key_trust_empty_for_unknown_host_no_file() {
516+
// Unlike `fingerprints_for_host`, `host_key_trust` does NOT
517+
// error on an empty trust set — that is the caller's policy
518+
// call. This is the path the AcceptNew policy relies on.
519+
let trust = host_key_trust("git.example.com", &None).expect("trust");
520+
assert!(trust.fingerprints.is_empty());
521+
assert!(trust.cert_authorities.is_empty());
522+
assert!(trust.revoked.is_empty());
523+
}
337524
}

src/session.rs

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ struct GitwayHandler {
3838
/// — the handler will record the first fingerprint it sees in that
3939
/// case.
4040
fingerprints: Vec<String>,
41+
/// SHA-256 fingerprints explicitly revoked for this host (M14, FR-64).
42+
/// Checked **before** the policy and fingerprint paths: a presented
43+
/// key that hits one of these is rejected unconditionally — even
44+
/// [`StrictHostKeyChecking::No`] cannot override a `@revoked`
45+
/// entry.
46+
revoked: Vec<String>,
4147
/// Host-key verification policy (FR-8).
4248
policy: StrictHostKeyChecking,
4349
/// Hostname being connected to — needed by the
@@ -64,6 +70,7 @@ impl fmt::Debug for GitwayHandler {
6470
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6571
f.debug_struct("GitwayHandler")
6672
.field("fingerprints", &self.fingerprints)
73+
.field("revoked", &self.revoked)
6774
.field("policy", &self.policy)
6875
.field("host", &self.host)
6976
.field("custom_known_hosts", &self.custom_known_hosts)
@@ -83,8 +90,24 @@ impl client::Handler for GitwayHandler {
8390
let fp = server_public_key.fingerprint(HashAlg::Sha256).to_string();
8491
log::debug!("session: checking server host key {fp}");
8592

93+
// M14 / FR-64: a `@revoked` entry beats every other policy —
94+
// even `StrictHostKeyChecking::No` cannot override an explicit
95+
// revocation. This runs first so a compromised key can't be
96+
// accepted via the insecure-skip path.
97+
if self.revoked.iter().any(|r| r == &fp) {
98+
return Err(AnvilError::host_key_mismatch(fp.clone()).with_hint(format!(
99+
"{fp} is listed in a @revoked entry for {} in the known_hosts \
100+
file (M14, FR-64). Refusing the connection unconditionally — \
101+
the key has been explicitly blocklisted. Remove the @revoked \
102+
line if the revocation was a mistake, or rotate the upstream \
103+
host key.",
104+
self.host,
105+
)));
106+
}
107+
86108
// StrictHostKeyChecking=No: accept any key. Equivalent to the
87-
// 0.2.x `--insecure-skip-host-check` path.
109+
// 0.2.x `--insecure-skip-host-check` path. Reached only after
110+
// the `@revoked` check above.
88111
if matches!(self.policy, StrictHostKeyChecking::No) {
89112
log::warn!("host-key verification skipped (StrictHostKeyChecking=No)");
90113
if let Ok(mut guard) = self.verified_fingerprint.lock() {
@@ -201,35 +224,56 @@ impl AnvilSession {
201224
/// `auth_banner` / `verified_fingerprint` mutex pair.
202225
fn build_handler_pieces(config: &AnvilConfig) -> Result<HandlerPieces, AnvilError> {
203226
let russh_cfg = Arc::new(build_russh_config(config.inactivity_timeout));
204-
// For StrictHostKeyChecking=AcceptNew with a writable known_hosts
205-
// path, an empty fingerprint set is acceptable — the handler will
206-
// record the first fingerprint it sees. Every other policy
207-
// (Yes / No) treats the lookup error as fatal as before.
208-
let fingerprints =
209-
match hostkey::fingerprints_for_host(&config.host, &config.custom_known_hosts) {
210-
Ok(fps) => fps,
211-
Err(e) => {
212-
if matches!(
213-
config.strict_host_key_checking,
214-
StrictHostKeyChecking::AcceptNew
215-
) && config.custom_known_hosts.is_some()
216-
{
217-
log::info!(
218-
"session: no fingerprints known for {}; \
219-
accept-new will record on first connection",
220-
config.host,
221-
);
222-
Vec::new()
223-
} else {
224-
return Err(e);
225-
}
226-
}
227-
};
227+
// M14: pull the trust view (direct fingerprints + revoked
228+
// entries) in one pass. For
229+
// `StrictHostKeyChecking::AcceptNew` with a writable
230+
// `custom_known_hosts` path an empty fingerprint set is
231+
// tolerated — the handler will record the first fingerprint
232+
// it sees. Every other policy (Yes / No) treats a fully-
233+
// empty trust set as fatal, with the long-form hint copied
234+
// from `fingerprints_for_host`.
235+
let trust = hostkey::host_key_trust(&config.host, &config.custom_known_hosts)?;
236+
let revoked: Vec<String> = trust.revoked.into_iter().map(|r| r.fingerprint).collect();
237+
238+
let fingerprints = if !trust.fingerprints.is_empty() {
239+
trust.fingerprints
240+
} else if matches!(
241+
config.strict_host_key_checking,
242+
StrictHostKeyChecking::AcceptNew
243+
) && config.custom_known_hosts.is_some()
244+
{
245+
log::info!(
246+
"session: no fingerprints known for {}; \
247+
accept-new will record on first connection",
248+
config.host,
249+
);
250+
Vec::new()
251+
} else {
252+
return Err(AnvilError::invalid_config(format!(
253+
"no fingerprints known for host '{}'",
254+
config.host
255+
))
256+
.with_hint(format!(
257+
"Gitway refuses to connect to hosts whose SSH fingerprint it can't \
258+
verify (no trust-on-first-use). Either you typed the hostname wrong, \
259+
or this is a self-hosted server and you need to pin its fingerprint: \
260+
fetch it from the provider's docs (GitHub, GitLab, Codeberg publish \
261+
them) and append one line to ~/.config/gitway/known_hosts:\n\
262+
\n\
263+
{} SHA256:<base64-fingerprint>\n\
264+
\n\
265+
As a last resort, re-run with --insecure-skip-host-check (not \
266+
recommended — this disables MITM protection).",
267+
config.host,
268+
)));
269+
};
270+
228271
let auth_banner = Arc::new(Mutex::new(None));
229272
let verified_fingerprint = Arc::new(Mutex::new(None));
230273

231274
let handler = GitwayHandler {
232275
fingerprints,
276+
revoked,
233277
policy: config.strict_host_key_checking,
234278
host: config.host.clone(),
235279
custom_known_hosts: config.custom_known_hosts.clone(),

0 commit comments

Comments
 (0)