Skip to main content

anvil_ssh/
cert_authority.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! `@cert-authority` and `@revoked` markers in `known_hosts`-style files
4//! (PRD §5.8.3 / FR-60, FR-64).
5//!
6//! M14 ships the *parsing* surface plus the M14.2 revoked-key
7//! enforcement in [`crate::session::AnvilSession::check_server_key`].
8//! The actual cert-during-handshake verification (FR-61, FR-62, FR-63)
9//! is deferred until russh exposes the server's certificate to the
10//! `check_server_key` callback — russh 0.59's KEX negotiation does not
11//! advertise `*-cert-v01@openssh.com` as a host-key algorithm, so the
12//! callback only ever sees plain public keys. See the M14 plan for the
13//! upstream blocker.
14//!
15//! # File format
16//!
17//! Three line shapes are recognized:
18//!
19//! ```text
20//! # Direct fingerprint (Anvil convention, predates M14):
21//! github.com SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s
22//!
23//! # Cert-authority CA pubkey (OpenSSH convention):
24//! @cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZD... ca-key
25//!
26//! # Revoked specific key (Anvil shorthand: SHA256: form):
27//! @revoked example.com SHA256:abcd...
28//! ```
29//!
30//! Multiple comma-separated host patterns on one line are split into
31//! 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
35use russh::keys::{ssh_key::PublicKey, HashAlg};
36
37use crate::error::AnvilError;
38
39/// One `@cert-authority` line: a CA public key plus the host pattern
40/// it applies to.
41///
42/// Comma-separated patterns on the source line produce one
43/// [`CertAuthority`] per pattern, sharing the underlying pubkey blob.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CertAuthority {
46    /// Raw glob pattern from the `known_hosts` line, e.g. `*.example.com`
47    /// or `bastion`.  Compared with [`crate::ssh_config::lexer::wildcard_match`]
48    /// at lookup time.
49    pub host_pattern: String,
50    /// Algorithm string ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", …).
51    pub algorithm: String,
52    /// SHA-256 fingerprint of the CA pubkey, in OpenSSH format
53    /// (`SHA256:base64...`).  Surfaces in `gitway config show --json`
54    /// for audit and acts as the canonical identity of the CA.
55    pub fingerprint: String,
56    /// Re-serialised OpenSSH public key string (`algorithm AAAA...
57    /// comment`).  Preserved verbatim so downstream cert-validation
58    /// (deferred to russh upstream) can re-parse without round-tripping
59    /// through a wire-format blob.
60    pub openssh: String,
61}
62
63/// One `@revoked` line: a specific key fingerprint blocklisted for the
64/// matching host pattern.
65///
66/// The Anvil shorthand uses the `SHA256:...` fingerprint form rather
67/// than the full OpenSSH pubkey blob — this matches the rest of the
68/// `known_hosts` file's existing convention.  OpenSSH's full
69/// pubkey-blob form (`@revoked host algorithm AAAA...`) is documented
70/// as a follow-up if users ask.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RevokedEntry {
73    /// Raw glob pattern.  `*` to revoke unconditionally.
74    pub host_pattern: String,
75    /// Fingerprint string, e.g. `SHA256:uNiVztksCs...`.  Compared
76    /// case-sensitively against the presented key's fingerprint.
77    pub fingerprint: String,
78}
79
80/// One direct host-fingerprint pin (`host SHA256:fp`).  Predates M14;
81/// kept here so [`parse_known_hosts`] can return everything in one
82/// pass instead of forcing the caller to re-iterate the file.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DirectHostKey {
85    pub host_pattern: String,
86    pub fingerprint: String,
87}
88
89/// Fully-parsed view of one `known_hosts`-style file.
90///
91/// Returned by [`parse_known_hosts`].  Empty vectors are the natural
92/// state when a file contains no entries of that class.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct KnownHostsFile {
95    pub direct: Vec<DirectHostKey>,
96    pub cert_authorities: Vec<CertAuthority>,
97    pub revoked: Vec<RevokedEntry>,
98}
99
100/// Parses `content` (the contents of a `known_hosts`-style file) into
101/// the three classes of entries Anvil understands.
102///
103/// Errors only on hard malformation — a `@cert-authority` line whose
104/// pubkey string cannot be parsed as OpenSSH format.  Direct-fingerprint
105/// lines that do not split into `host fingerprint` are silently skipped
106/// (matches the pre-M14 lenient parser).
107///
108/// # Errors
109/// [`AnvilError::invalid_config`] when a `@cert-authority` pubkey
110/// string fails to parse as OpenSSH (e.g. unknown algorithm, malformed
111/// base64).
112pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
113    let mut out = KnownHostsFile::default();
114
115    for (idx, raw) in content.lines().enumerate() {
116        let line = raw.trim();
117        if line.is_empty() || line.starts_with('#') {
118            continue;
119        }
120        let line_no = idx + 1;
121
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
129        if let Some(rest) = strip_marker_ci(line, "@cert-authority") {
130            parse_cert_authority_line(rest, line_no, &mut out)?;
131            continue;
132        }
133        if let Some(rest) = strip_marker_ci(line, "@revoked") {
134            parse_revoked_line(rest, line_no, &mut out);
135            continue;
136        }
137
138        // Plain direct line: `host[,host2,…] SHA256:fp`.
139        let mut parts = line.splitn(2, char::is_whitespace);
140        let Some(host_part) = parts.next() else {
141            continue;
142        };
143        let Some(fp_part) = parts.next() else {
144            continue;
145        };
146        let fp = fp_part.trim();
147        if fp.is_empty() {
148            continue;
149        }
150        for host in split_host_patterns(host_part) {
151            out.direct.push(DirectHostKey {
152                host_pattern: host,
153                fingerprint: fp.to_owned(),
154            });
155        }
156    }
157
158    Ok(out)
159}
160
161/// Returns the rest of `line` after `marker`, but only if `marker`
162/// appears at the start of `line` followed by whitespace
163/// (case-insensitive on the marker itself, matching OpenSSH).
164fn strip_marker_ci<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
165    if line.len() <= marker.len() {
166        return None;
167    }
168    let head = line.get(..marker.len())?;
169    if !head.eq_ignore_ascii_case(marker) {
170        return None;
171    }
172    let rest = &line[marker.len()..];
173    let trimmed = rest.trim_start();
174    if !rest.starts_with(char::is_whitespace) || trimmed.is_empty() {
175        // `@cert-authorityFOO ...` — must be `@cert-authority<space>...`.
176        return None;
177    }
178    Some(trimmed)
179}
180
181/// Parses the body of a `@cert-authority` line (everything after the
182/// marker token + whitespace).  Format: `host_pattern[s] algorithm
183/// AAAA... [comment]`.
184fn parse_cert_authority_line(
185    rest: &str,
186    line_no: usize,
187    out: &mut KnownHostsFile,
188) -> Result<(), AnvilError> {
189    let mut parts = rest.splitn(2, char::is_whitespace);
190    let Some(host_part) = parts.next() else {
191        return Err(AnvilError::invalid_config(format!(
192            "known_hosts:{line_no}: @cert-authority line missing host pattern",
193        )));
194    };
195    let Some(key_part) = parts.next() else {
196        return Err(AnvilError::invalid_config(format!(
197            "known_hosts:{line_no}: @cert-authority line missing pubkey",
198        )));
199    };
200
201    let key_part = key_part.trim();
202    let pk = PublicKey::from_openssh(key_part).map_err(|e| {
203        AnvilError::invalid_config(format!(
204            "known_hosts:{line_no}: failed to parse @cert-authority pubkey: {e}",
205        ))
206    })?;
207    let algorithm = pk.algorithm().as_str().to_owned();
208    let fingerprint = pk.fingerprint(HashAlg::Sha256).to_string();
209
210    for host in split_host_patterns(host_part) {
211        out.cert_authorities.push(CertAuthority {
212            host_pattern: host,
213            algorithm: algorithm.clone(),
214            fingerprint: fingerprint.clone(),
215            openssh: key_part.to_owned(),
216        });
217    }
218    Ok(())
219}
220
221/// Parses the body of a `@revoked` line.  Format:
222/// `host_pattern[s] SHA256:fingerprint`.
223fn parse_revoked_line(rest: &str, line_no: usize, out: &mut KnownHostsFile) {
224    let mut parts = rest.splitn(2, char::is_whitespace);
225    let Some(host_part) = parts.next() else {
226        log::warn!("known_hosts:{line_no}: @revoked line missing host pattern");
227        return;
228    };
229    let Some(fp_part) = parts.next() else {
230        log::warn!("known_hosts:{line_no}: @revoked line missing fingerprint");
231        return;
232    };
233    let fp = fp_part.trim();
234    if fp.is_empty() {
235        log::warn!("known_hosts:{line_no}: @revoked line has empty fingerprint");
236        return;
237    }
238    for host in split_host_patterns(host_part) {
239        out.revoked.push(RevokedEntry {
240            host_pattern: host,
241            fingerprint: fp.to_owned(),
242        });
243    }
244}
245
246/// Splits a comma-separated host-pattern column into individual
247/// patterns, trimming whitespace and skipping empties.
248fn split_host_patterns(column: &str) -> Vec<String> {
249    column
250        .split(',')
251        .map(str::trim)
252        .filter(|s| !s.is_empty())
253        .map(str::to_owned)
254        .collect()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn empty_input_yields_default() {
263        let parsed = parse_known_hosts("").expect("empty");
264        assert_eq!(parsed, KnownHostsFile::default());
265    }
266
267    #[test]
268    fn comments_and_blanks_skipped() {
269        let parsed = parse_known_hosts(
270            "# top comment\n\
271             \n\
272             # another\n",
273        )
274        .expect("parse");
275        assert_eq!(parsed, KnownHostsFile::default());
276    }
277
278    #[test]
279    fn direct_fingerprint_line() {
280        let parsed =
281            parse_known_hosts("github.com SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s\n")
282                .expect("parse");
283        assert_eq!(parsed.direct.len(), 1);
284        assert_eq!(parsed.direct[0].host_pattern, "github.com");
285        assert_eq!(
286            parsed.direct[0].fingerprint,
287            "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s",
288        );
289        assert!(parsed.cert_authorities.is_empty());
290        assert!(parsed.revoked.is_empty());
291    }
292
293    #[test]
294    fn comma_separated_hosts_split_into_multiple_entries() {
295        let parsed =
296            parse_known_hosts("github.com,gitlab.com,codeberg.org SHA256:abcd\n").expect("parse");
297        assert_eq!(parsed.direct.len(), 3);
298        let hosts: Vec<&str> = parsed
299            .direct
300            .iter()
301            .map(|d| d.host_pattern.as_str())
302            .collect();
303        assert_eq!(hosts, vec!["github.com", "gitlab.com", "codeberg.org"]);
304    }
305
306    #[test]
307    fn cert_authority_line_parsed() {
308        // Real ed25519 pubkey blob (32-byte point base64-encoded with the
309        // "ssh-ed25519" header).  Doubles as a roundtrip check that
310        // ssh_key::PublicKey accepts the input we emit.
311        let parsed = parse_known_hosts(
312            "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca-key\n",
313        )
314        .expect("parse");
315        assert_eq!(parsed.cert_authorities.len(), 1);
316        let ca = &parsed.cert_authorities[0];
317        assert_eq!(ca.host_pattern, "*.example.com");
318        assert_eq!(ca.algorithm, "ssh-ed25519");
319        assert!(
320            ca.fingerprint.starts_with("SHA256:"),
321            "expected SHA256 fp, got: {}",
322            ca.fingerprint,
323        );
324        assert!(parsed.direct.is_empty());
325        assert!(parsed.revoked.is_empty());
326    }
327
328    #[test]
329    fn cert_authority_marker_case_insensitive() {
330        let parsed = parse_known_hosts(
331            "@CERT-AUTHORITY *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
332        )
333        .expect("parse");
334        assert_eq!(parsed.cert_authorities.len(), 1);
335    }
336
337    #[test]
338    fn cert_authority_invalid_pubkey_errors() {
339        let err = parse_known_hosts("@cert-authority *.example.com ssh-ed25519 not-base64-data\n")
340            .expect_err("malformed pubkey");
341        let msg = format!("{err}");
342        assert!(
343            msg.contains("@cert-authority"),
344            "expected error to mention @cert-authority, got: {msg}",
345        );
346    }
347
348    #[test]
349    fn revoked_line_parsed() {
350        let parsed =
351            parse_known_hosts("@revoked example.com SHA256:abcdefghijklmnop\n").expect("parse");
352        assert_eq!(parsed.revoked.len(), 1);
353        assert_eq!(parsed.revoked[0].host_pattern, "example.com");
354        assert_eq!(parsed.revoked[0].fingerprint, "SHA256:abcdefghijklmnop");
355        assert!(parsed.direct.is_empty());
356        assert!(parsed.cert_authorities.is_empty());
357    }
358
359    #[test]
360    fn revoked_marker_case_insensitive() {
361        let parsed = parse_known_hosts("@REVOKED * SHA256:a\n").expect("parse");
362        assert_eq!(parsed.revoked.len(), 1);
363        assert_eq!(parsed.revoked[0].host_pattern, "*");
364    }
365
366    #[test]
367    fn revoked_with_comma_hosts() {
368        let parsed =
369            parse_known_hosts("@revoked a.example.com,b.example.com SHA256:abc\n").expect("parse");
370        assert_eq!(parsed.revoked.len(), 2);
371        assert_eq!(parsed.revoked[0].host_pattern, "a.example.com");
372        assert_eq!(parsed.revoked[1].host_pattern, "b.example.com");
373    }
374
375    #[test]
376    fn revoked_missing_fingerprint_logged_and_skipped() {
377        // Truncated `@revoked example.com` (no fingerprint) — soft-skip
378        // with a warn rather than error: matches the leniency of the
379        // existing direct-fingerprint parser.
380        let parsed = parse_known_hosts("@revoked example.com\n").expect("parse");
381        assert!(parsed.revoked.is_empty());
382    }
383
384    #[test]
385    fn hashed_entry_skipped_silently() {
386        let parsed = parse_known_hosts(
387            "|1|abcdef==|fedcba== ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
388        )
389        .expect("parse");
390        // We don't try to decode hashed entries; they just don't
391        // contribute.  Documented as a follow-up.
392        assert!(parsed.direct.is_empty());
393        assert!(parsed.cert_authorities.is_empty());
394    }
395
396    #[test]
397    fn mixed_file_three_classes() {
398        let parsed = parse_known_hosts(
399            "# header\n\
400             github.com SHA256:fp1\n\
401             @cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n\
402             @revoked github.com SHA256:bad-fp\n\
403             gitlab.com SHA256:fp2\n",
404        )
405        .expect("parse");
406        assert_eq!(parsed.direct.len(), 2);
407        assert_eq!(parsed.cert_authorities.len(), 1);
408        assert_eq!(parsed.revoked.len(), 1);
409        assert_eq!(parsed.direct[0].host_pattern, "github.com");
410        assert_eq!(parsed.direct[1].host_pattern, "gitlab.com");
411        assert_eq!(parsed.cert_authorities[0].host_pattern, "*.example.com");
412        assert_eq!(parsed.revoked[0].host_pattern, "github.com");
413    }
414
415    #[test]
416    fn marker_without_trailing_space_not_treated_as_marker() {
417        // `@cert-authoritySomething` should NOT match the marker — the
418        // marker requires whitespace after.  Such a line is treated as
419        // a malformed direct line and silently skipped.
420        let parsed = parse_known_hosts("@cert-authoritynot-a-marker\n").expect("parse");
421        assert_eq!(parsed, KnownHostsFile::default());
422    }
423
424    #[test]
425    fn whitespace_around_fields_tolerated() {
426        let parsed = parse_known_hosts("  github.com\tSHA256:fp\n").expect("parse");
427        assert_eq!(parsed.direct.len(), 1);
428        assert_eq!(parsed.direct[0].host_pattern, "github.com");
429        assert_eq!(parsed.direct[0].fingerprint, "SHA256:fp");
430    }
431}