Files
nexus/rust/core/src/known_hosts.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

248 lines
9.8 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! OpenSSH `known_hosts` interchange and SSH certificate authorities.
//!
//! Imported host keys become ordinary known-host rows. `@cert-authority` lines become rows with
//! `key_type` [`CA_KEY_TYPE`], the host *pattern* as `host`, port 0, and the CA's public key in
//! OpenSSH form; a host certificate signed by a CA whose pattern matches the host is trusted
//! without a first-use prompt. Hashed entries (`|1|…`) can't be mapped back to a host name and
//! are skipped, as are `@revoked` lines.
use crate::models::KnownHost;
use russh::keys::{HashAlg, PublicKey};
/// `key_type` of a certificate-authority row.
pub const CA_KEY_TYPE: &str = "@cert-authority";
/// Longest host pattern accepted (OpenSSH's own line limit is 8 KiB; real patterns are tiny).
pub const MAX_PATTERN_LEN: usize = 1024;
#[derive(Debug, Default, PartialEq)]
pub struct Parsed {
pub hosts: Vec<(String, u16, PublicKey)>,
/// (host pattern, CA key)
pub authorities: Vec<(String, PublicKey)>,
/// Lines skipped: hashed host names, revocations, unparseable keys, wildcard host keys.
pub skipped: usize,
}
pub fn parse(text: &str) -> Parsed {
let mut out = Parsed::default();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let mut fields = line.split_whitespace().peekable();
let marker = fields.next_if(|f| f.starts_with('@'));
let (Some(hosts), Some(kind), Some(b64)) = (fields.next(), fields.next(), fields.next()) else {
out.skipped += 1;
continue;
};
let Ok(key) = PublicKey::from_openssh(&format!("{kind} {b64}")) else {
out.skipped += 1;
continue;
};
match marker {
Some("@cert-authority") if hosts.len() > MAX_PATTERN_LEN => out.skipped += 1,
Some("@cert-authority") => out.authorities.push((hosts.to_string(), key)),
Some(_) => out.skipped += 1,
None if hosts.starts_with('|') => out.skipped += 1,
None => {
for h in hosts.split(',') {
let (host, port) = split_host(h);
if host.contains(['*', '?', '!']) {
out.skipped += 1;
} else {
out.hosts.push((host.to_string(), port, key.clone()));
}
}
}
}
}
out
}
/// `[host]:port` → (host, port); a bare host is port 22.
fn split_host(h: &str) -> (&str, u16) {
if let Some(rest) = h.strip_prefix('[')
&& let Some((host, port)) = rest.split_once("]:")
&& let Ok(p) = port.parse()
{
return (host, p);
}
(h, 22)
}
/// OpenSSH form of a host: `host` on port 22, `[host]:port` otherwise.
fn host_field(host: &str, port: i64) -> String {
if port == 22 { host.to_string() } else { format!("[{host}]:{port}") }
}
/// Known-hosts lines for `rows` (SSH host keys and CAs; other protocols' certificates are left out).
pub fn export(rows: &[KnownHost]) -> String {
let mut out = String::new();
for kh in rows {
if kh.key_type == CA_KEY_TYPE {
out += &format!("@cert-authority {} {}\n", kh.host, kh.public_key);
} else if !kh.public_key.is_empty() && ["ssh-", "ecdsa-", "sk-"].iter().any(|p| kh.key_type.starts_with(p)) {
out += &format!("{} {} {}\n", host_field(&kh.host, kh.port), kh.key_type, kh.public_key);
}
}
out
}
/// A row for a CA under a host pattern.
pub fn authority_row(pattern: &str, key: &PublicKey) -> KnownHost {
let now = crate::models::now();
KnownHost {
id: String::new(),
host: pattern.into(),
port: 0,
key_type: CA_KEY_TYPE.into(),
fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
public_key: key.to_openssh().unwrap_or_default(),
first_seen: now,
last_seen: now,
trusted: true,
}
}
/// Whether `host:port` matches an OpenSSH host pattern list (`*`, `?`, `!negation`, commas). As in
/// OpenSSH, patterns match `host` for port 22 and `[host]:port` otherwise, so `[*.corp]:*`
/// covers every port.
pub fn pattern_matches(patterns: &str, host: &str, port: u16) -> bool {
let target = host_field(host, port.into()).to_ascii_lowercase();
let mut matched = false;
for p in patterns.split(',') {
let (neg, p) = p.strip_prefix('!').map_or((false, p), |r| (true, r));
if glob(&p.to_ascii_lowercase(), &target) {
if neg {
return false;
}
matched = true;
}
}
matched
}
/// Wildcard match with `*` and `?`, iterative and linear in `|p| + |s|` per star (the classic
/// two-pointer algorithm), so a pattern of a million stars or `*a*a*a…` can't recurse or
/// backtrack exponentially. Patterns come from imported files and typed input.
fn glob(p: &str, s: &str) -> bool {
let (p, s): (Vec<char>, Vec<char>) = (p.chars().collect(), s.chars().collect());
let (mut pi, mut si) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while si < s.len() {
match p.get(pi) {
Some('*') => {
star = Some((pi, si));
pi += 1;
}
Some(&c) if c == '?' || c == s[si] => {
pi += 1;
si += 1;
}
_ => match star {
Some((sp, ss)) => {
pi = sp + 1;
si = ss + 1;
star = Some((sp, ss + 1));
}
None => return false,
},
}
}
p[pi..].iter().all(|&c| c == '*')
}
/// CA keys trusted for `host:port`.
pub fn authorities_for(rows: &[KnownHost], host: &str, port: u16) -> Vec<PublicKey> {
rows.iter()
.filter(|r| r.key_type == CA_KEY_TYPE && pattern_matches(&r.host, host, port))
.filter_map(|r| PublicKey::from_openssh(&r.public_key).ok())
.collect()
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use super::*;
/// A real (deterministic) Ed25519 public key in OpenSSH form.
fn ed() -> String {
use russh::keys::ssh_key::private::Ed25519Keypair;
let kp = Ed25519Keypair::from_seed(&[7; 32]);
PublicKey::new(kp.public.into(), "").to_openssh().unwrap()
}
#[test]
fn parses_openssh_known_hosts() {
let ED = ed();
let text = format!(
"# comment\n\
github.com,140.82.112.3 {ED}\n\
[git.example.com]:2222 {ED} laptop\n\
|1|abc=|def= {ED}\n\
*.wild {ED}\n\
@cert-authority *.corp.example,!bad.corp.example {ED}\n\
@revoked old.example {ED}\n\
broken ssh-ed25519 notbase64\n"
);
let p = parse(&text);
let hosts: Vec<_> = p.hosts.iter().map(|(h, port, _)| (h.as_str(), *port)).collect();
assert_eq!(hosts, [("github.com", 22), ("140.82.112.3", 22), ("git.example.com", 2222)]);
assert_eq!(p.authorities.len(), 1);
assert_eq!(p.skipped, 4, "hashed, wildcard, revoked, broken");
assert_eq!(p.authorities[0].0, "*.corp.example,!bad.corp.example");
}
#[test]
fn host_patterns() {
let pats = "*.corp.example,!bad.corp.example,[jump.?.example]:2200,[*.any]:*";
assert!(pattern_matches(pats, "x.any", 2201) && !pattern_matches(pats, "x.any", 22), "[..]:* is any port but 22");
assert!(pattern_matches(pats, "web.corp.example", 22));
assert!(pattern_matches(pats, "WEB.Corp.Example", 22), "case-insensitive");
assert!(!pattern_matches(pats, "bad.corp.example", 22), "negated");
assert!(!pattern_matches(pats, "web.corp.example", 2222), "bare patterns are port 22");
assert!(pattern_matches(pats, "jump.a.example", 2200));
assert!(!pattern_matches(pats, "jump.ab.example", 2200));
assert!(!pattern_matches(pats, "corp.example", 22));
}
#[test]
fn hostile_patterns_match_in_linear_time() {
// The old recursive matcher overflowed the stack on a long run of stars and went
// exponential on `*a*a*a…b`; both must now be instant (and run on a small stack).
let t = std::thread::Builder::new()
.stack_size(256 << 10)
.spawn(|| {
let start = std::time::Instant::now();
assert!(pattern_matches(&"*".repeat(200_000), "db.corp", 22));
assert!(!pattern_matches("*a*a*a*a*a*a*a*a*a*a*b", &"a".repeat(253), 22));
assert!(pattern_matches("*a*a*a*b?", &format!("{}bx", "a".repeat(253)), 22));
assert!(!pattern_matches("a*c", "ab", 22) && pattern_matches("a*", "a", 22) && !pattern_matches("a?", "a", 22));
start.elapsed()
})
.unwrap()
.join()
.unwrap();
assert!(t < std::time::Duration::from_millis(500), "{t:?}");
// Over-long CA patterns in an imported file are skipped rather than stored.
let p = parse(&format!("@cert-authority {} {}\n", "*".repeat(MAX_PATTERN_LEN + 1), ed()));
assert!(p.authorities.is_empty() && p.skipped == 1);
}
#[test]
fn exports_what_it_imports() {
let ED = ed();
let p = parse(&format!("[h.example]:2022 {ED}\n@cert-authority *.corp {ED}\n"));
let (h, port, key) = &p.hosts[0];
let mut row = authority_row("x", key);
row.host = h.clone();
row.port = *port as i64;
row.key_type = key.algorithm().as_str().into();
row.public_key = ED.split_whitespace().nth(1).unwrap().into();
let ca = authority_row(&p.authorities[0].0, &p.authorities[0].1);
let out = export(&[row, ca.clone()]);
assert_eq!(out, format!("[h.example]:2022 {ED}\n@cert-authority *.corp {ED}\n"));
assert_eq!(authorities_for(&[ca], "db.corp", 22).len(), 1);
}
}