From 0fc9451583fc572cd0fa65fce7ed518f71f6a997 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 19:02:59 +0100 Subject: [PATCH] feat: SSH certificates and known_hosts import/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Host certificates: certificate authorities are trusted per OpenSSH host pattern (stored as @cert-authority known-hosts rows, matched against 'host' or '[host]:port' as OpenSSH does, with *, ? and !). For a host a CA covers, the client asks for a certificate; one signed by a matching CA, of host type, naming the host and in date is trusted without a first-use prompt, and anything else is refused. Hosts no CA covers keep the existing key checks. Applies to jump hosts and tunnels too. - User certificates: -cert.pub next to the configured private key is used for publickey login (falling back to the plain key). - Settings → Known Hosts: import ~/.ssh/known_hosts (hashed names and @revoked lines skipped and counted), export in OpenSSH format, and a Trust a CA form. Verified against OpenSSH sshd with ssh-keygen-issued certificates: host cert accepted without prompting and user-cert login with only TrustedUserCAKeys; a CA for other hosts falls back to the key check; a different CA is refused. --- docs/overview.md | 2 + docs/user-manual.md | 4 + rust/app/src/settings.rs | 58 +++++++- rust/core/src/clients/ssh.rs | 70 +++++++++- rust/core/src/known_hosts.rs | 198 ++++++++++++++++++++++++++++ rust/core/src/lib.rs | 1 + rust/core/src/service.rs | 59 +++++++++ rust/core/src/sessions/protocols.rs | 7 + rust/core/tests/ssh_integration.rs | 52 +++++++- 9 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 rust/core/src/known_hosts.rs diff --git a/docs/overview.md b/docs/overview.md index c120e5c..8a948e0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -80,6 +80,8 @@ Use Nexus Terminal when you need to: ### SSH capabilities +- SSH certificates: host certificates from trusted CAs (by OpenSSH host pattern) skip the first-use prompt; user certificates (`*-cert.pub`) are used for login. +- Import and export `~/.ssh/known_hosts`, including `@cert-authority` lines. - Password, public key, keyboard-interactive and SSH agent authentication. - SSH agent forwarding. - Private key suggestions from `~/.ssh`, with a key file check. diff --git a/docs/user-manual.md b/docs/user-manual.md index cc6078b..73b0f55 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -405,6 +405,10 @@ Choose one of the following policies per SSH profile: - **Accept new, reject changed**: trust first-time keys automatically. - **Insecure (accept anything)**: accept any key (not recommended). +**Settings → Known Hosts** can **Import ~/.ssh/known_hosts** (hashed host names can't be read back and are skipped) and **Export…** the list in OpenSSH format, so Nexus Terminal and `ssh` can share what they trust. + +**SSH certificates.** **Trust a CA…** adds a certificate authority for a host pattern in OpenSSH syntax (`*.example.com`, `[host]:2222`, `!excluded.example.com`; `@cert-authority` lines in an imported known_hosts work too). Servers covered by a trusted CA are asked for their host certificate, and a certificate that the CA signed, that names the host and that is in date is trusted without a first-use prompt; any other certificate is refused. For logging in, a user certificate next to your key (`id_ed25519-cert.pub` beside `id_ed25519`) is used automatically. + Under every policy except Insecure, a key that differs from the saved one is rejected. Nexus Terminal keeps its own known-hosts list and does not read `~/.ssh/known_hosts`. Review and remove entries in **Settings → Known Hosts**; click a fingerprint to copy it. ### TOTP / 2FA diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 5dc54b1..2034b32 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -102,6 +102,8 @@ pub struct Settings { prefs: AppPreferences, theme: ThemeKind, known: Vec, + /// Add-CA form: (host pattern, public key), shown when Some. + new_ca: Option<(String, String)>, default_shell: String, help_topic: usize, master: Option, @@ -444,6 +446,58 @@ impl Settings { fn known_hosts(&mut self, ui: &mut Ui, s: &mut Shared) { let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + if widgets::button(ui, icon::DOWNLOAD_SIMPLE, "Import ~/.ssh/known_hosts").clicked() { + match s.svc.import_known_hosts(None) { + Ok((n, ca, skipped)) => { + let extra = if skipped > 0 { format!(" ({skipped} hashed or unsupported line(s) skipped)") } else { String::new() }; + s.success(format!("Imported {n} host key(s) and {ca} certificate authorit{}{extra}", if ca == 1 { "y" } else { "ies" })); + self.known = s.svc.known_hosts(); + } + Err(e) => s.error(format!("Import failed: {e}")), + } + } + if widgets::button(ui, icon::UPLOAD_SIMPLE, "Export…").clicked() + && let Some(path) = rfd::FileDialog::new().set_file_name("known_hosts").save_file() + { + match s.svc.export_known_hosts(&path) { + Ok(n) => s.success(format!("Wrote {n} line(s) to {}", path.display())), + Err(e) => s.error(format!("Export failed: {e}")), + } + } + if widgets::button(ui, icon::SEAL_CHECK, "Trust a CA…").on_hover_text("Trust SSH host certificates signed by a certificate authority").clicked() { + self.new_ca = Some(Default::default()); + } + }); + let (mut done, mut added) = (false, false); + if let Some((pattern, key)) = &mut self.new_ca { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(RichText::new("Trust an SSH certificate authority").font(theme::semibold(13.0))); + widgets::hint(ui, "Servers whose host certificate this CA signed are trusted without a first-use prompt. Patterns use OpenSSH syntax: *.example.com, [host]:2222, !exclude."); + ui.add(egui::TextEdit::singleline(pattern).hint_text("*.example.com").desired_width(320.0)); + ui.add(egui::TextEdit::multiline(key).hint_text("ssh-ed25519 AAAA… (the CA's public key)").desired_rows(2).desired_width(f32::INFINITY)); + ui.horizontal(|ui| { + if widgets::primary(ui, icon::CHECK, "Trust").clicked() { + match s.svc.add_cert_authority(pattern, key) { + Ok(()) => { + s.success("Certificate authority trusted"); + (done, added) = (true, true); + } + Err(e) => s.error(e.to_string()), + } + } else if ui.button("Cancel").clicked() { + done = true; + } + }); + }); + } + if done { + self.new_ca = None; + } + if added { + self.known = s.svc.known_hosts(); + } + ui.add_space(6.0); if self.known.is_empty() { widgets::empty(ui, icon::FINGERPRINT, "No known hosts recorded", "Host keys are recorded the first time you connect over SSH."); return; @@ -462,7 +516,7 @@ impl Settings { ui.end_row(); for kh in &self.known { ui.label(&kh.host); - ui.label(kh.port.to_string()); + ui.label(if kh.port == 0 { "—".to_string() } else { kh.port.to_string() }); ui.label(RichText::new(&kh.key_type).font(theme::mono(11.5))); let short: String = kh.fingerprint.chars().take(24).collect(); let fp = ui.add(egui::Label::new(RichText::new(format!("{short}…")).font(theme::mono(11.5))).sense(egui::Sense::click())); @@ -839,7 +893,7 @@ TOTP secrets for SSH two-factor authentication are stored alongside your other c ## Known Hosts When you connect to an SSH server for the first time, Nexus Terminal records the server's fingerprint. If the fingerprint changes on a later connection, you'll be warned — this protects against man-in-the-middle attacks. -You can review and remove known hosts in **Settings → Known Hosts**. +You can review and remove known hosts in **Settings → Known Hosts**, import or export them in OpenSSH known_hosts format, and trust SSH certificate authorities for host patterns. ## Encrypted Export When exporting profiles, encrypted export (a `.nexus` file) protects the file with a password, using the same AES-256-GCM encryption as the vault.", ), diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs index 31ed87a..a839b94 100644 --- a/rust/core/src/clients/ssh.rs +++ b/rust/core/src/clients/ssh.rs @@ -61,6 +61,22 @@ struct Handler { port: u16, check: HostKeyCheck, shared: Arc, + /// Certificate authorities trusted for this host (from `@cert-authority` known-hosts rows). + cas: Vec, +} + +/// A host certificate is trusted when a CA for this host signed it, it is a host certificate, +/// it names the host as a principal and it is inside its validity window. +fn check_host_certificate(cert: &russh::keys::Certificate, cas: &[PublicKey], host: &str) -> std::result::Result<(), String> { + use russh::keys::ssh_key::certificate::CertType; + if cert.cert_type() != CertType::Host { + return Err(format!("{host} presented a user certificate as its host key")); + } + if !cert.valid_principals().iter().any(|p| p.eq_ignore_ascii_case(host)) { + return Err(format!("host certificate for {host} doesn't list it (principals: {})", cert.valid_principals().join(", "))); + } + let fps: Vec<_> = cas.iter().map(|k| k.fingerprint(HashAlg::Sha256)).collect(); + cert.validate(&fps).map_err(|_| format!("host certificate for {host} isn't signed by a trusted CA, or is expired")) } impl client::Handler for Handler { @@ -69,7 +85,19 @@ impl client::Handler for Handler { async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> std::result::Result { let key = match key { russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), - russh::keys::PublicKeyOrCertificate::Certificate(c) => c.public_key().clone().into(), + // Only asked for when a CA covers this host, so it must check out against one. + russh::keys::PublicKeyOrCertificate::Certificate(c) => { + return match check_host_certificate(c, &self.cas, &self.host) { + Ok(()) => { + tracing::info!(host = %self.host, ca = %c.signature_key().fingerprint(HashAlg::Sha256), "host certificate accepted"); + Ok(true) + } + Err(why) => { + *self.shared.rejection.lock() = Some(why); + Ok(false) + } + }; + } }; let info = host_key_info(&self.host, self.port, &key); let check = self.check.clone(); @@ -201,6 +229,15 @@ fn base32_decode(s: &str) -> Option> { } /// Reads an OpenSSH/PEM private key, asking for a passphrase only if it's encrypted. +/// The OpenSSH user certificate next to a private key (`id_ed25519` → `id_ed25519-cert.pub`). +fn load_user_certificate(key_path: &str) -> Option { + if key_path.is_empty() { + return None; + } + let text = std::fs::read_to_string(format!("{key_path}-cert.pub")).ok().filter(|t| t.len() < 64 << 10)?; + russh::keys::Certificate::from_openssh(text.trim()).ok() +} + pub fn load_private_key(path: &str, passphrase: Option<&SecretFn>) -> Result { let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?; if meta.is_dir() { @@ -240,8 +277,12 @@ async fn dial(sc: &SshConfig, proxy_password: &str, host: &str, port: u16, timeo Ok(tokio::net::TcpStream::from_std(s)?) } -fn config(sc: &SshConfig) -> Arc { +/// `host_certificates`: ask for a host certificate first (when a CA applies), as OpenSSH does. +fn config(sc: &SshConfig, host_certificates: bool) -> Arc { let mut c = client::Config::default(); + if host_certificates { + c.preferred.host_key_certificates = c.preferred.key.clone(); + } if sc.keepalive_interval > 0 { c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64)); c.keepalive_max = sc.keepalive_count_max.max(1) as usize; @@ -260,6 +301,8 @@ struct Auth<'a> { methods: Vec, agent: bool, key: Option, + /// `-cert.pub` next to the private key: log in with the certificate instead. + cert: Option, password: Option, totp_secret: String, } @@ -282,6 +325,11 @@ async fn authenticate(h: &mut Handle, a: Auth<'_>) -> Result<()> { for m in &a.methods { match m.as_str() { "publickey" => { + if let (Some(k), Some(cert)) = (&a.key, &a.cert) + && h.authenticate_openssh_cert(a.user, Arc::new(k.clone()), cert.clone()).await?.success() + { + return Ok(()); + } if let Some(k) = &a.key { let hash = if k.algorithm().is_rsa() { h.best_supported_rsa_hash().await?.flatten() } else { None }; if h.authenticate_publickey(a.user, PrivateKeyWithHashAlg::new(Arc::new(k.clone()), hash)).await?.success() { @@ -349,6 +397,8 @@ pub struct SshClient { /// Credentials that live in the secret store rather than the profile: (TOTP seed, proxy /// password). Set by the session manager before connecting. stored: Mutex<(Zeroizing, Zeroizing)>, + /// `@cert-authority` known-hosts rows (CA keys by host pattern), for host certificates. + authorities: Mutex>, } impl SshClient { @@ -364,9 +414,15 @@ impl SshClient { sftp: tokio::sync::Mutex::new(None), closed: false.into(), stored: Mutex::default(), + authorities: Mutex::default(), }) } + /// Certificate authorities (known-hosts CA rows) to trust for host certificates. + pub fn set_authorities(&self, rows: Vec) { + *self.authorities.lock() = rows; + } + /// The TOTP seed (for keyboard-interactive 2FA prompts) and proxy password to use. pub fn set_stored_secrets(&self, totp_secret: String, proxy_password: String) { *self.stored.lock() = (Zeroizing::new(totp_secret), Zeroizing::new(proxy_password)); @@ -414,9 +470,11 @@ impl SshClient { port: u16, check: &HostKeyCheck, ) -> Result> { - let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone() }; + let cas = crate::known_hosts::authorities_for(&self.authorities.lock(), host, port); + let cfg = config(&self.sc(), !cas.is_empty()); + let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone(), cas }; let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64); - match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await { + match tokio::time::timeout(timeout, client::connect_stream(cfg, stream, handler)).await { Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()), Ok(Ok(h)) => Ok(h), Ok(Err(e)) => match self.shared.rejection.lock().take() { @@ -461,7 +519,8 @@ impl SshClient { self.handshake(ch.into_stream(), &p.host, port, &check).await? }; - authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret }).await?; + let cert = load_user_certificate(&sc.private_key_path); + authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, cert, password, totp_secret }).await?; let handle = Arc::new(handle); if !shell { *self.handle.lock() = Some(handle); @@ -558,6 +617,7 @@ impl SshClient { methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, + cert: None, password: None, totp_secret: String::new(), }, diff --git a/rust/core/src/known_hosts.rs b/rust/core/src/known_hosts.rs new file mode 100644 index 0000000..d6c1ef7 --- /dev/null +++ b/rust/core/src/known_hosts.rs @@ -0,0 +1,198 @@ +//! 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"; + +#[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") => 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 +} + +fn glob(p: &str, s: &str) -> bool { + match (p.chars().next(), s.chars().next()) { + (None, None) => true, + (Some('*'), _) => glob(&p[1..], s) || (!s.is_empty() && glob(p, &s[s.chars().next().unwrap().len_utf8()..])), + (Some('?'), Some(c)) => glob(&p[1..], &s[c.len_utf8()..]), + (Some(a), Some(b)) if a == b => glob(&p[a.len_utf8()..], &s[b.len_utf8()..]), + _ => false, + } +} + +/// CA keys trusted for `host:port`. +pub fn authorities_for(rows: &[KnownHost], host: &str, port: u16) -> Vec { + 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 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); + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 1220562..41db584 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod exportimport; pub mod ircformat; pub mod keyfiles; +pub mod known_hosts; pub mod models; pub mod recording; pub mod secrets; diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index e5a65f7..48e3914 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -165,6 +165,65 @@ impl Service { self.store.delete_known_host(id) } + /// Imports an OpenSSH known_hosts file (default ~/.ssh/known_hosts); returns (host keys, + /// certificate authorities, lines skipped). Existing entries for the same host and port are + /// replaced. + pub fn import_known_hosts(&self, path: Option<&Path>) -> Result<(usize, usize, usize)> { + let path = match path { + Some(p) => p.to_path_buf(), + None => Self::ssh_dir()?.join("known_hosts"), + }; + if std::fs::metadata(&path)?.len() > 5 << 20 { + return Err("known_hosts file is too large".into()); + } + let parsed = crate::known_hosts::parse(&std::fs::read_to_string(&path)?); + for (host, port, key) in &parsed.hosts { + let i = crate::clients::ssh::host_key_info(host, *port, key); + let n = now(); + self.store.save_known_host(&KnownHost { + id: String::new(), + host: i.host, + port: i.port as i64, + key_type: i.key_type, + fingerprint: i.fingerprint, + public_key: i.public_key_b64, + first_seen: n, + last_seen: n, + trusted: true, + })?; + } + for (pattern, key) in &parsed.authorities { + self.store.save_known_host(&crate::known_hosts::authority_row(pattern, key))?; + } + self.store.add_audit( + "knownhosts.import", + "", + "", + &format!("Imported {} host key(s) and {} CA(s) from {}", parsed.hosts.len(), parsed.authorities.len(), path.display()), + ); + Ok((parsed.hosts.len(), parsed.authorities.len(), parsed.skipped)) + } + + /// Writes the SSH known hosts and CAs in OpenSSH known_hosts format. + pub fn export_known_hosts(&self, path: &Path) -> Result { + let rows = self.store.list_known_hosts()?; + let text = crate::known_hosts::export(&rows); + std::fs::write(path, &text)?; + Ok(text.lines().count()) + } + + /// Trusts an SSH certificate authority for hosts matching `pattern` (OpenSSH syntax). + pub fn add_cert_authority(&self, pattern: &str, public_key: &str) -> Result<()> { + let pattern = pattern.trim(); + if pattern.is_empty() || pattern.contains(char::is_whitespace) { + return Err("the host pattern must be one word, e.g. *.example.com".into()); + } + let key = russh::keys::PublicKey::from_openssh(public_key.trim()).map_err(|e| format!("not an OpenSSH public key: {e}"))?; + self.store.save_known_host(&crate::known_hosts::authority_row(pattern, &key))?; + self.store.add_audit("knownhosts.ca", "", "", &format!("Trusted SSH CA {} for {pattern}", key.fingerprint(russh::keys::HashAlg::Sha256))); + Ok(()) + } + pub fn preferences(&self) -> AppPreferences { self.store.preferences() } diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index cea8152..718880a 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -3,6 +3,11 @@ use super::*; impl Manager { + /// Known-hosts rows that are SSH certificate authorities. + pub(super) fn cert_authorities(&self) -> Vec { + self.store.list_known_hosts().unwrap_or_default().into_iter().filter(|k| k.key_type == crate::known_hosts::CA_KEY_TYPE).collect() + } + // --- per-protocol connect ----------------------------------------------------------- pub(super) fn connect_ssh(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { @@ -11,6 +16,7 @@ impl Manager { let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone()); let passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt); let c = SshClient::new(&p, self.terminal_callbacks(id, epoch)); + c.set_authorities(self.cert_authorities()); let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).unwrap_or_default(); c.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword)); c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?; @@ -56,6 +62,7 @@ impl Manager { let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone())); let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt); let t = SshClient::new(&tp, Callbacks::noop()); + t.set_authorities(self.cert_authorities()); t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?; Ok(t) } diff --git a/rust/core/tests/ssh_integration.rs b/rust/core/tests/ssh_integration.rs index 886549e..b6c08c5 100644 --- a/rust/core/tests/ssh_integration.rs +++ b/rust/core/tests/ssh_integration.rs @@ -39,6 +39,12 @@ fn free_port() -> u16 { } fn start(name: &str) -> Option { + start_with(name, |_, _| String::new()) +} + +/// `extra` gets the server directory and ssh-keygen, may create files, and returns more +/// sshd_config lines. +fn start_with(name: &str, extra: impl FnOnce(&Path, &Path) -> String) -> Option { let sshd = which("sshd")?; let keygen = which("ssh-keygen")?; let sftp_server = @@ -50,13 +56,15 @@ fn start(name: &str) -> Option { Command::new(&keygen).args(["-q", "-t", "ed25519", "-N", "", "-f"]).arg(dir.join(k)).status().unwrap(); } std::fs::copy(dir.join("client.pub"), dir.join("authorized_keys")).unwrap(); + let more = extra(&dir, &keygen); let port = free_port(); - let cfg = format!( + let mut cfg = format!( "Port {port}\nListenAddress 127.0.0.1\nHostKey {d}/host\nAuthorizedKeysFile {d}/authorized_keys\nPidFile {d}/pid\n\ StrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n\ AllowTcpForwarding yes\nAllowAgentForwarding yes\nSubsystem sftp {sftp_server}\n", d = dir.display() ); + cfg += &more; std::fs::write(dir.join("sshd_config"), cfg).unwrap(); let child = Command::new(sshd).args(["-D", "-e", "-f"]).arg(dir.join("sshd_config")).spawn().ok()?; let start = Instant::now(); @@ -285,3 +293,45 @@ fn agent_auth_and_forwarding() { assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock())); cl.close(); } + +#[test] +fn host_and_user_certificates() { + let user = std::env::var("USER").unwrap_or_else(|_| "root".into()); + let u2 = user.clone(); + let Some(s) = start_with("certs", move |d, keygen| { + let run = |args: &[&str]| assert!(Command::new(keygen).args(args).current_dir(d).status().unwrap().success()); + run(&["-q", "-t", "ed25519", "-N", "", "-f", "ca"]); + // Host certificate for 127.0.0.1; user certificate for us. Only the CA authorises logins. + run(&["-q", "-s", "ca", "-I", "host", "-h", "-n", "127.0.0.1", "host.pub"]); + run(&["-q", "-s", "ca", "-I", "user", "-n", &u2, "client.pub"]); + std::fs::write(d.join("authorized_keys"), "").unwrap(); + format!("HostCertificate {0}/host-cert.pub\nTrustedUserCAKeys {0}/ca.pub\n", d.display()) + }) else { + return; + }; + let ca = russh::keys::PublicKey::from_openssh(std::fs::read_to_string(s.dir.join("ca.pub")).unwrap().trim()).unwrap(); + let never_asked: HostKeyCheck = Arc::new(|i| Err(format!("asked about plain key {}", i.fingerprint))); + + // A CA for this host: the certificate is checked, no first-use prompt, and the user + // certificate (client-cert.pub next to the key) logs in. + let c = SshClient::new(&profile(&s), callbacks().0); + let here = format!("[127.0.0.1]:{}", s.port); + c.set_authorities(vec![nexus_core::known_hosts::authority_row(&here, &ca)]); + c.connect(never_asked.clone(), None, None).unwrap(); + c.close(); + + // A CA for other hosts doesn't apply: back to the plain host key, which this check refuses. + let c = SshClient::new(&profile(&s), callbacks().0); + c.set_authorities(vec![nexus_core::known_hosts::authority_row("*.example.com", &ca)]); + let err = c.connect(never_asked.clone(), None, None).unwrap_err().to_string(); + assert!(err.contains("asked about plain key"), "{err}"); + + // A different CA for this host: the certificate is refused. + let other = s.dir.join("other"); + Command::new(which("ssh-keygen").unwrap()).args(["-q", "-t", "ed25519", "-N", "", "-f"]).arg(&other).status().unwrap(); + let other = russh::keys::PublicKey::from_openssh(std::fs::read_to_string(other.with_extension("pub")).unwrap().trim()).unwrap(); + let c = SshClient::new(&profile(&s), callbacks().0); + c.set_authorities(vec![nexus_core::known_hosts::authority_row(&here, &other)]); + let err = c.connect(never_asked, None, None).unwrap_err().to_string(); + assert!(err.contains("trusted CA"), "{err}"); +}