use std::io::{BufRead, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use puressh::client::{
AlgoOverrides, Client, Config, HostKeyPolicy, KnownHostsPolicy, TofuAction, TofuPromptFn,
};
use puressh::known_hosts::KnownHosts;
const USAGE: &str = "usage: ssh-keyscan [-46cDHqv] [-f file] [-O option] [-p port] [-T timeout]\n\
\t\t [-t type] [host | addrlist namelist]";
struct Opts {
family: Option<bool>, hash: bool,
verbose: bool,
quiet: bool,
certs: bool,
sshfp: bool,
sshfp_hash: Option<u8>,
port: u16,
timeout: Duration,
types: Vec<&'static str>,
hosts: Vec<String>,
}
fn algorithms_for(ty: &str, cert: bool) -> &'static [&'static str] {
match (ty, cert) {
("rsa", false) => &["rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"],
("rsa", true) => &[
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
],
("ecdsa", false) => &[
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
],
("ecdsa", true) => &[
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
],
("ed25519", false) => &["ssh-ed25519"],
("ed25519", true) => &["ssh-ed25519-cert-v01@openssh.com"],
("mldsa44-ed25519", false) => &["ssh-mldsa44-ed25519@openssh.com"],
("mldsa44-ed25519", true) => &["ssh-mldsa44-ed25519-cert-v01@openssh.com"],
_ => &[],
}
}
fn parse_args(args: &[String]) -> Result<Opts, String> {
let mut o = Opts {
family: None,
hash: false,
verbose: false,
quiet: false,
certs: false,
sshfp: false,
sshfp_hash: None,
port: 22,
timeout: Duration::from_secs(5),
types: vec!["rsa", "ecdsa", "ed25519", "mldsa44-ed25519"],
hosts: Vec::new(),
};
let mut files: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
o.hosts.extend(args[i + 1..].iter().cloned());
break;
}
if !a.starts_with('-') || a == "-" {
o.hosts.push(a.clone());
i += 1;
continue;
}
let chars: Vec<char> = a[1..].chars().collect();
let mut j = 0;
while j < chars.len() {
let c = chars[j];
if "fOpTt".contains(c) {
let attached: String = chars[j + 1..].iter().collect();
let v = if !attached.is_empty() {
attached
} else {
i += 1;
args.get(i)
.ok_or_else(|| format!("option requires an argument -- {c}"))?
.clone()
};
match c {
'f' => files.push(v),
'O' => match v.strip_prefix("hashalg=") {
Some("sha1") => o.sshfp_hash = Some(1),
Some("sha256") => o.sshfp_hash = Some(2),
Some(other) => return Err(format!("Unsupported hash algorithm {other}")),
None => return Err("Unsupported -O option".into()),
},
'p' => o.port = v.parse().map_err(|_| format!("bad port '{v}'"))?,
'T' => {
let secs: u64 = v.parse().map_err(|_| format!("bad timeout '{v}'"))?;
o.timeout = Duration::from_secs(secs.max(1));
}
't' => {
o.types.clear();
for t in v.split(',') {
let t = match t {
"ssh-rsa" | "rsa" => "rsa",
"ecdsa-sha2-nistp256"
| "ecdsa-sha2-nistp384"
| "ecdsa-sha2-nistp521"
| "ecdsa" => "ecdsa",
"ssh-ed25519" | "ed25519" => "ed25519",
"ssh-mldsa44-ed25519@openssh.com" | "mldsa44-ed25519" => {
"mldsa44-ed25519"
}
"dsa" | "ssh-dss" | "ecdsa-sk" | "ed25519-sk" => continue,
other => return Err(format!("unknown key type {other}")),
};
if !o.types.contains(&t) {
o.types.push(t);
}
}
}
_ => unreachable!(),
}
break;
}
match c {
'4' => o.family = Some(true),
'6' => o.family = Some(false),
'H' => o.hash = true,
'v' => o.verbose = true,
'q' => o.quiet = true,
'c' => o.certs = true,
'D' => o.sshfp = true,
_ => return Err(format!("unknown option -- {c}")),
}
j += 1;
}
i += 1;
}
for f in files {
let reader: Box<dyn BufRead> = if f == "-" {
Box::new(std::io::BufReader::new(std::io::stdin()))
} else {
Box::new(std::io::BufReader::new(
std::fs::File::open(&f).map_err(|e| format!("{f}: {e}"))?,
))
};
for line in reader.lines() {
let line = line.map_err(|e| format!("{f}: {e}"))?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
o.hosts.push(line.to_string());
}
}
if o.hosts.is_empty() {
return Err(USAGE.into());
}
Ok(o)
}
type KeyFound = (String, Vec<u8>);
fn scan_one(
name: &str,
port: u16,
addrs: &[SocketAddr],
algs: &[&str],
timeout: Duration,
comment: Option<char>,
) -> Option<(String, Vec<u8>)> {
let found: Arc<Mutex<Option<KeyFound>>> = Arc::new(Mutex::new(None));
for addr in addrs {
let sink = found.clone();
let capture: Arc<TofuPromptFn> = Arc::new(move |_h, _p, key_type, blob| {
if let Ok(mut g) = sink.lock()
&& g.is_none()
{
*g = Some((key_type.to_string(), blob.to_vec()));
}
false
});
let mut policy = KnownHostsPolicy::strict(Arc::new(Mutex::new(KnownHosts::new())));
policy.on_unknown = TofuAction::Prompt(capture);
let first = found.clone();
policy.command = Some(Arc::new(move |key_type: &str, blob: &[u8]| {
if let Ok(mut g) = first.lock()
&& g.is_none()
{
*g = Some((key_type.to_string(), blob.to_vec()));
}
None
}));
let mut algorithms = AlgoOverrides::default();
algorithms.host_key_algorithms = Some(algs.iter().map(|a| a.to_string()).collect());
let mut cfg = Config::new(HostKeyPolicy::KnownHosts(policy)).with_algorithms(algorithms);
cfg.timeout = Some(timeout);
let Ok(stream) = TcpStream::connect_timeout(addr, timeout) else {
continue;
};
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
if let Some(c) = comment
&& let Some(banner) = peek_banner(&stream)
{
println!("{c} {name}:{port} {banner}");
}
let _ = Client::connect_via(Box::new(stream), name, port, cfg);
if let Some(k) = found.lock().ok().and_then(|mut g| g.take()) {
let key_type = if k.0.starts_with("rsa-sha2-") && !k.0.contains("-cert-") {
"ssh-rsa".to_string()
} else if k.0.starts_with("rsa-sha2-") {
"ssh-rsa-cert-v01@openssh.com".to_string()
} else {
k.0
};
return Some((key_type, k.1));
}
}
None
}
fn banner_comment(line: &[u8]) -> String {
let line = String::from_utf8_lossy(line);
line.trim_end_matches('\r')
.chars()
.map(|c| if c.is_control() { '?' } else { c })
.collect()
}
fn peek_banner(stream: &TcpStream) -> Option<String> {
let mut buf = [0u8; 256];
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(5) {
let n = stream.peek(&mut buf).ok()?;
if n == 0 {
return None;
}
if let Some(end) = buf[..n].iter().position(|b| *b == b'\n') {
return Some(banner_comment(&buf[..end]));
}
if n == buf.len() {
return None;
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
fn run() -> Result<i32, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let o = parse_args(&args)?;
let mut out = std::io::stdout();
let mut found_one = false;
for spec in &o.hosts {
let mut words = spec.split_whitespace();
let addr_list = words.next().unwrap_or("");
let names: Vec<&str> = words
.next()
.map(|n| n.split(',').collect())
.unwrap_or_else(|| addr_list.split(',').collect());
let target = addr_list.split(',').next().unwrap_or("");
let addrs: Vec<SocketAddr> = match (target, o.port).to_socket_addrs() {
Ok(a) => a
.filter(|a| match o.family {
Some(true) => a.is_ipv4(),
Some(false) => a.is_ipv6(),
None => true,
})
.collect(),
Err(e) => {
eprintln!("{target}: {e}");
continue;
}
};
let comment = (!o.quiet).then_some(if o.sshfp { ';' } else { '#' });
for ty in &o.types {
let algs = algorithms_for(ty, o.certs);
match scan_one(target, o.port, &addrs, algs, o.timeout, comment) {
Some((key_type, blob)) => {
found_one = true;
print_key(&o, &names, &key_type, &blob, &mut out)
}
None => {
if o.verbose {
eprintln!("# {target}:{} no {ty} host key", o.port);
}
}
}
}
}
Ok(if found_one { 0 } else { 1 })
}
fn print_key(o: &Opts, names: &[&str], key_type: &str, blob: &[u8], out: &mut impl Write) {
let joined = names.join(",");
let hosts: Vec<&str> = if o.certs || (!o.hash && o.port == 22) {
vec![joined.as_str()]
} else {
names.to_vec()
};
let b64 = purecrypto::der::base64_encode(blob);
for host in hosts {
let line = if o.sshfp {
sshfp_lines(host, blob, o.sshfp_hash)
} else if o.certs {
format!("{key_type} {b64}\n")
} else if o.hash {
let mut kh = KnownHosts::new();
kh.add(host, o.port, key_type, blob, true);
String::from_utf8_lossy(&kh.to_bytes()).into_owned()
} else {
let host = host.to_ascii_lowercase();
if o.port == 22 {
format!("{host} {key_type} {b64}\n")
} else {
format!("[{host}]:{} {key_type} {b64}\n", o.port)
}
};
let _ = out.write_all(line.as_bytes());
}
}
fn sshfp_lines(host: &str, blob: &[u8], only: Option<u8>) -> String {
use purecrypto::hash::{Digest, Sha1, Sha256};
let Some(alg) = puressh::key::PublicKey::parse_wire_blob(blob)
.ok()
.and_then(|k| k.sshfp_algorithm())
else {
return String::new();
};
let mut out = String::new();
for (fptype, digest) in [
(1u8, Sha1::digest(blob).as_ref().to_vec()),
(2, Sha256::digest(blob).as_ref().to_vec()),
] {
if only.is_some_and(|o| o != fptype) {
continue;
}
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
out.push_str(&format!("{host} IN SSHFP {alg} {fptype} {hex}\n"));
}
out
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("{e}");
ExitCode::from(1)
}
}
}
#[cfg(test)]
mod tests {
use super::banner_comment;
#[test]
fn banner_comment_scrubs_controls() {
assert_eq!(
banner_comment(b"SSH-2.0-OpenSSH_9.9\r"),
"SSH-2.0-OpenSSH_9.9"
);
assert_eq!(
banner_comment("SSH-2.0-x\x1b]0;pwn\x07\u{9b}2J\x7f".as_bytes()),
"SSH-2.0-x?]0;pwn??2J?"
);
}
}