fix(ssh): linear host-pattern matching, per-hop forwarding gates, loopback remote forwards

- known_hosts: the @cert-authority pattern matcher was recursive and
  backtracking; a pattern of a few hundred thousand stars overflowed the
  connect thread's stack on every handshake and '*a*a*a…b' went
  exponential. It is now the iterative two-pointer match, and patterns
  over 1 KiB are refused on import and in Trust a CA.
- Agent-forward and remote-forward channels are only honoured on the
  final hop: a jump host could previously open auth-agent channels and
  reach remote-forward destinations on the client machine.
- Remote forwards with no bind address listen on the server's loopback
  (as ssh -R does) instead of every interface.
- Keyboard-interactive gives up after 10 rounds; dials through jump hosts
  and run_command have timeouts; the handshake timeout leaves room for
  the host-key prompt so a slow reader isn't timed out mid-decision.
- Known hosts are looked up per key type, so an imported known_hosts with
  ed25519 and RSA rows no longer produces a false 'key changed' warning.
- SFTP download suggests a sanitised base name in the save dialog.
This commit is contained in:
Andy Dixon
2026-10-13 00:42:17 +01:00
parent 76bd664ed1
commit 0bcfe6470b
8 changed files with 139 additions and 31 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ If you connect to a profile that already has a live session, Nexus Terminal asks
- **Key & Passphrase**: choose a key from the suggestions (keys in `~/.ssh`), click **Browse**, or type a full path (`~` is not expanded). The editor checks that the file exists and is a private key. - **Key & Passphrase**: choose a key from the suggestions (keys in `~/.ssh`), click **Browse**, or type a full path (`~` is not expanded). The editor checks that the file exists and is a private key.
- **Host Verification**: the host key policy (see section 12). - **Host Verification**: the host key policy (see section 12).
- **Jump Hosts**: one or more bastions, each with user, host, port and an optional key. Nexus Terminal tunnels through each hop in order. - **Jump Hosts**: one or more bastions, each with user, host, port and an optional key. Nexus Terminal tunnels through each hop in order.
- **Port Forwarding**: local and remote forwards, each with a bind address and port, destination, label and an **Active** box to start it on connect. - **Port Forwarding**: local and remote forwards, each with a bind address and port, destination, label and an **Active** box to start it on connect. A remote forward with no bind address listens on the server's loopback only (as `ssh -R` does); set it to `0.0.0.0` to expose it to the server's network, which also needs `GatewayPorts` on the server.
- **Startup**: a startup command and environment variables (`KEY=VALUE`, one per line). - **Startup**: a startup command and environment variables (`KEY=VALUE`, one per line).
- **Login Script**: expect-style steps (see below). - **Login Script**: expect-style steps (see below).
- **SSH Agent**: agent authentication and **Forward Agent to Remote** (like `ssh -A`). - **SSH Agent**: agent authentication and **Forward Agent to Remote** (like `ssh -A`).
+3 -1
View File
@@ -393,7 +393,9 @@ impl Sftp {
fn download(&mut self, s: &Shared, session: &str, files: Vec<FileEntry>) { fn download(&mut self, s: &Shared, session: &str, files: Vec<FileEntry>) {
let targets: Vec<(FileEntry, PathBuf)> = if files.len() == 1 { let targets: Vec<(FileEntry, PathBuf)> = if files.len() == 1 {
let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else { // The name comes from the server: keep only a safe base name for the dialog's default.
let suggested = safe_sftp_upload_name(&files[0].name).unwrap_or_else(|_| "download".into());
let Some(p) = rfd::FileDialog::new().set_file_name(suggested).save_file() else {
return; return;
}; };
vec![(files[0].clone(), p)] vec![(files[0].clone(), p)]
+45 -17
View File
@@ -20,6 +20,16 @@ use zeroize::Zeroizing;
const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20; const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20;
/// Bounds what `run_command` buffers, so a hostile server can't stream unlimited output into memory. /// Bounds what `run_command` buffers, so a hostile server can't stream unlimited output into memory.
const MAX_COMMAND_OUTPUT_BYTES: usize = 1 << 20; const MAX_COMMAND_OUTPUT_BYTES: usize = 1 << 20;
/// How long `run_command` waits for a command to finish.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
/// Keyboard-interactive rounds answered before giving up on the method.
const MAX_KBD_INTERACTIVE_ROUNDS: usize = 10;
/// Extra handshake time allowed for the host-key prompt (see `credentials::host_key_check`).
const HOST_KEY_PROMPT_SECS: u64 = 60;
/// Remote forwards listen on the server's loopback unless told otherwise, as `ssh -R` does; an
/// empty bind host used to mean every interface, exposing the forwarded service to the server's
/// whole network wherever `GatewayPorts` is on.
const REMOTE_BIND_DEFAULT: &str = "localhost";
/// What a host-key decision needs. The manager's checker consults the known-hosts table and, /// What a host-key decision needs. The manager's checker consults the known-hosts table and,
/// per policy, prompts the user; it returns Err with the reason to reject. /// per policy, prompts the user; it returns Err with the reason to reject.
@@ -61,6 +71,9 @@ struct Handler {
port: u16, port: u16,
check: HostKeyCheck, check: HostKeyCheck,
shared: Arc<Shared>, shared: Arc<Shared>,
/// Whether this is the final hop. Only the target may open agent-forward and remote-forward
/// channels; a jump host never asked for either and must not get them (as `ssh -J` behaves).
target: bool,
/// Certificate authorities trusted for this host (from `@cert-authority` known-hosts rows). /// Certificate authorities trusted for this host (from `@cert-authority` known-hosts rows).
cas: Vec<PublicKey>, cas: Vec<PublicKey>,
} }
@@ -123,7 +136,7 @@ impl client::Handler for Handler {
_session: &mut client::Session, _session: &mut client::Session,
) -> std::result::Result<(), Self::Error> { ) -> std::result::Result<(), Self::Error> {
let dest = self.shared.remote_targets.lock().get(&(connected_address.to_string(), connected_port)).cloned(); let dest = self.shared.remote_targets.lock().get(&(connected_address.to_string(), connected_port)).cloned();
let Some((host, port)) = dest else { let Some((host, port)) = dest.filter(|_| self.target) else {
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
return Ok(()); return Ok(());
}; };
@@ -143,7 +156,7 @@ impl client::Handler for Handler {
reply: client::ChannelOpenHandle, reply: client::ChannelOpenHandle,
_session: &mut client::Session, _session: &mut client::Session,
) -> std::result::Result<(), Self::Error> { ) -> std::result::Result<(), Self::Error> {
if !self.shared.agent_forwarding.load(std::sync::atomic::Ordering::Relaxed) { if !self.target || !self.shared.agent_forwarding.load(std::sync::atomic::Ordering::Relaxed) {
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
return Ok(()); return Ok(());
} }
@@ -348,7 +361,9 @@ async fn authenticate(h: &mut Handle<Handler>, a: Auth<'_>) -> Result<()> {
"keyboard-interactive" => { "keyboard-interactive" => {
let Some(get) = a.password.clone() else { continue }; let Some(get) = a.password.clone() else { continue };
let mut resp = h.authenticate_keyboard_interactive_start(a.user, None).await?; let mut resp = h.authenticate_keyboard_interactive_start(a.user, None).await?;
loop { // A server can keep asking forever (harvesting TOTP codes, parking the connect
// thread on prompts); OpenSSH gives up after a handful of rounds too.
for _ in 0..MAX_KBD_INTERACTIVE_ROUNDS {
match resp { match resp {
client::KeyboardInteractiveAuthResponse::Success => return Ok(()), client::KeyboardInteractiveAuthResponse::Success => return Ok(()),
client::KeyboardInteractiveAuthResponse::Failure { .. } => break, client::KeyboardInteractiveAuthResponse::Failure { .. } => break,
@@ -469,11 +484,14 @@ impl SshClient {
host: &str, host: &str,
port: u16, port: u16,
check: &HostKeyCheck, check: &HostKeyCheck,
target: bool,
) -> Result<Handle<Handler>> { ) -> Result<Handle<Handler>> {
let cas = crate::known_hosts::authorities_for(&self.authorities.lock(), host, port); let cas = crate::known_hosts::authorities_for(&self.authorities.lock(), host, port);
let cfg = config(&self.sc(), !cas.is_empty()); let cfg = config(&self.sc(), !cas.is_empty());
let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone(), cas }; let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone(), cas, target };
let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64); // The handshake may stop at the host-key prompt (up to 60 s): don't let the connect
// timeout expire underneath the user while they read a fingerprint.
let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64 + HOST_KEY_PROMPT_SECS);
match tokio::time::timeout(timeout, client::connect_stream(cfg, 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),
@@ -507,16 +525,16 @@ impl SshClient {
}; };
let mut handle = if sc.jump_hosts.is_empty() { let mut handle = if sc.jump_hosts.is_empty() {
let tcp = dial(&sc, &proxy_password, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?; let tcp = dial(&sc, &proxy_password, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?;
self.handshake(tcp, &p.host, port, &check).await? self.handshake(tcp, &p.host, port, &check, true).await?
} else { } else {
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?; let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
let ch = prev let ch = tokio::time::timeout(timeout, prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0))
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
.await .await
.map_err(|_| format!("failed to dial target {}:{port} through jump hosts: timeout", p.host))?
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; .map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
self.hops.lock().extend(h); self.hops.lock().extend(h);
self.hops.lock().push(prev); self.hops.lock().push(prev);
self.handshake(ch.into_stream(), &p.host, port, &check).await? self.handshake(ch.into_stream(), &p.host, port, &check, true).await?
}; };
let cert = load_user_certificate(&sc.private_key_path); let cert = load_user_certificate(&sc.private_key_path);
@@ -600,14 +618,14 @@ impl SshClient {
.await .await
.map_err(|_| format!("failed to connect to jump host {i} ({host}:{jport}): timeout"))? .map_err(|_| format!("failed to connect to jump host {i} ({host}:{jport}): timeout"))?
.map_err(|e| format!("failed to connect to jump host {i} ({host}:{jport}): {e}"))?; .map_err(|e| format!("failed to connect to jump host {i} ({host}:{jport}): {e}"))?;
self.handshake(tcp, host, jport, check).await? self.handshake(tcp, host, jport, check, false).await?
} }
Some(prev) => { Some(prev) => {
let ch = prev let ch = tokio::time::timeout(timeout, prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0))
.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0)
.await .await
.map_err(|_| format!("failed to dial through jump host {i} ({host}:{jport}): timeout"))?
.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?; .map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
self.handshake(ch.into_stream(), host, jport, check).await? self.handshake(ch.into_stream(), host, jport, check, false).await?
} }
}; };
authenticate( authenticate(
@@ -652,11 +670,21 @@ impl SshClient {
Ok(start.elapsed().as_millis() as i64) Ok(start.elapsed().as_millis() as i64)
} }
/// Runs a command on a separate channel and returns trimmed stdout. /// Runs a command on a separate channel and returns trimmed stdout, giving up after
/// `COMMAND_TIMEOUT` so a server that never answers can't park the caller's thread.
pub fn run_command(&self, command: &str) -> Result<String> { pub fn run_command(&self, command: &str) -> Result<String> {
let h = self.handle()?; let h = self.handle()?;
let command = command.to_string(); let command = command.to_string();
rt().block_on(async move { rt().block_on(async move {
match tokio::time::timeout(COMMAND_TIMEOUT, Self::run_command_inner(h, command)).await {
Ok(r) => r,
Err(_) => Err("command timed out".into()),
}
})
}
async fn run_command_inner(h: Arc<Handle<Handler>>, command: String) -> Result<String> {
{
let mut ch = h.channel_open_session().await.map_err(|e| format!("failed to open SSH session: {e}"))?; let mut ch = h.channel_open_session().await.map_err(|e| format!("failed to open SSH session: {e}"))?;
ch.exec(true, command.as_bytes()).await?; ch.exec(true, command.as_bytes()).await?;
let mut out = vec![]; let mut out = vec![];
@@ -673,7 +701,7 @@ impl SshClient {
} }
} }
Ok(String::from_utf8_lossy(&out).trim().to_string()) Ok(String::from_utf8_lossy(&out).trim().to_string())
}) }
} }
pub fn is_connected(&self) -> bool { pub fn is_connected(&self) -> bool {
@@ -733,7 +761,7 @@ impl SshClient {
tasks.lock().push(task.abort_handle()); tasks.lock().push(task.abort_handle());
} }
ForwardDirection::Remote => { ForwardDirection::Remote => {
let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() }; let bind = if rule.bind_host.is_empty() { REMOTE_BIND_DEFAULT.to_string() } else { rule.bind_host.clone() };
let port = rt() let port = rt()
.block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32)) .block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32))
.map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?; .map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?;
@@ -756,7 +784,7 @@ impl SshClient {
t.abort(); t.abort();
} }
if f.rule.direction == ForwardDirection::Remote { if f.rule.direction == ForwardDirection::Remote {
let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() }; let bind = if f.rule.bind_host.is_empty() { REMOTE_BIND_DEFAULT.to_string() } else { f.rule.bind_host.clone() };
self.shared.remote_targets.lock().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32)); self.shared.remote_targets.lock().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32));
if let Ok(h) = self.handle() { if let Ok(h) = self.handle() {
let port = f.rule.bind_port as u32; let port = f.rule.bind_port as u32;
+52 -6
View File
@@ -11,6 +11,8 @@ use russh::keys::{HashAlg, PublicKey};
/// `key_type` of a certificate-authority row. /// `key_type` of a certificate-authority row.
pub const CA_KEY_TYPE: &str = "@cert-authority"; pub const CA_KEY_TYPE: &str = "@cert-authority";
/// Longest host pattern accepted (OpenSSH's own line limit is 8 KiB; real patterns are tiny).
pub const MAX_PATTERN_LEN: usize = 1024;
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
pub struct Parsed { pub struct Parsed {
@@ -35,6 +37,7 @@ pub fn parse(text: &str) -> Parsed {
continue; continue;
}; };
match marker { match marker {
Some("@cert-authority") if hosts.len() > MAX_PATTERN_LEN => out.skipped += 1,
Some("@cert-authority") => out.authorities.push((hosts.to_string(), key)), Some("@cert-authority") => out.authorities.push((hosts.to_string(), key)),
Some(_) => out.skipped += 1, Some(_) => out.skipped += 1,
None if hosts.starts_with('|') => out.skipped += 1, None if hosts.starts_with('|') => out.skipped += 1,
@@ -116,14 +119,34 @@ pub fn pattern_matches(patterns: &str, host: &str, port: u16) -> bool {
matched matched
} }
/// Wildcard match with `*` and `?`, iterative and linear in `|p| + |s|` per star (the classic
/// two-pointer algorithm), so a pattern of a million stars or `*a*a*a…` can't recurse or
/// backtrack exponentially. Patterns come from imported files and typed input.
fn glob(p: &str, s: &str) -> bool { fn glob(p: &str, s: &str) -> bool {
match (p.chars().next(), s.chars().next()) { let (p, s): (Vec<char>, Vec<char>) = (p.chars().collect(), s.chars().collect());
(None, None) => true, let (mut pi, mut si) = (0, 0);
(Some('*'), _) => glob(&p[1..], s) || (!s.is_empty() && glob(p, &s[s.chars().next().unwrap().len_utf8()..])), let mut star: Option<(usize, usize)> = None;
(Some('?'), Some(c)) => glob(&p[1..], &s[c.len_utf8()..]), while si < s.len() {
(Some(a), Some(b)) if a == b => glob(&p[a.len_utf8()..], &s[b.len_utf8()..]), match p.get(pi) {
_ => false, Some('*') => {
star = Some((pi, si));
pi += 1;
}
Some(&c) if c == '?' || c == s[si] => {
pi += 1;
si += 1;
}
_ => match star {
Some((sp, ss)) => {
pi = sp + 1;
si = ss + 1;
star = Some((sp, ss + 1));
}
None => return false,
},
}
} }
p[pi..].iter().all(|&c| c == '*')
} }
/// CA keys trusted for `host:port`. /// CA keys trusted for `host:port`.
@@ -180,6 +203,29 @@ mod tests {
assert!(!pattern_matches(pats, "corp.example", 22)); assert!(!pattern_matches(pats, "corp.example", 22));
} }
#[test]
fn hostile_patterns_match_in_linear_time() {
// The old recursive matcher overflowed the stack on a long run of stars and went
// exponential on `*a*a*a…b`; both must now be instant (and run on a small stack).
let t = std::thread::Builder::new()
.stack_size(256 << 10)
.spawn(|| {
let start = std::time::Instant::now();
assert!(pattern_matches(&"*".repeat(200_000), "db.corp", 22));
assert!(!pattern_matches("*a*a*a*a*a*a*a*a*a*a*b", &"a".repeat(253), 22));
assert!(pattern_matches("*a*a*a*b?", &format!("{}bx", "a".repeat(253)), 22));
assert!(!pattern_matches("a*c", "ab", 22) && pattern_matches("a*", "a", 22) && !pattern_matches("a?", "a", 22));
start.elapsed()
})
.unwrap()
.join()
.unwrap();
assert!(t < std::time::Duration::from_millis(500), "{t:?}");
// Over-long CA patterns in an imported file are skipped rather than stored.
let p = parse(&format!("@cert-authority {} {}\n", "*".repeat(MAX_PATTERN_LEN + 1), ed()));
assert!(p.authorities.is_empty() && p.skipped == 1);
}
#[test] #[test]
fn exports_what_it_imports() { fn exports_what_it_imports() {
let ED = ed(); let ED = ed();
+1 -1
View File
@@ -324,7 +324,7 @@ impl Service {
/// Trusts an SSH certificate authority for hosts matching `pattern` (OpenSSH syntax). /// Trusts an SSH certificate authority for hosts matching `pattern` (OpenSSH syntax).
pub fn add_cert_authority(&self, pattern: &str, public_key: &str) -> Result<()> { pub fn add_cert_authority(&self, pattern: &str, public_key: &str) -> Result<()> {
let pattern = pattern.trim(); let pattern = pattern.trim();
if pattern.is_empty() || pattern.contains(char::is_whitespace) { if pattern.is_empty() || pattern.contains(char::is_whitespace) || pattern.len() > crate::known_hosts::MAX_PATTERN_LEN {
return Err("the host pattern must be one word, e.g. *.example.com".into()); 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}"))?; let key = russh::keys::PublicKey::from_openssh(public_key.trim()).map_err(|e| format!("not an OpenSSH public key: {e}"))?;
+1 -1
View File
@@ -108,7 +108,7 @@ impl Manager {
pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck { pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck {
let (me, sid) = (self.clone(), session.to_string()); let (me, sid) = (self.clone(), session.to_string());
Arc::new(move |k: &HostKeyInfo| { Arc::new(move |k: &HostKeyInfo| {
let stored = me.store.get_known_host(&k.host, k.port as i64).map_err(|e| format!("failed to query known hosts: {e}"))?; let stored = me.store.get_known_host(&k.host, k.port as i64, &k.key_type).map_err(|e| format!("failed to query known hosts: {e}"))?;
let save = || { let save = || {
let n = now(); let n = now();
me.store me.store
+1 -1
View File
@@ -873,7 +873,7 @@ mod tests {
} }
})); }));
let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() }; let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() };
let stored = |m: &Manager| m.store.get_known_host("h", 22).unwrap().map(|k| k.fingerprint); let stored = |m: &Manager| m.store.get_known_host("h", 22, "ssh-ed25519").unwrap().map(|k| k.fingerprint);
// Unknown key: accept once records nothing; accept & save records it. // Unknown key: accept once records nothing; accept & save records it.
let check = m.host_key_check("s", HostKeyPolicy::Ask); let check = m.host_key_check("s", HostKeyPolicy::Ask);
answers.lock().push(HostKeyDecision::AcceptOnce); answers.lock().push(HostKeyDecision::AcceptOnce);
+35 -3
View File
@@ -273,14 +273,17 @@ impl Store {
Ok(n) Ok(n)
} }
pub fn get_known_host(&self, host: &str, port: i64) -> Result<Option<KnownHost>> { /// The stored key of this type for `host:port`. A host can have one key per type (an
/// imported known_hosts usually brings ed25519, RSA and ECDSA), and comparing the offered
/// key with a row of another type would look like a changed key.
pub fn get_known_host(&self, host: &str, port: i64, key_type: &str) -> Result<Option<KnownHost>> {
Ok(self Ok(self
.db .db
.lock() .lock()
.query_row( .query_row(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted "SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts WHERE host = ? AND port = ?", FROM known_hosts WHERE host = ? AND port = ? AND key_type = ?",
params![host, port], params![host, port, key_type],
scan_known_host, scan_known_host,
) )
.optional()?) .optional()?)
@@ -557,4 +560,33 @@ mod tests {
self self
} }
} }
#[test]
fn known_hosts_are_looked_up_per_key_type() {
let tmp = std::env::temp_dir().join(format!("nexus-kh-{}.db", std::process::id()));
let _ = std::fs::remove_file(&tmp);
let s = Store::open(&tmp).unwrap();
let n = now();
for (kt, fp) in [("ssh-ed25519", "SHA256:ed"), ("ssh-rsa", "SHA256:rsa")] {
s.save_known_host(&KnownHost {
id: String::new(),
host: "h".into(),
port: 22,
key_type: kt.into(),
fingerprint: fp.into(),
public_key: "AAAA".into(),
first_seen: n,
last_seen: n,
trusted: true,
})
.unwrap();
}
// An imported known_hosts brings several key types per host; the offered key is
// compared with the row of its own type, not whichever row comes first.
assert_eq!(s.get_known_host("h", 22, "ssh-rsa").unwrap().unwrap().fingerprint, "SHA256:rsa");
assert_eq!(s.get_known_host("h", 22, "ssh-ed25519").unwrap().unwrap().fingerprint, "SHA256:ed");
assert!(s.get_known_host("h", 22, "ecdsa-sha2-nistp256").unwrap().is_none());
drop(s);
let _ = std::fs::remove_file(tmp);
}
} }