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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct Settings {
|
||||
prefs: AppPreferences,
|
||||
theme: ThemeKind,
|
||||
known: Vec<KnownHost>,
|
||||
/// Add-CA form: (host pattern, public key), shown when Some.
|
||||
new_ca: Option<(String, String)>,
|
||||
default_shell: String,
|
||||
help_topic: usize,
|
||||
master: Option<MasterPrompt>,
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -61,6 +61,22 @@ struct Handler {
|
||||
port: u16,
|
||||
check: HostKeyCheck,
|
||||
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 {
|
||||
@@ -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> {
|
||||
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<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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<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();
|
||||
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<String>,
|
||||
agent: bool,
|
||||
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>,
|
||||
totp_secret: String,
|
||||
}
|
||||
@@ -282,6 +325,11 @@ async fn authenticate(h: &mut Handle<Handler>, 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<String>, Zeroizing<String>)>,
|
||||
/// `@cert-authority` known-hosts rows (CA keys by host pattern), for host certificates.
|
||||
authorities: Mutex<Vec<crate::models::KnownHost>>,
|
||||
}
|
||||
|
||||
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<crate::models::KnownHost>) {
|
||||
*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<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);
|
||||
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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
self.store.preferences()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
use super::*;
|
||||
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ fn free_port() -> u16 {
|
||||
}
|
||||
|
||||
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 keygen = which("ssh-keygen")?;
|
||||
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();
|
||||
}
|
||||
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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user