feat: SSH certificates and known_hosts import/export

- 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: <key>-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.
This commit is contained in:
Andy Dixon
2026-10-12 19:02:59 +01:00
parent 1990fb5da9
commit 0fc9451583
9 changed files with 443 additions and 8 deletions
+2
View File
@@ -80,6 +80,8 @@ Use Nexus Terminal when you need to:
### SSH capabilities ### 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. - Password, public key, keyboard-interactive and SSH agent authentication.
- SSH agent forwarding. - SSH agent forwarding.
- Private key suggestions from `~/.ssh`, with a key file check. - Private key suggestions from `~/.ssh`, with a key file check.
+4
View File
@@ -405,6 +405,10 @@ Choose one of the following policies per SSH profile:
- **Accept new, reject changed**: trust first-time keys automatically. - **Accept new, reject changed**: trust first-time keys automatically.
- **Insecure (accept anything)**: accept any key (not recommended). - **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. 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 ### TOTP / 2FA
+56 -2
View File
@@ -102,6 +102,8 @@ pub struct Settings {
prefs: AppPreferences, prefs: AppPreferences,
theme: ThemeKind, theme: ThemeKind,
known: Vec<KnownHost>, known: Vec<KnownHost>,
/// Add-CA form: (host pattern, public key), shown when Some.
new_ca: Option<(String, String)>,
default_shell: String, default_shell: String,
help_topic: usize, help_topic: usize,
master: Option<MasterPrompt>, master: Option<MasterPrompt>,
@@ -444,6 +446,58 @@ impl Settings {
fn known_hosts(&mut self, ui: &mut Ui, s: &mut Shared) { fn known_hosts(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx()); 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() { 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."); widgets::empty(ui, icon::FINGERPRINT, "No known hosts recorded", "Host keys are recorded the first time you connect over SSH.");
return; return;
@@ -462,7 +516,7 @@ impl Settings {
ui.end_row(); ui.end_row();
for kh in &self.known { for kh in &self.known {
ui.label(&kh.host); 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))); ui.label(RichText::new(&kh.key_type).font(theme::mono(11.5)));
let short: String = kh.fingerprint.chars().take(24).collect(); 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())); 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 ## 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. 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 ## 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.", When exporting profiles, encrypted export (a `.nexus` file) protects the file with a password, using the same AES-256-GCM encryption as the vault.",
), ),
+65 -5
View File
@@ -61,6 +61,22 @@ struct Handler {
port: u16, port: u16,
check: HostKeyCheck, check: HostKeyCheck,
shared: Arc<Shared>, shared: Arc<Shared>,
/// Certificate authorities trusted for this host (from `@cert-authority` known-hosts rows).
cas: Vec<PublicKey>,
}
/// 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 { 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<bool, Self::Error> { async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> std::result::Result<bool, Self::Error> {
let key = match key { let key = match key {
russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), 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 info = host_key_info(&self.host, self.port, &key);
let check = self.check.clone(); let check = self.check.clone();
@@ -201,6 +229,15 @@ fn base32_decode(s: &str) -> Option<Vec<u8>> {
} }
/// Reads an OpenSSH/PEM private key, asking for a passphrase only if it's encrypted. /// 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<russh::keys::Certificate> {
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<russh::keys::PrivateKey> { pub fn load_private_key(path: &str, passphrase: Option<&SecretFn>) -> Result<russh::keys::PrivateKey> {
let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?; let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?;
if meta.is_dir() { 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)?) Ok(tokio::net::TcpStream::from_std(s)?)
} }
fn config(sc: &SshConfig) -> Arc<client::Config> { /// `host_certificates`: ask for a host certificate first (when a CA applies), as OpenSSH does.
fn config(sc: &SshConfig, host_certificates: bool) -> Arc<client::Config> {
let mut c = client::Config::default(); let mut c = client::Config::default();
if host_certificates {
c.preferred.host_key_certificates = c.preferred.key.clone();
}
if sc.keepalive_interval > 0 { if sc.keepalive_interval > 0 {
c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64)); c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64));
c.keepalive_max = sc.keepalive_count_max.max(1) as usize; c.keepalive_max = sc.keepalive_count_max.max(1) as usize;
@@ -260,6 +301,8 @@ struct Auth<'a> {
methods: Vec<String>, methods: Vec<String>,
agent: bool, agent: bool,
key: Option<russh::keys::PrivateKey>, key: Option<russh::keys::PrivateKey>,
/// `<key>-cert.pub` next to the private key: log in with the certificate instead.
cert: Option<russh::keys::Certificate>,
password: Option<SecretFn>, password: Option<SecretFn>,
totp_secret: String, totp_secret: String,
} }
@@ -282,6 +325,11 @@ async fn authenticate(h: &mut Handle<Handler>, a: Auth<'_>) -> Result<()> {
for m in &a.methods { for m in &a.methods {
match m.as_str() { match m.as_str() {
"publickey" => { "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 { if let Some(k) = &a.key {
let hash = if k.algorithm().is_rsa() { h.best_supported_rsa_hash().await?.flatten() } else { None }; 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() { 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 /// Credentials that live in the secret store rather than the profile: (TOTP seed, proxy
/// password). Set by the session manager before connecting. /// password). Set by the session manager before connecting.
stored: Mutex<(Zeroizing<String>, Zeroizing<String>)>, stored: Mutex<(Zeroizing<String>, Zeroizing<String>)>,
/// `@cert-authority` known-hosts rows (CA keys by host pattern), for host certificates.
authorities: Mutex<Vec<crate::models::KnownHost>>,
} }
impl SshClient { impl SshClient {
@@ -364,9 +414,15 @@ impl SshClient {
sftp: tokio::sync::Mutex::new(None), sftp: tokio::sync::Mutex::new(None),
closed: false.into(), closed: false.into(),
stored: Mutex::default(), stored: Mutex::default(),
authorities: Mutex::default(),
}) })
} }
/// Certificate authorities (known-hosts CA rows) to trust for host certificates.
pub fn set_authorities(&self, rows: Vec<crate::models::KnownHost>) {
*self.authorities.lock() = rows;
}
/// The TOTP seed (for keyboard-interactive 2FA prompts) and proxy password to use. /// 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) { pub fn set_stored_secrets(&self, totp_secret: String, proxy_password: String) {
*self.stored.lock() = (Zeroizing::new(totp_secret), Zeroizing::new(proxy_password)); *self.stored.lock() = (Zeroizing::new(totp_secret), Zeroizing::new(proxy_password));
@@ -414,9 +470,11 @@ impl SshClient {
port: u16, port: u16,
check: &HostKeyCheck, check: &HostKeyCheck,
) -> Result<Handle<Handler>> { ) -> Result<Handle<Handler>> {
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); 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()), Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()),
Ok(Ok(h)) => Ok(h), Ok(Ok(h)) => Ok(h),
Ok(Err(e)) => match self.shared.rejection.lock().take() { 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? 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); let handle = Arc::new(handle);
if !shell { if !shell {
*self.handle.lock() = Some(handle); *self.handle.lock() = Some(handle);
@@ -558,6 +617,7 @@ impl SshClient {
methods: vec!["publickey".into()], methods: vec!["publickey".into()],
agent: key.is_none() || sc.agent_auth, agent: key.is_none() || sc.agent_auth,
key, key,
cert: None,
password: None, password: None,
totp_secret: String::new(), totp_secret: String::new(),
}, },
+198
View File
@@ -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<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 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);
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod config;
pub mod exportimport; pub mod exportimport;
pub mod ircformat; pub mod ircformat;
pub mod keyfiles; pub mod keyfiles;
pub mod known_hosts;
pub mod models; pub mod models;
pub mod recording; pub mod recording;
pub mod secrets; pub mod secrets;
+59
View File
@@ -165,6 +165,65 @@ impl Service {
self.store.delete_known_host(id) 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<usize> {
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 { pub fn preferences(&self) -> AppPreferences {
self.store.preferences() self.store.preferences()
} }
+7
View File
@@ -3,6 +3,11 @@
use super::*; use super::*;
impl Manager { impl Manager {
/// Known-hosts rows that are SSH certificate authorities.
pub(super) fn cert_authorities(&self) -> Vec<KnownHost> {
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 ----------------------------------------------------------- // --- per-protocol connect -----------------------------------------------------------
pub(super) fn connect_ssh(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> { pub(super) fn connect_ssh(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
@@ -11,6 +16,7 @@ impl Manager {
let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone()); 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 passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt);
let c = SshClient::new(&p, self.terminal_callbacks(id, epoch)); 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(); 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.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword));
c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?; 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 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 passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt);
let t = SshClient::new(&tp, Callbacks::noop()); 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))?; t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?;
Ok(t) Ok(t)
} }
+51 -1
View File
@@ -39,6 +39,12 @@ fn free_port() -> u16 {
} }
fn start(name: &str) -> Option<Server> { fn start(name: &str) -> Option<Server> {
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<Server> {
let sshd = which("sshd")?; let sshd = which("sshd")?;
let keygen = which("ssh-keygen")?; let keygen = which("ssh-keygen")?;
let sftp_server = let sftp_server =
@@ -50,13 +56,15 @@ fn start(name: &str) -> Option<Server> {
Command::new(&keygen).args(["-q", "-t", "ed25519", "-N", "", "-f"]).arg(dir.join(k)).status().unwrap(); 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(); std::fs::copy(dir.join("client.pub"), dir.join("authorized_keys")).unwrap();
let more = extra(&dir, &keygen);
let port = free_port(); 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\ "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\ StrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n\
AllowTcpForwarding yes\nAllowAgentForwarding yes\nSubsystem sftp {sftp_server}\n", AllowTcpForwarding yes\nAllowAgentForwarding yes\nSubsystem sftp {sftp_server}\n",
d = dir.display() d = dir.display()
); );
cfg += &more;
std::fs::write(dir.join("sshd_config"), cfg).unwrap(); 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 child = Command::new(sshd).args(["-D", "-e", "-f"]).arg(dir.join("sshd_config")).spawn().ok()?;
let start = Instant::now(); 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())); assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock()));
cl.close(); 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}");
}