use std::fs;
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[path = "fingerprint.rs"]
#[allow(dead_code)] mod fingerprint;
#[cfg(unix)]
#[path = "unix_sys.rs"]
mod unix_sys;
use std::path::Path;
use std::process::ExitCode;
use purecrypto::rng::{OsRng, RngCore};
use purecrypto::zeroize::Zeroizing;
use puressh::key::{EcdsaCurve, PrivateKey, PublicKey};
fn open_create_new(path: &str, _mode: u32) -> std::io::Result<fs::File> {
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
opts.mode(_mode);
}
opts.open(path)
}
fn unique_tmp_path(path: &str) -> String {
let mut rng = OsRng;
let mut nonce = [0u8; 8];
rng.fill_bytes(&mut nonce);
let nonce = u64::from_le_bytes(nonce);
let pid = std::process::id();
let p = Path::new(path);
let file_name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("ssh-keygen");
let tmp_name = format!(".{file_name}.tmp.{pid}.{nonce:016x}");
match p.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
parent.join(tmp_name).to_string_lossy().into_owned()
}
_ => tmp_name,
}
}
const VERSION: &str = env!("CARGO_PKG_VERSION");
const USAGE: &str = "usage: ssh-keygen [-q] [-a rounds] [-b bits] [-C comment] [-f output_keyfile]
[-m format] [-N new_passphrase] [-O option]
[-t ecdsa|ecdsa-sk|ed25519|ed25519-sk|mldsa44-ed25519|rsa]
[-w provider] [-Z cipher]
ssh-keygen -p [-a rounds] [-f keyfile] [-m format] [-N new_passphrase]
[-P old_passphrase] [-Z cipher]
ssh-keygen -i [-f input_keyfile] [-m key_format]
ssh-keygen -e [-f input_keyfile] [-m key_format]
ssh-keygen -y [-f input_keyfile]
ssh-keygen -c [-a rounds] [-C comment] [-f keyfile] [-P passphrase]
ssh-keygen -l [-v] [-E fingerprint_hash] [-f input_keyfile]
ssh-keygen -B [-f input_keyfile]
ssh-keygen -D pkcs11
ssh-keygen -F hostname [-lv] [-f known_hosts_file]
ssh-keygen -H [-f known_hosts_file]
ssh-keygen -K [-a rounds] [-w provider]
ssh-keygen -R hostname [-f known_hosts_file]
ssh-keygen -r hostname [-g] [-f input_keyfile]
ssh-keygen -M generate [-O option] output_file
ssh-keygen -M screen [-f input_file] [-O option] output_file
ssh-keygen -I certificate_identity -s ca_key [-hU] [-D pkcs11_provider]
[-n principals] [-O option] [-V validity_interval]
[-z serial_number] file ...
ssh-keygen -L [-f input_keyfile]
ssh-keygen -A [-a rounds] [-f prefix_path]
ssh-keygen -k -f krl_file [-u] [-s ca_public] [-z version_number]
file ...
ssh-keygen -Q [-l] -f krl_file [file ...]
ssh-keygen -Y find-principals -s signature_file -f allowed_signers_file
ssh-keygen -Y match-principals -I signer_identity -f allowed_signers_file
ssh-keygen -Y check-novalidate -n namespace -s signature_file
ssh-keygen -Y sign -f key_file -n namespace file [-O option] ...
ssh-keygen -Y verify -f allowed_signers_file -I signer_identity
-n namespace -s signature_file [-r krl_file] [-O option]";
#[derive(Default)]
struct Args {
type_: Option<String>,
bits: Option<usize>,
new_pass: Option<Zeroizing<String>>,
new_pass_set: bool,
old_pass: Option<Zeroizing<String>>,
comment: Option<String>,
file: Option<String>,
fingerprint: bool,
derive_pub: bool,
change_pass: bool,
change_comment: bool,
remove_host: Option<String>,
find_host: Option<String>,
hash_all: bool,
host_keys: bool,
quiet: bool,
verbose: u8,
sig_op: Option<String>,
namespace: Option<String>,
identity: Option<String>,
sig_file: Option<String>,
sig_options: Vec<String>,
use_agent: bool,
rest: Vec<String>,
host_cert: bool,
validity: Option<String>,
serial: Option<String>,
list_cert: bool,
format: Option<String>,
cipher: Option<String>,
export: bool,
import: bool,
fp_hash: Option<String>,
bubble: bool,
visual: bool,
rounds: Option<u32>,
krl: bool,
krl_update: bool,
krl_query: bool,
sshfp: Option<String>,
sshfp_generic: bool,
moduli: Option<String>,
sk_provider: Option<String>,
download_sk: bool,
pkcs11: Option<String>,
}
#[derive(Debug, PartialEq)]
enum ArgError {
Usage(Option<String>),
Fatal(String),
}
fn strtonum(s: &str, min: i64, max: i64) -> Result<i64, &'static str> {
match s.parse::<i64>() {
Ok(n) if n < min => Err("too small"),
Ok(n) if n > max => Err("too large"),
Ok(n) => Ok(n),
Err(e) => match e.kind() {
std::num::IntErrorKind::PosOverflow => Err("too large"),
std::num::IntErrorKind::NegOverflow => Err("too small"),
_ => Err("invalid"),
},
}
}
fn parse_args(args: &[String]) -> Result<Args, ArgError> {
const FLAGS: &str = "ABHKLQUXceghiklopquvy";
const WITH_VALUE: &str = "CDEFIMNOPRVYZabfmnrstwz";
let mut out = Args::default();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
i += 1;
break;
}
if !a.starts_with('-') || a == "-" {
break;
}
let chars: Vec<char> = a[1..].chars().collect();
let mut j = 0;
while j < chars.len() {
let c = chars[j];
if WITH_VALUE.contains(c) {
let attached: String = chars[j + 1..].iter().collect();
let v = if !attached.is_empty() {
attached
} else {
i += 1;
args.get(i).cloned().ok_or_else(|| {
ArgError::Usage(Some(format!("option requires an argument -- {c}")))
})?
};
set_value(&mut out, c, v)?;
break;
}
if !FLAGS.contains(c) {
return Err(ArgError::Usage(Some(format!("unknown option -- {c}"))));
}
match c {
'A' => out.host_keys = true,
'B' => out.bubble = true,
'H' => out.hash_all = true,
'L' => out.list_cert = true,
'Q' => out.krl_query = true,
'U' => out.use_agent = true,
'X' | 'i' => out.import = true,
'c' => out.change_comment = true,
'e' => out.export = true,
'g' => out.sshfp_generic = true,
'h' => out.host_cert = true,
'k' => out.krl = true,
'l' => out.fingerprint = true,
'o' => {}
'p' => out.change_pass = true,
'q' => out.quiet = true,
'u' => out.krl_update = true,
'v' => {
out.verbose = (out.verbose + 1).min(3);
out.visual = true;
}
'y' => out.derive_pub = true,
'K' => out.download_sk = true,
_ => unreachable!("in FLAGS"),
}
j += 1;
}
i += 1;
}
out.rest = args[i.min(args.len())..].to_vec();
Ok(out)
}
fn set_value(out: &mut Args, c: char, v: String) -> Result<(), ArgError> {
match c {
'C' => out.comment = Some(v),
'E' => {
if !puressh::key::is_fingerprint_hash(&v) {
return Err(ArgError::Fatal(format!("Invalid hash algorithm \"{v}\"")));
}
out.fp_hash = Some(v.to_ascii_lowercase());
}
'F' => out.find_host = Some(v),
'I' => out.identity = Some(v),
'M' => {
if v != "generate" && v != "screen" {
return Err(ArgError::Fatal(format!("Unsupported moduli option {v}")));
}
out.moduli = Some(v);
}
'N' => {
out.new_pass = Some(Zeroizing::new(v));
out.new_pass_set = true;
}
'O' => out.sig_options.push(v),
'P' => out.old_pass = Some(Zeroizing::new(v)),
'R' => out.remove_host = Some(v),
'V' => out.validity = Some(v),
'Y' => out.sig_op = Some(v),
'Z' => {
let mut known = false;
for name in puressh::key::OPENSSH_KEY_CIPHERS {
known |= *name == v;
}
if !known {
return Err(ArgError::Fatal(format!(
"Invalid OpenSSH-format cipher '{v}'"
)));
}
out.cipher = Some(v);
}
'a' => {
let n = strtonum(&v, 1, 10_000)
.map_err(|e| ArgError::Fatal(format!("Invalid number: {v} ({e})")))?;
out.rounds = Some(n as u32);
}
'b' => {
let n = strtonum(&v, 1, u32::MAX as i64)
.map_err(|e| ArgError::Fatal(format!("Bits has bad value {v} ({e})")))?;
out.bits = Some(n as usize);
}
'f' => out.file = Some(v),
'm' => {
let f = v.to_ascii_uppercase();
out.format = Some(match f.as_str() {
"RFC4716" | "SSH2" => "RFC4716".into(),
"PKCS8" | "PEM" | "HEXDUMP" => f,
_ => {
return Err(ArgError::Fatal(format!(
"Unsupported conversion format \"{v}\""
)));
}
});
}
'n' => out.namespace = Some(v),
'r' => out.sshfp = Some(v),
's' => out.sig_file = Some(v),
't' => out.type_ = Some(v),
'z' => {
let digits = v.strip_prefix('+').unwrap_or(&v);
if !digits.starts_with(|c: char| c.is_ascii_digit()) || digits.parse::<u64>().is_err() {
return Err(ArgError::Fatal(format!(
"Invalid serial number \"{digits}\""
)));
}
out.serial = Some(v);
}
'D' => out.pkcs11 = Some(v),
'w' => out.sk_provider = Some(v),
_ => {
return Err(ArgError::Usage(Some(format!("unknown option -- {c}"))));
}
}
Ok(())
}
fn read_to_string(path: &str) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("{path}: {}", io_reason(&e)))
}
const RAW: char = '\u{1}';
const FATAL: char = '\u{2}';
fn load_reason(e: &puressh::Error) -> String {
match e {
puressh::Error::Crypto(m) if m.contains("passphrase") => {
"incorrect passphrase supplied to decrypt private key".into()
}
other => other.to_string(),
}
}
#[cfg_attr(not(feature = "security-key"), allow(dead_code))]
fn write_private_bytes(path: &str, contents: &[u8]) -> std::io::Result<()> {
let mut o = fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
o.open(path)?.write_all(contents)
}
fn write_private(path: &str, contents: &str) -> Result<(), String> {
let p = Path::new(path);
if let Some(parent) = p.parent()
&& !parent.as_os_str().is_empty()
{
let existed_before = parent.exists();
fs::create_dir_all(parent).map_err(|e| format!("mkdir {parent:?}: {e}"))?;
#[cfg(unix)]
{
if !existed_before {
use std::os::unix::fs::PermissionsExt as _;
let _ = fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
#[cfg(not(unix))]
{
let _ = existed_before; }
}
if p.exists() {
return Err(format!("{path} already exists"));
}
let mut f = open_create_new(path, 0o600).map_err(|e| format!("open {path}: {e}"))?;
f.write_all(contents.as_bytes())
.map_err(|e| format!("write {path}: {e}"))?;
Ok(())
}
fn write_public(path: &str, contents: &str) -> Result<(), String> {
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
opts.mode(0o644);
}
let mut f = opts.open(path).map_err(|e| format!("open {path}: {e}"))?;
f.write_all(contents.as_bytes())
.map_err(|e| format!("write {path}: {e}"))?;
Ok(())
}
fn load_public_any(path: &str) -> Result<PublicKey, String> {
let text = read_to_string(path)?;
if let Ok(pk) = PublicKey::parse_authorized_keys_line(text.trim()) {
return Ok(pk);
}
if let Ok(pubtext) = fs::read_to_string(format!("{path}.pub"))
&& let Ok(pk) = PublicKey::parse_authorized_keys_line(pubtext.trim())
{
return Ok(pk);
}
PrivateKey::public_key_from_pem(&text).map_err(|e| format!("{path}: {e}"))
}
fn askpass(prompt: &str) -> Option<Zeroizing<String>> {
use std::io::IsTerminal;
let set = |v: &str| std::env::var_os(v).is_some_and(|s| !s.is_empty());
let mut allow = set("DISPLAY") || set("WAYLAND_DISPLAY");
let mut use_it = false;
match std::env::var("SSH_ASKPASS_REQUIRE")
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"force" => {
use_it = true;
allow = true;
}
"prefer" => use_it = allow,
"never" => allow = false,
_ => {}
}
if !std::io::stdin().is_terminal() {
use_it = true;
}
if !(use_it && allow) {
return None;
}
let helper = std::env::var_os("SSH_ASKPASS")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "/usr/bin/ssh-askpass".into());
let out = std::process::Command::new(helper)
.arg(prompt)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::inherit())
.output();
let answer = match out {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
text.split(['\r', '\n']).next().unwrap_or("").to_string()
}
_ => String::new(),
};
Some(Zeroizing::new(answer))
}
fn read_passphrase_interactive(prompt: &str) -> Result<Zeroizing<String>, String> {
if let Some(answer) = askpass(prompt) {
return Ok(answer);
}
eprint!("{prompt}");
let _ = std::io::stderr().flush();
#[cfg(unix)]
{
if let Some(out) =
read_passphrase_no_echo_unix().map_err(|e| format!("read passphrase: {e}"))?
{
return Ok(out);
}
}
eprintln!();
eprintln!("(warning: terminal echo could not be disabled; passphrase will be visible)");
let mut s = String::new();
let mut byte = [0u8; 1];
let mut stdin = std::io::stdin();
let max_len = 4096usize;
loop {
let n = stdin
.read(&mut byte)
.map_err(|e| format!("read passphrase: {e}"))?;
if n == 0 || byte[0] == b'\n' {
break;
}
if byte[0] == b'\r' {
continue;
}
s.push(byte[0] as char);
if s.len() > max_len {
break;
}
}
Ok(Zeroizing::new(s))
}
fn read_new_passphrase_confirmed(prompt: &str) -> Result<Zeroizing<String>, String> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Ok(Zeroizing::new(String::new()));
}
loop {
let first = read_passphrase_interactive(prompt)?;
let second = read_passphrase_interactive("Enter same passphrase again: ")?;
if *first == *second {
return Ok(first);
}
eprintln!("Passphrases do not match. Try again.");
}
}
#[cfg(unix)]
fn read_passphrase_no_echo_unix() -> std::io::Result<Option<Zeroizing<String>>> {
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let mut term: libc::termios = unsafe { core::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut term as *mut _) } != 0 {
return Ok(None);
}
let original = term;
struct EchoGuard {
fd: libc::c_int,
original: libc::termios,
}
impl Drop for EchoGuard {
fn drop(&mut self) {
unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &self.original) };
}
}
term.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &term) } != 0 {
return Ok(None);
}
let _guard = EchoGuard { fd, original };
let mut s = String::new();
let mut byte = [0u8; 1];
let mut stdin = std::io::stdin();
let max_len = 4096usize;
loop {
let n = stdin.read(&mut byte)?;
if n == 0 || byte[0] == b'\n' {
break;
}
if byte[0] == b'\r' {
continue;
}
s.push(byte[0] as char);
if s.len() > max_len {
break;
}
}
eprintln!();
Ok(Some(Zeroizing::new(s)))
}
fn algorithm_label(pk: &PublicKey) -> &'static str {
match pk {
PublicKey::Ed25519 { .. } => "ED25519",
PublicKey::EcdsaP256 { .. } => "ECDSA",
PublicKey::EcdsaP384 { .. } => "ECDSA",
PublicKey::EcdsaP521 { .. } => "ECDSA",
PublicKey::Rsa { .. } => "RSA",
PublicKey::MlDsa44Ed25519 { .. } => "MLDSA44-ED25519",
PublicKey::SkEcdsaP256 { .. } => "ECDSA-SK",
PublicKey::SkEd25519 { .. } => "ED25519-SK",
_ => "UNKNOWN",
}
}
fn load_public(path: &str) -> Result<PublicKey, String> {
let text = read_to_string(path)?;
if let Ok(p) = PublicKey::parse_authorized_keys_line(text.trim()) {
return Ok(p);
}
let pk = PrivateKey::parse_pem(&text, None).map_err(|e| {
format!("{path}: not a public key line and not an unencrypted private key ({e})")
})?;
Ok(pk.public_key())
}
fn fingerprint_key(path: &str) -> Result<PublicKey, String> {
let text = read_to_string(path)?;
if let Ok(pk) = PublicKey::parse_authorized_keys_line(text.trim()) {
return Ok(pk);
}
let from_pub = fs::read_to_string(format!("{path}.pub"))
.ok()
.and_then(|t| PublicKey::parse_authorized_keys_line(t.trim()).ok());
if let Some(pk) = &from_pub
&& !pk.comment().is_empty()
{
return Ok(pk.clone());
}
match PrivateKey::parse_pem(&text, None) {
Ok(sk) => {
let pk = from_pub.unwrap_or_else(|| sk.public_key());
if sk.comment().is_empty() {
return Ok(pk);
}
let full = pk.to_authorized_keys_line();
let bare: Vec<&str> = full.split_whitespace().take(2).collect();
let line = format!("{} {}", bare.join(" "), sk.comment());
Ok(PublicKey::parse_authorized_keys_line(&line).unwrap_or(pk))
}
Err(_) => from_pub
.map(Ok)
.unwrap_or_else(|| PrivateKey::public_key_from_pem(&text))
.map_err(|_| format!("{path} is not a key file.")),
}
}
fn default_key_file(args: &Args) -> Result<String, String> {
let ty = args
.type_
.as_deref()
.unwrap_or("ed25519")
.to_ascii_lowercase();
let name = match ty.as_str() {
"ecdsa" | "ecdsa-sha2-nistp256" | "ecdsa-sha2-nistp384" | "ecdsa-sha2-nistp521" => {
"id_ecdsa"
}
"ecdsa-sk" | "sk-ecdsa-sha2-nistp256@openssh.com" => "id_ecdsa_sk",
"rsa" | "ssh-rsa" | "rsa-sha2-256" | "rsa-sha2-512" => "id_rsa",
"ed25519" | "ssh-ed25519" => "id_ed25519",
"ed25519-sk" | "sk-ssh-ed25519@openssh.com" => "id_ed25519_sk",
"mldsa44-ed25519" | "ssh-mldsa44-ed25519" => "id_mldsa44_ed25519",
_ => return Err(format!("{FATAL}bad key type")),
};
#[cfg(unix)]
let home = unix_sys::passwd_entry(unix_sys::getuid()).map(|(_, dir)| dir);
#[cfg(not(unix))]
let home = std::env::var("USERPROFILE").ok();
let home = home.ok_or("cannot determine the home directory")?;
Ok(format!("{home}/.ssh/{name}"))
}
fn ensure_file_in(args: &Args) -> Result<String, String> {
if let Some(f) = &args.file {
return Ok(f.clone());
}
let default = default_key_file(args)?;
match ask(&format!("Enter file in which the key is ({default}): ")) {
None => std::process::exit(1),
Some(a) if a.is_empty() => Ok(default),
Some(a) => Ok(a),
}
}
fn ask(prompt: &str) -> Option<String> {
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()),
}
}
fn run_generate(args: &Args) -> Result<i32, String> {
let named = args.type_.as_deref().unwrap_or("ed25519");
let (ty, curve_bits) = match named {
"ssh-ed25519" => ("ed25519".to_string(), None),
"ssh-mldsa44-ed25519" => ("mldsa44-ed25519".to_string(), None),
"ssh-rsa" | "rsa-sha2-256" | "rsa-sha2-512" => ("rsa".to_string(), None),
"ecdsa-sha2-nistp256" => ("ecdsa".to_string(), Some(256)),
"ecdsa-sha2-nistp384" => ("ecdsa".to_string(), Some(384)),
"ecdsa-sha2-nistp521" => ("ecdsa".to_string(), Some(521)),
"sk-ecdsa-sha2-nistp256@openssh.com" => ("ecdsa-sk".to_string(), None),
"sk-ssh-ed25519@openssh.com" => ("ed25519-sk".to_string(), None),
other => (other.to_lowercase(), None),
};
if !args.quiet {
println!("Generating public/private {ty} key pair.");
}
let file = match &args.file {
Some(f) => f.clone(),
None => {
let home = std::env::var("HOME").map_err(|_| "$HOME is not set")?;
let default = format!("{home}/.ssh/id_{}", ty.replace('-', "_"));
let answer = ask(&format!(
"Enter file in which to save the key ({default}): "
))
.ok_or("no file name given")?;
let file = if answer.is_empty() { default } else { answer };
if let Some(dir) = Path::new(&file).parent()
&& !dir.as_os_str().is_empty()
&& !dir.exists()
{
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
println!("Created directory '{}'.", dir.display());
}
file
}
};
if Path::new(&file).exists() {
let answer = ask(&format!("{file} already exists.\nOverwrite (y/n)? ")).unwrap_or_default();
if !answer.starts_with(['y', 'Y']) {
return Ok(1);
}
let _ = fs::remove_file(&file);
let _ = fs::remove_file(format!("{file}.pub"));
}
let pub_path = format!("{file}.pub");
let comment = args.comment.clone().unwrap_or_else(user_at_host);
let mut rng = OsRng;
#[cfg_attr(not(feature = "security-key"), allow(unused_mut))]
let mut attestation: Option<(String, Vec<u8>)> = None;
let sk = match ty.as_str() {
#[cfg(feature = "security-key")]
"ecdsa-sk" | "ed25519-sk" => {
let (mut key, attest) = security_key::enroll(args, &ty)?;
key.set_comment(comment);
attestation = attest;
key
}
"ed25519" => PrivateKey::generate_ed25519(&mut rng, comment),
"mldsa44-ed25519" => PrivateKey::generate_mldsa44_ed25519(&mut rng, comment),
"ecdsa" => {
let bits = args.bits.or(curve_bits).unwrap_or(256);
let curve = match bits {
256 => EcdsaCurve::P256,
384 => EcdsaCurve::P384,
521 => EcdsaCurve::P521,
_ => {
return Err(format!(
"ecdsa: unsupported bit length {bits} (256|384|521)"
));
}
};
PrivateKey::generate_ecdsa(&mut rng, curve, comment)
}
"rsa" => {
let bits = args.bits.unwrap_or(3072);
if bits < 2048 {
return Err("RSA key size must be at least 2048 bits".to_string());
}
PrivateKey::generate_rsa(&mut rng, bits, comment)
.map_err(|e| format!("rsa generation: {e}"))?
}
other => return Err(format!("unsupported key type: {other}")),
};
let passphrase: Zeroizing<String> = if args.new_pass_set {
args.new_pass.clone().unwrap_or_default()
} else {
read_new_passphrase_confirmed("Enter passphrase (empty for no passphrase): ")?
};
let pem = encode_private(&sk, args, &passphrase)
.map_err(|e| format!("Saving key \"{file}\" failed: {e}"))?;
let pub_line = sk.public_key().to_authorized_keys_line();
let mut pub_full = pub_line;
pub_full.push('\n');
write_private(&file, &pem)?;
write_public(&pub_path, &pub_full)?;
if let Some((path, blob)) = attestation {
write_private_bytes(&path, &blob)
.map_err(|e| format!("{RAW}Unable to write attestation data \"{path}\": {e}"))?;
if !args.quiet {
println!("Your FIDO attestation certificate has been saved in {path}");
}
}
if !args.quiet {
let pk = sk.public_key();
println!("Your identification has been saved in {file}");
println!("Your public key has been saved in {pub_path}");
println!("The key fingerprint is:");
println!("{} {}", pk.sha256_fingerprint(), pk.comment());
println!("The key's randomart image is:");
println!("{}", randomart(&pk, &sha256(&pk.wire_blob()), "SHA256"));
}
Ok(0)
}
#[cfg(feature = "pkcs11")]
fn run_pkcs11_download(args: &Args, module: &str) -> Result<i32, i32> {
struct Prompt;
impl puressh::pkcs11::PinPrompt for Prompt {
fn pin(&self, label: &str) -> Option<Zeroizing<String>> {
read_passphrase_interactive(&format!("Enter PIN for '{label}': "))
.ok()
.filter(|p| !p.is_empty())
}
}
let keys = match puressh::pkcs11::load(module, None, Some(std::sync::Arc::new(Prompt))) {
Ok(k) if !k.is_empty() => k,
Ok(_) | Err(_) => {
eprintln!("cannot read public key from pkcs11");
return Err(255);
}
};
for k in &keys {
let pk = k.public_key();
if args.fingerprint {
let fp = if args.bubble {
bubblebabble(&sha1(&pk.wire_blob()))
} else {
pk.sha256_fingerprint()
};
println!(
"{} {fp} {} (PKCS11 key)",
pk.bit_length(),
algorithm_label(pk)
);
} else {
println!("{}", pk.to_authorized_keys_line());
}
}
Ok(0)
}
fn run_host_keys(args: &Args) -> Result<i32, String> {
let prefix = args.file.clone().unwrap_or_default();
let dir = format!("{}/etc/ssh", prefix.trim_end_matches('/'));
let host = fs::read_to_string("/proc/sys/kernel/hostname")
.or_else(|_| fs::read_to_string("/etc/hostname"))
.map(|h| h.trim().to_string())
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "localhost".into());
let comment = format!("root@{host}");
fs::create_dir_all(&dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
let mut made = Vec::new();
for (ty, label) in [
("rsa", "RSA"),
("ecdsa", "ECDSA"),
("ed25519", "ED25519"),
("mldsa44-ed25519", "MLDSA44-ED25519"),
] {
let path = format!("{dir}/ssh_host_{}_key", ty.replace('-', "_"));
if Path::new(&path).exists() {
continue;
}
if made.is_empty() && !args.quiet {
print!("ssh-keygen: generating new host keys: ");
}
let mut rng = OsRng;
let sk = match ty {
"rsa" => PrivateKey::generate_rsa(&mut rng, 3072, comment.clone())
.map_err(|e| format!("rsa generation: {e}"))?,
"ecdsa" => PrivateKey::generate_ecdsa(&mut rng, EcdsaCurve::P256, comment.clone()),
"mldsa44-ed25519" => PrivateKey::generate_mldsa44_ed25519(&mut rng, comment.clone()),
_ => PrivateKey::generate_ed25519(&mut rng, comment.clone()),
};
let pem = sk
.to_openssh_pem(None)
.map_err(|e| format!("encode: {e}"))?;
let mut pub_line = sk.public_key().to_authorized_keys_line();
pub_line.push('\n');
let tmp = format!("{path}.{}.tmp", std::process::id());
let tmp_pub = format!("{tmp}.pub");
let _ = fs::remove_file(&tmp);
let _ = fs::remove_file(&tmp_pub);
write_private(&tmp, &pem)?;
write_public(&tmp_pub, &pub_line)?;
fs::rename(&tmp_pub, format!("{path}.pub"))
.map_err(|e| format!("rename {tmp_pub}: {e}"))?;
fs::rename(&tmp, &path).map_err(|e| format!("rename {tmp}: {e}"))?;
if !args.quiet {
print!("{label} ");
}
made.push(label);
}
if !made.is_empty() && !args.quiet {
println!();
}
Ok(0)
}
fn fingerprint_text(pk: &PublicKey, args: &Args) -> Result<String, String> {
if args.bubble {
return Ok(bubblebabble(&sha1(&pk.wire_blob())));
}
match args.fp_hash.as_deref().unwrap_or("sha256") {
"md5" => Ok(format!(
"MD5:{}",
md5(&pk.wire_blob())
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
)),
hash => pk
.fingerprint(hash)
.ok_or_else(|| format!("Invalid hash algorithm \"{hash}\"")),
}
}
fn sha1(data: &[u8]) -> Vec<u8> {
use purecrypto::hash::{Digest, Sha1};
Sha1::digest(data).as_ref().to_vec()
}
fn md5(data: &[u8]) -> Vec<u8> {
use purecrypto::hash::{Digest, Md5};
Md5::digest(data).as_ref().to_vec()
}
fn sha256(data: &[u8]) -> Vec<u8> {
use purecrypto::hash::{Digest, Sha256};
Sha256::digest(data).as_ref().to_vec()
}
fn bubblebabble(d: &[u8]) -> String {
const VOWELS: &[u8] = b"aeiouy";
const CONSONANTS: &[u8] = b"bcdfghklmnprstvzx";
let rounds = d.len() / 2 + 1;
let mut seed = 1usize;
let mut out = String::from("x");
for i in 0..rounds {
if i + 1 < rounds || !d.len().is_multiple_of(2) {
let b = d[2 * i] as usize;
out.push(VOWELS[(((b >> 6) & 3) + seed) % 6] as char);
out.push(CONSONANTS[(b >> 2) & 15] as char);
out.push(VOWELS[((b & 3) + seed / 6) % 6] as char);
if i + 1 < rounds {
let c = d[2 * i + 1] as usize;
out.push(CONSONANTS[(c >> 4) & 15] as char);
out.push('-');
out.push(CONSONANTS[c & 15] as char);
seed = (seed * 5 + b * 7 + c) % 36;
}
} else {
out.push(VOWELS[seed % 6] as char);
out.push(CONSONANTS[16] as char);
out.push(VOWELS[seed / 6] as char);
}
}
out.push('x');
out
}
fn randomart(pk: &PublicKey, digest: &[u8], hash_name: &str) -> String {
fingerprint::randomart(
algorithm_label(pk),
pk.bit_length() as usize,
digest,
hash_name,
)
}
fn run_fingerprint(args: &Args) -> Result<i32, String> {
let file = ensure_file_in(args)?;
let keys: Vec<(PublicKey, Option<String>)> = match fingerprint_key(&file) {
Ok(pk) => vec![(pk, None)],
Err(e) => {
let text = read_to_string(&file)?;
let keys: Vec<(PublicKey, Option<String>)> = text
.lines()
.filter_map(|l| {
let l = l.trim();
if l.is_empty() || l.starts_with('#') {
return None;
}
if let Ok(pk) = PublicKey::parse_authorized_keys_line(l) {
return Some((pk, None));
}
if l.starts_with('@') {
return None;
}
let (hosts, rest) = l.split_once(char::is_whitespace)?;
PublicKey::parse_authorized_keys_line(rest.trim())
.ok()
.map(|pk| (pk, Some(hosts.to_string())))
})
.collect();
if keys.is_empty() {
return Err(e);
}
keys
}
};
for (pk, hosts) in keys {
let fp = fingerprint_text(&pk, args)?;
let comment = match &hosts {
Some(h) => h.as_str(),
None if pk.comment().is_empty() => "no comment",
None => pk.comment(),
};
println!(
"{} {fp} {comment} ({})",
pk.bit_length(),
algorithm_label(&pk)
);
if args.visual && !args.bubble {
let hash = args.fp_hash.as_deref().unwrap_or("sha256");
let (name, digest) = pk
.fingerprint_digest(hash)
.ok_or_else(|| format!("Invalid hash algorithm \"{hash}\""))?;
println!("{}", randomart(&pk, &digest, name));
}
}
Ok(0)
}
fn user_at_host() -> String {
#[cfg(unix)]
let pw = unix_sys::passwd_entry(unix_sys::getuid()).map(|(name, _)| name);
#[cfg(not(unix))]
let pw: Option<String> = None;
let user = pw
.or_else(|| std::env::var("USER").ok())
.or_else(|| std::env::var("LOGNAME").ok())
.unwrap_or_else(|| "user".into());
let host = fs::read_to_string("/proc/sys/kernel/hostname")
.or_else(|_| fs::read_to_string("/etc/hostname"))
.map(|h| h.trim().to_string())
.ok()
.filter(|h| !h.is_empty())
.or_else(|| std::env::var("HOSTNAME").ok())
.unwrap_or_else(|| "localhost".into());
format!("{user}@{host}")
}
fn run_export(args: &Args) -> Result<i32, String> {
let file = ensure_file_in(args)?;
let format = args.format.as_deref().unwrap_or("RFC4716");
if format == "HEXDUMP" {
return run_export_hexdump(args, &file);
}
let pk = load_public(&file)?;
let out = match format {
"RFC4716" => pk.to_rfc4716(&user_at_host()),
"PKCS8" | "PEM" if matches!(pk, PublicKey::MlDsa44Ed25519 { .. }) => {
return Err("unsupported key type MLDSA44-ED25519".into());
}
"PKCS8" => pk.to_spki_pem(),
"PEM" => pk.to_pem(),
other => return Err(format!("Unsupported conversion format \"{other}\"")),
};
print!("{out}");
Ok(0)
}
fn hexdump(data: &[u8]) -> String {
let mut out = format!("buffer len = {}\n", data.len());
for (n, chunk) in data.chunks(16).enumerate() {
out.push_str(&format!("{:04}: ", n * 16));
for i in 0..16 {
match chunk.get(i) {
Some(b) => out.push_str(&format!("{b:02x} ")),
None => out.push_str(" "),
}
}
out.push(' ');
for b in chunk {
let printable = b.is_ascii_graphic() || *b == b' ';
out.push(if printable { *b as char } else { '.' });
}
out.push('\n');
}
out
}
fn run_export_hexdump(args: &Args, file: &str) -> Result<i32, String> {
let text = read_to_string(file)?;
let private = match PrivateKey::parse_pem(&text, None) {
Ok(sk) => Some(sk),
Err(puressh::Error::Crypto(_)) => {
let pass = match &args.old_pass {
Some(p) => p.clone(),
None => read_passphrase_interactive("Enter passphrase: ")?,
};
let pass = (!pass.is_empty()).then_some(pass.as_bytes());
Some(
PrivateKey::parse_pem(&text, pass)
.map_err(|e| format!("{RAW}Load key \"{file}\": {}", load_reason(&e)))?,
)
}
Err(_) => None,
};
let pk = match &private {
Some(sk) => sk.public_key(),
None => load_public(file)?,
};
let label = algorithm_label(&pk);
print!("Public {label} key:\n{}", hexdump(&pk.wire_blob()));
if let Some(sk) = private {
let body = Zeroizing::new(sk.to_agent_add_body());
let end = body.len() - 4 - sk.comment().len();
print!("\nPrivate {label} key:\n{}", hexdump(&body[..end]));
}
Ok(0)
}
fn run_import(args: &Args) -> Result<i32, String> {
if args.format.as_deref() == Some("HEXDUMP") {
return Err(format!("{FATAL}do_convert_from: unknown key format 3"));
}
let file = ensure_file_in(args)?;
let text = read_to_string(&file)?;
let pk = PublicKey::parse_foreign(&text).map_err(|e| format!("{file}: {e}"))?;
println!("{}", pk.to_authorized_keys_line());
Ok(0)
}
fn encode_private(sk: &PrivateKey, args: &Args, passphrase: &str) -> Result<String, String> {
let pass = (!passphrase.is_empty()).then_some(passphrase.as_bytes());
match args.format.as_deref().unwrap_or("RFC4716") {
"RFC4716" | "HEXDUMP" => match (&args.cipher, args.rounds) {
(None, None) => sk.to_openssh_pem(pass),
(cipher, rounds) => sk.to_openssh_pem_with_cipher(
pass,
rounds.unwrap_or(puressh::key::DEFAULT_KDF_ROUNDS),
cipher.as_deref().unwrap_or("aes256-ctr"),
),
}
.map_err(|e| format!("encode: {e}")),
"PEM" if !passphrase.is_empty() => sk
.to_traditional_pem_encrypted(&mut purecrypto::rng::OsRng, passphrase.as_bytes())
.map_err(|e| format!("invalid format: {e}")),
"PKCS8" if !passphrase.is_empty() => sk
.to_pkcs8_pem_encrypted(&mut purecrypto::rng::OsRng, passphrase.as_bytes())
.map_err(|e| format!("invalid format: {e}")),
"PEM" => sk
.to_traditional_pem()
.map_err(|e| format!("invalid format: {e}")),
"PKCS8" => sk
.to_pkcs8_pem()
.map_err(|e| format!("invalid format: {e}")),
other => Err(format!("Unsupported key format \"{other}\"")),
}
}
fn replace_private(file: &str, pem: &str) -> Result<(), String> {
let tmp = unique_tmp_path(file);
let write_result = (|| -> Result<(), String> {
let mut f = open_create_new(&tmp, 0o600).map_err(|e| format!("open {tmp}: {e}"))?;
f.write_all(pem.as_bytes())
.map_err(|e| format!("write {tmp}: {e}"))?;
f.sync_all().map_err(|e| format!("fsync {tmp}: {e}"))?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = fs::rename(&tmp, file) {
let _ = fs::remove_file(&tmp);
return Err(format!("rename {tmp} -> {file}: {e}"));
}
Ok(())
}
fn run_change_comment(args: &Args) -> Result<i32, String> {
let file = ensure_file_in(args)?;
let text = match fs::read_to_string(&file) {
Ok(t) => t,
Err(e) => return Err(format!("{FATAL}{file}: {}", io_reason(&e))),
};
let cannot = |e: &puressh::Error| {
format!(
"{FATAL}Cannot load private key \"{file}\": {}",
load_reason(e)
)
};
let (mut sk, passphrase) = match PrivateKey::parse_pem(&text, None) {
Ok(sk) => (sk, Zeroizing::new(String::new())),
Err(puressh::Error::Crypto(_)) => {
let pass = match args.old_pass.as_ref().or(args.new_pass.as_ref()) {
Some(p) => p.clone(),
None => read_passphrase_interactive("Enter passphrase: ")?,
};
let try_pass = (!pass.is_empty()).then_some(pass.as_bytes());
let sk = PrivateKey::parse_pem(&text, try_pass).map_err(|e| cannot(&e))?;
(sk, pass)
}
Err(e) => return Err(cannot(&e)),
};
let format = args.format.as_deref().unwrap_or("RFC4716");
if !matches!(sk, PrivateKey::Ed25519 { .. }) && matches!(format, "PEM" | "PKCS8") {
return Err(format!(
"{RAW}Comments are only supported for keys stored in the new format (-o)."
));
}
let old = text
.contains("-----BEGIN OPENSSH PRIVATE KEY-----")
.then(|| sk.comment().to_string());
match &old {
Some(c) => println!("Old comment: {c}"),
None => println!("No existing comment"),
}
let new = match &args.comment {
Some(c) => c.clone(),
None => match ask("New comment: ") {
Some(c) => c,
None => return Ok(1),
},
};
if old.as_deref() == Some(new.as_str()) {
println!("No change to comment");
return Ok(0);
}
sk.set_comment(new.clone());
let pem = encode_private(&sk, args, &passphrase)
.map_err(|e| format!("{RAW}Saving key \"{file}\" failed: {e}"))?;
replace_private(&file, &pem).map_err(|e| format!("{RAW}Saving key \"{file}\" failed: {e}"))?;
let pub_path = format!("{file}.pub");
let mut line = sk.public_key().to_authorized_keys_line();
line.push('\n');
if let Err(e) = fs::write(&pub_path, line) {
return Err(format!(
"{FATAL}Unable to save public key to {pub_path}: {}",
io_reason(&e)
));
}
if new.is_empty() {
println!("Comment removed");
} else {
println!("Comment '{new}' applied");
}
Ok(0)
}
fn run_extract_public(args: &Args) -> Result<i32, String> {
let file = ensure_file_in(args)?;
let text = read_to_string(&file)?;
let pem_pass = args
.old_pass
.as_deref()
.or(args.new_pass.as_deref())
.map(String::as_str)
.filter(|p| !p.is_empty());
let sk = match PrivateKey::parse_pem(&text, pem_pass.map(str::as_bytes)) {
Ok(sk) => sk,
Err(_) if pem_pass.is_none() => {
let pp = read_passphrase_interactive("Enter passphrase: ")?;
PrivateKey::parse_pem(
&text,
if pp.is_empty() {
None
} else {
Some(pp.as_bytes())
},
)
.map_err(|e| format!("{RAW}Load key \"{file}\": {}", load_reason(&e)))?
}
Err(e) => return Err(format!("{RAW}Load key \"{file}\": {}", load_reason(&e))),
};
let pub_line = sk.public_key().to_authorized_keys_line();
println!("{pub_line}");
Ok(0)
}
fn run_change_passphrase(args: &Args) -> Result<i32, String> {
let file = ensure_file_in(args)?;
let text = read_to_string(&file)?;
let old_pass = args
.old_pass
.as_deref()
.map(String::as_str)
.filter(|p| !p.is_empty());
let sk = match PrivateKey::parse_pem(&text, old_pass.map(str::as_bytes)) {
Ok(sk) => sk,
Err(_) if old_pass.is_none() => {
let pp = read_passphrase_interactive("Enter old passphrase: ")?;
PrivateKey::parse_pem(
&text,
if pp.is_empty() {
None
} else {
Some(pp.as_bytes())
},
)
.map_err(|e| format!("{RAW}Failed to load key {file}: {}", load_reason(&e)))?
}
Err(e) => {
return Err(format!(
"{RAW}Failed to load key {file}: {}",
load_reason(&e)
));
}
};
if text.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
println!("Key has comment '{}'", sk.comment());
}
let new_pass: Zeroizing<String> = if args.new_pass_set {
args.new_pass.clone().unwrap_or_default()
} else {
read_new_passphrase_confirmed("Enter new passphrase (empty for no passphrase): ")?
};
let pem = encode_private(&sk, args, &new_pass)?;
replace_private(&file, &pem)?;
println!("Your identification has been saved with the new passphrase.");
Ok(0)
}
fn resolve_known_hosts_path(args: &Args) -> Result<String, String> {
let path = match &args.file {
Some(f) => f.clone(),
None => {
let home = std::env::var("HOME").map_err(|_| {
"no -f given and $HOME is unset; cannot locate default known_hosts".to_string()
})?;
format!("{home}/.ssh/known_hosts")
}
};
if args.verbose >= 3 {
eprintln!("debug3: hostkeys_foreach: reading file \"{path}\"");
}
Ok(path)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum KhStatus {
Comment,
Ok,
Matched,
Invalid,
}
struct KhLine<'a> {
num: usize,
line: &'a str,
status: KhStatus,
marker: &'static str,
hosts: &'a str,
rawkey: &'a str,
key: Option<PublicKey>,
}
fn known_key_type(name: &str) -> bool {
name.starts_with("ssh-ed25519")
|| name.starts_with("ecdsa-sha2-nistp")
|| name.starts_with("ssh-rsa")
|| name.starts_with("rsa-sha2-")
|| name.starts_with("sk-ecdsa-sha2-nistp256")
|| name.starts_with("sk-ssh-ed25519")
|| name.starts_with("ssh-mldsa44-ed25519")
|| name.starts_with("webauthn-sk-")
}
fn kh_glob(pat: &[u8], s: &[u8]) -> bool {
let (mut p, mut i, mut star, mut mark) = (0usize, 0usize, None, 0usize);
while i < s.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p].eq_ignore_ascii_case(&s[i])) {
p += 1;
i += 1;
} else if p < pat.len() && pat[p] == b'*' {
star = Some(p);
p += 1;
mark = i;
} else if let Some(sp) = star {
p = sp + 1;
mark += 1;
i = mark;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
fn kh_match(host: &str, hosts: &str) -> Option<bool> {
use puressh::known_hosts::hash::{check_hashed, parse_hashed};
if hosts.starts_with('|') {
let (salt, tag) = parse_hashed(hosts)?;
return Some(check_hashed(&salt, &tag, host, 22));
}
let host = host.to_ascii_lowercase();
let mut matched = false;
for pat in hosts.split(',') {
let (neg, pat) = match pat.strip_prefix('!') {
Some(p) => (true, p),
None => (false, pat),
};
if kh_glob(pat.as_bytes(), host.as_bytes()) {
if neg {
return Some(false);
}
matched = true;
}
}
Some(matched)
}
fn kh_scan<'a>(
text: &'a str,
host: Option<&str>,
want_match: bool,
parse_key: bool,
) -> Vec<KhLine<'a>> {
let ws = |c: char| c == ' ' || c == '\t';
let mut out = Vec::new();
for (i, line) in text.lines().enumerate() {
let mut l = KhLine {
num: i + 1,
line,
status: KhStatus::Ok,
marker: "",
hosts: "",
rawkey: "",
key: None,
};
let mut cp = line.trim_start_matches(ws);
if cp.is_empty() || cp.starts_with('#') {
if !want_match {
l.status = KhStatus::Comment;
out.push(l);
}
continue;
}
let bad = |mut l: KhLine<'a>, out: &mut Vec<KhLine<'a>>| {
l.status = KhStatus::Invalid;
out.push(l);
};
if cp.starts_with('@') {
let (m, rest) = cp.split_at(cp.find(ws).unwrap_or(cp.len()));
match m {
"@cert-authority" if !rest.is_empty() => l.marker = "CA",
"@revoked" if !rest.is_empty() => l.marker = "REVOKED",
_ => {
if !want_match {
bad(l, &mut out);
}
continue;
}
}
cp = rest.trim_start_matches(ws);
}
let Some(end) = cp.find(ws) else {
if !want_match {
bad(l, &mut out);
}
continue;
};
l.hosts = &cp[..end];
if let Some(h) = host {
match kh_match(h, l.hosts) {
None => {
bad(l, &mut out);
continue;
}
Some(true) => l.status = KhStatus::Matched,
Some(false) => {}
}
if want_match && l.status != KhStatus::Matched {
continue;
}
}
let rest = cp[end..].trim_start_matches(ws);
if rest.is_empty() || rest.starts_with('#') {
bad(l, &mut out);
continue;
}
l.rawkey = rest;
if parse_key {
match PublicKey::parse_authorized_keys_line(rest) {
Ok(k) => l.key = Some(k),
Err(_) => {
bad(l, &mut out);
continue;
}
}
} else {
let tl = rest.find(ws).unwrap_or(rest.len());
let ktype = &rest[..tl];
let after = rest[tl..].trim_start_matches(ws);
if tl <= 1 || tl >= 128 || tl == rest.len() {
bad(l, &mut out);
continue;
}
if !known_key_type(ktype) || after.is_empty() || after.starts_with('#') {
bad(l, &mut out);
continue;
}
}
out.push(l);
}
out
}
fn kh_hash(host: &str) -> String {
let mut salt = [0u8; 20];
OsRng.fill_bytes(&mut salt);
puressh::known_hosts::hash::encode_hashed(&salt, host)
}
fn kh_hash_line(l: &KhLine<'_>, path: &str, find: bool) -> (Vec<String>, bool) {
let has_wild = l.hosts.contains(['*', '?', '!']);
let was_hashed = l.hosts.starts_with('|');
match l.status {
KhStatus::Ok | KhStatus::Matched => {
if was_hashed || has_wild || !l.marker.is_empty() {
if has_wild && !find {
let shown: String = l.hosts.chars().take(64).collect();
eprintln!(
"{path}:{}: ignoring host name with wildcard: {shown}",
l.num
);
}
return (vec![l.line.to_string()], false);
}
let lines: Vec<String> = l
.hosts
.split(',')
.take_while(|h| !h.is_empty())
.map(|h| format!("{} {}", kh_hash(&h.to_ascii_lowercase()), l.rawkey))
.collect();
let any = !lines.is_empty();
(lines, any)
}
_ => (vec![l.line.to_string()], false),
}
}
fn kh_replace(path: &str, contents: &str) -> Result<(), String> {
let old = format!("{path}.old");
let tmp = format!("{path}.{:010}", OsRng.next_u32());
std::fs::write(&tmp, contents).map_err(|e| format!("{tmp}: {}", io_reason(&e)))?;
if let Ok(meta) = std::fs::metadata(path) {
let _ = std::fs::set_permissions(&tmp, meta.permissions());
}
let _ = std::fs::remove_file(&old);
if let Err(e) = std::fs::hard_link(path, &old) {
let _ = std::fs::remove_file(&tmp);
return Err(format!("link {path} to {old}: {}", io_reason(&e)));
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(&old);
return Err(format!("rename\"{tmp}\" to \"{path}\": {}", io_reason(&e)));
}
println!("{path} updated.");
println!("Original contents retained as {old}");
Ok(())
}
fn kh_invalid(path: &str, inplace: bool) -> i32 {
eprintln!("{path} is not a valid known_hosts file.");
if inplace {
eprintln!("Not replacing existing known_hosts file because of errors");
}
1
}
fn run_known_hosts_remove(args: &Args, host: &str) -> Result<i32, String> {
let path = resolve_known_hosts_path(args)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {}", io_reason(&e)))?;
let mut kept = String::with_capacity(text.len());
let (mut found, mut invalid) = (false, false);
for l in kh_scan(&text, Some(host), false, false) {
if l.status == KhStatus::Matched && l.marker.is_empty() {
found = true;
if !args.quiet {
println!("# Host {host} found: line {}", l.num);
}
continue;
}
if l.status == KhStatus::Invalid {
invalid = true;
eprintln!("{path}:{}: invalid line", l.num);
}
kept.push_str(l.line);
kept.push('\n');
}
if invalid {
return Ok(kh_invalid(&path, true));
}
if !found {
eprintln!("Host {host} not found in {path}");
return Ok(0);
}
kh_replace(&path, &kept)?;
Ok(0)
}
fn run_known_hosts_find(args: &Args, host: &str) -> Result<i32, String> {
let path = resolve_known_hosts_path(args)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {}", io_reason(&e)))?;
let mut found = false;
for l in kh_scan(&text, Some(host), true, args.fingerprint) {
if l.status != KhStatus::Matched {
continue;
}
found = true;
if !args.quiet {
println!("# Host {host} found: line {} {}", l.num, l.marker);
}
if args.hash_all {
for line in kh_hash_line(&l, &path, true).0 {
println!("{line}");
}
} else if let Some(pk) = l.key.as_ref().filter(|_| args.fingerprint) {
let fp = fingerprint_text(pk, args)?;
let comment = pk.comment();
let sep = if comment.is_empty() { "" } else { " " };
println!("{host} {} {fp}{sep}{comment}", algorithm_label(pk));
} else {
println!("{}", l.line);
}
}
Ok(if found { 0 } else { 1 })
}
fn io_reason(e: &std::io::Error) -> String {
let s = e.to_string();
match s.find(" (os error") {
Some(i) => s[..i].to_string(),
None => s,
}
}
fn run_known_hosts_hash(args: &Args) -> Result<i32, String> {
let path = resolve_known_hosts_path(args)?;
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {}", io_reason(&e)))?;
let mut out = String::with_capacity(text.len() * 2);
let (mut invalid, mut unhashed) = (false, false);
for l in kh_scan(&text, None, false, false) {
if l.status == KhStatus::Invalid {
invalid = true;
eprintln!("{path}:{}: invalid line", l.num);
}
let (lines, any) = kh_hash_line(&l, &path, false);
unhashed |= any;
for line in lines {
out.push_str(&line);
out.push('\n');
}
}
if invalid {
return Ok(kh_invalid(&path, true));
}
kh_replace(&path, &out)?;
if unhashed {
eprintln!("WARNING: {path}.old contains unhashed entries");
eprintln!("Delete this file to ensure privacy of hostnames");
}
Ok(0)
}
fn run() -> Result<i32, i32> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--help") {
println!("{USAGE}");
println!("\nA pure-Rust ssh-keygen built on puressh {VERSION}.");
return Ok(0);
}
if args.iter().any(|a| a == "--version") {
println!("puressh ssh-keygen {VERSION}");
return Ok(0);
}
let usage = |msg: &str| {
if !msg.is_empty() {
eprintln!("{msg}");
}
eprintln!("{USAGE}");
Err(1)
};
let mut parsed = match parse_args(&args) {
Ok(p) => p,
Err(ArgError::Usage(msg)) => return usage(msg.as_deref().unwrap_or("")),
Err(ArgError::Fatal(msg)) => {
eprintln!("{msg}");
return Err(255);
}
};
if let Some(op) = &parsed.sig_op {
return match sig::run(op, &parsed) {
Ok(code) => Ok(code),
Err(msg) => {
eprintln!("{msg}");
Err(255)
}
};
}
if parsed.sig_file.is_some() {
if parsed.rest.is_empty() && !parsed.krl {
return usage("Too few arguments.");
}
} else if !parsed.rest.is_empty() && !parsed.krl && !parsed.krl_query && parsed.moduli.is_none()
{
return usage("Too many arguments.");
}
if parsed.change_pass && parsed.change_comment {
return usage("Can only have one of -p and -c.");
}
if parsed.fingerprint && (parsed.remove_host.is_some() || parsed.hash_all) {
return usage("Cannot use -l with -H or -R.");
}
if parsed.bubble {
parsed.fingerprint = true;
}
#[cfg(feature = "security-key")]
if let Some(p) = &parsed.sk_provider {
let _ = security_key::PROVIDER.set(p.clone());
}
let res = if parsed.krl {
krl::build(&parsed)
} else if parsed.krl_query {
krl::query(&parsed)
} else if parsed.sig_file.is_some() {
certs::sign(&parsed)
} else if parsed.list_cert {
certs::list(&parsed)
} else if let Some(host) = &parsed.remove_host {
run_known_hosts_remove(&parsed, host)
} else if let Some(host) = &parsed.find_host {
run_known_hosts_find(&parsed, host)
} else if parsed.hash_all {
run_known_hosts_hash(&parsed)
} else if let Some(module) = &parsed.pkcs11 {
#[cfg(feature = "pkcs11")]
return run_pkcs11_download(&parsed, module);
#[cfg(not(feature = "pkcs11"))]
{
let _ = module;
eprintln!("no pkcs11 support");
return Err(255);
}
} else if parsed.download_sk {
#[cfg(feature = "security-key")]
{
security_key::download(&parsed)
}
#[cfg(not(feature = "security-key"))]
Err("security key support not enabled".into())
} else if parsed.fingerprint {
run_fingerprint(&parsed)
} else if parsed.change_pass {
run_change_passphrase(&parsed)
} else if parsed.change_comment {
run_change_comment(&parsed)
} else if parsed.export {
run_export(&parsed)
} else if parsed.import {
run_import(&parsed)
} else if parsed.derive_pub {
run_extract_public(&parsed)
} else if let Some(host) = &parsed.sshfp {
run_sshfp(&parsed, host)
} else if let Some(mode) = &parsed.moduli {
let count = match parsed.rest.len() {
0 => Some("No output file specified"),
1 => None,
_ => Some("Too many output files specified"),
};
if let Some(m) = count {
eprintln!("{m}");
return Err(255);
}
return match moduli::run(mode, &parsed) {
Ok(code) => Ok(code),
Err(msg) => {
eprintln!("{msg}");
Err(255)
}
};
} else if parsed.host_keys {
run_host_keys(&parsed)
} else {
run_generate(&parsed)
};
match res {
Ok(code) => Ok(code),
Err(msg) => {
if let Some(fatal) = msg.strip_prefix(FATAL) {
eprintln!("{fatal}");
return Err(255);
}
match msg.strip_prefix(RAW) {
Some(raw) => eprintln!("{raw}"),
None => eprintln!("ssh-keygen: {msg}"),
}
let lower = msg.to_lowercase();
let crypto = lower.contains("passphrase")
|| lower.contains("signature")
|| lower.contains("crypto")
|| lower.contains("bcrypt")
|| lower.contains("decrypt")
|| lower.contains("encode");
Err(if crypto { 3 } else { 1 })
}
}
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(unix)]
fn local_offset(t: i64) -> i64 {
let tt = t as libc::time_t;
let mut tm: libc::tm = unsafe { core::mem::zeroed() };
if unsafe { libc::localtime_r(&tt, &mut tm) }.is_null() {
return 0;
}
tm.tm_gmtoff as i64
}
#[cfg(not(unix))]
fn local_offset(_t: i64) -> i64 {
0
}
fn format_local_time(t: u64) -> String {
let t = i64::try_from(t).unwrap_or(i64::MAX);
let local = t.saturating_add(local_offset(t));
let days = local.div_euclid(86_400);
let secs = local.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}",
secs / 3600,
secs / 60 % 60,
secs % 60
)
}
mod sig {
use super::{
Args, PrivateKey, PublicKey, algorithm_label, local_offset, now,
read_passphrase_interactive,
};
use puressh::sshsig::{AllowedSigner, HashAlg, Signature};
use std::io::{Read, Write};
pub(super) fn run(op: &str, args: &Args) -> Result<i32, String> {
let mut hash = HashAlg::Sha512;
let mut verify_time = None;
let mut print_pubkey = false;
for o in &args.sig_options {
let (name, value) = o.split_once('=').unwrap_or((o, ""));
match name.to_ascii_lowercase().as_str() {
"hashalg" => {
hash = HashAlg::from_name(value)
.ok_or_else(|| format!("Unsupported hash algorithm \"{value}\""))?
}
"verify-time" => {
verify_time = Some(
puressh::sshsig::parse_time(value, local_offset(now()))
.ok_or_else(|| format!("Invalid \"verify-time\" option: {value}"))?,
)
}
"print-pubkey" => print_pubkey = true,
_ => return Err(format!("Invalid option \"{o}\"")),
}
}
let now = verify_time.unwrap_or_else(now);
match op {
"sign" => sign(args, hash),
"verify" => verify(args, now, print_pubkey, true),
"check-novalidate" => verify(args, now, print_pubkey, false),
"find-principals" => find_principals(args, now),
"match-principals" => match_principals(args),
_ => Err(format!("Unsupported operation for -Y: \"{op}\"")),
}
}
fn require<'a>(v: &'a Option<String>, what: &str) -> Result<&'a str, String> {
v.as_deref().ok_or_else(|| format!("Missing {what}"))
}
fn read_signature(path: &str) -> Result<Signature, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("Couldn't read signature file: {e}"))?;
Signature::from_armored(&text).map_err(|e| format!("Couldn't parse signature: {e}"))
}
fn read_allowed_signers(path: &str, at: i64) -> Result<Vec<AllowedSigner>, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("open {path}: {e}"))?;
let (signers, bad) = AllowedSigner::parse_file(&text, local_offset(at));
for (line, e) in bad {
eprintln!("{path}:{line}: {e}");
}
Ok(signers)
}
pub(super) enum Signer {
Key(Box<dyn puressh::hostkey::HostKey + Send>),
#[cfg(unix)]
Agent(puressh::agent::Agent, u32, Option<Vec<u8>>),
}
impl Signer {
pub(super) fn sign(&mut self, pk: &PublicKey, data: &[u8]) -> puressh::Result<Vec<u8>> {
#[cfg(not(unix))]
let _ = pk;
match self {
Signer::Key(k) => k.sign(data),
#[cfg(unix)]
Signer::Agent(a, flags, ident) => match ident {
Some(blob) => a.sign(blob, data, *flags),
None => a.sign(&pk.wire_blob(), data, *flags),
},
}
}
}
pub(super) fn load_signer(path: &str, use_agent: bool) -> Result<(Signer, PublicKey), String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("Couldn't load {path}: {e}"))?;
let public = PublicKey::parse_authorized_keys_line(text.trim()).ok();
if use_agent || public.is_some() {
#[cfg(not(unix))]
return Err("signing with an agent is not supported on this platform".into());
#[cfg(unix)]
{
let pk = match public {
Some(pk) => pk,
None => super::load_public(&format!("{path}.pub"))
.or_else(|_| super::load_public(path))?,
};
let agent = puressh::agent::Agent::connect_env()
.ok()
.flatten()
.ok_or("Couldn't get agent socket")?;
let flags = if matches!(pk, PublicKey::Rsa { .. }) {
4
} else {
0
};
return Ok((Signer::Agent(agent, flags, None), pk));
}
}
load_private_signer(path, &text)
}
fn load_private_signer(path: &str, text: &str) -> Result<(Signer, PublicKey), String> {
let sk = match PrivateKey::parse_pem(text, None) {
Ok(k) => k,
Err(_) => {
let pp =
read_passphrase_interactive(&format!("Enter passphrase for \"{path}\": "))?;
PrivateKey::parse_pem(text, Some(pp.as_bytes()))
.map_err(|e| format!("Couldn't load key {path}: {e}"))?
}
};
let pk = sk.public_key();
#[cfg(feature = "security-key")]
if sk.sk_credential().is_some() {
return Ok((Signer::Key(super::security_key::signer(path, sk)?), pk));
}
let key = sk.into_host_key().map_err(|e| format!("{path}: {e}"))?;
Ok((Signer::Key(key), pk))
}
fn load_signing_public(path: &str) -> Result<(PublicKey, Vec<u8>), String> {
let parse = |text: &str| -> Option<(PublicKey, Vec<u8>)> {
let line = text.trim();
if let Ok(c) = puressh::cert::Certificate::parse_openssh_line(line) {
let pk = PublicKey::parse_wire_blob(c.embedded_pubkey_blob()).ok()?;
return Some((pk, c.raw));
}
let pk = PublicKey::parse_authorized_keys_line(line).ok()?;
let blob = pk.wire_blob();
Some((pk, blob))
};
let text = std::fs::read_to_string(path)
.map_err(|e| format!("Couldn't load public key {path}: {e}"))?;
if let Some(v) = parse(&text) {
return Ok(v);
}
if let Some(v) = std::fs::read_to_string(format!("{path}.pub"))
.ok()
.and_then(|t| parse(&t))
{
return Ok(v);
}
let pk = PrivateKey::public_key_from_pem(&text)
.map_err(|e| format!("Couldn't load public key {path}: {e}"))?;
let blob = pk.wire_blob();
Ok((pk, blob))
}
fn load_sign_key(
path: &str,
require_agent: bool,
) -> Result<(Signer, PublicKey, Vec<u8>), String> {
let (pk, ident) = load_signing_public(path)?;
#[cfg(unix)]
match puressh::agent::Agent::connect_env() {
Ok(Some(mut agent)) => {
let has = agent
.identities()
.is_ok_and(|ids| ids.iter().any(|i| i.key_blob() == ident.as_slice()));
if has {
let flags = if matches!(pk, PublicKey::Rsa { .. }) {
4
} else {
0
};
let id = (ident != pk.wire_blob()).then(|| ident.clone());
return Ok((Signer::Agent(agent, flags, id), pk, ident));
}
if require_agent {
return Err("Couldn't find key in agent".into());
}
}
_ if require_agent => return Err("Couldn't get agent socket".into()),
_ => {}
}
#[cfg(not(unix))]
if require_agent {
return Err("signing with an agent is not supported on this platform".into());
}
let mut privpath = path;
for suffix in ["-cert.pub", ".pub"] {
if let Some(p) = privpath.strip_suffix(suffix).filter(|p| !p.is_empty()) {
privpath = p;
}
}
let text = std::fs::read_to_string(privpath)
.map_err(|e| format!("Couldn't load key {privpath}: {e}"))?;
let (signer, sk_pk) = load_private_signer(privpath, &text)?;
if sk_pk.wire_blob() != pk.wire_blob() {
return Err(format!(
"Public key {path} doesn't match private {privpath}"
));
}
Ok((signer, pk, ident))
}
fn sign_digest(
signer: &mut Signer,
(pk, ident): (&PublicKey, &[u8]),
namespace: &str,
hash: HashAlg,
digest: &[u8],
) -> Result<String, String> {
let sig = Signature::create(ident.to_vec(), namespace, hash, digest, |data| {
signer.sign(pk, data)
})
.map_err(|e| format!("Signing failed: {e}"))?;
Ok(sig.to_armored())
}
fn sign(args: &Args, hash: HashAlg) -> Result<i32, String> {
let key_path = require(&args.file, "key file (-f)")?;
let namespace = require(&args.namespace, "namespace (-n)")?;
let (mut signer, pk, ident) = load_sign_key(key_path, args.use_agent)?;
let files: Vec<&str> = if args.rest.is_empty() {
vec!["-"]
} else {
args.rest.iter().map(String::as_str).collect()
};
for f in files {
if f == "-" {
if !args.quiet {
eprintln!("Signing data on standard input");
}
let digest = hash
.digest_reader(std::io::stdin().lock())
.map_err(|e| format!("read stdin: {e}"))?;
let armored = sign_digest(&mut signer, (&pk, &ident), namespace, hash, &digest)?;
std::io::stdout()
.write_all(armored.as_bytes())
.map_err(|e| format!("write: {e}"))?;
continue;
}
if !args.quiet {
eprintln!("Signing file {f}");
}
let file = std::fs::File::open(f).map_err(|e| format!("Cannot open {f}: {e}"))?;
let digest = hash
.digest_reader(std::io::BufReader::new(file))
.map_err(|e| format!("read {f}: {e}"))?;
let armored = sign_digest(&mut signer, (&pk, &ident), namespace, hash, &digest)?;
let out = format!("{f}.sig");
if std::path::Path::new(&out).exists() && !confirm_overwrite(&out) {
return Err(format!("{out} exists, not overwriting"));
}
std::fs::write(&out, armored).map_err(|e| format!("Cannot write {out}: {e}"))?;
if !args.quiet {
eprintln!("Write signature to {out}");
}
}
Ok(0)
}
fn confirm_overwrite(path: &str) -> bool {
println!("{path} already exists.");
print!("Overwrite (y/n)? ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let mut byte = [0u8; 1];
while std::io::stdin()
.read(&mut byte)
.map(|n| n == 1)
.unwrap_or(false)
&& byte[0] != b'\n'
{
line.push(byte[0] as char);
}
line.starts_with('y')
}
fn verify(args: &Args, now: i64, print_pubkey: bool, validate: bool) -> Result<i32, String> {
let namespace = require(&args.namespace, "namespace (-n)")?;
let sig = read_signature(require(&args.sig_file, "signature file (-s)")?)?;
let digest = sig
.hash
.digest_reader(std::io::stdin().lock())
.map_err(|e| format!("read stdin: {e}"))?;
let pk = match puressh::cert::Certificate::parse(&sig.public_key) {
Ok(c) => PublicKey::parse_wire_blob(c.embedded_pubkey_blob()),
Err(_) => PublicKey::parse_wire_blob(&sig.public_key),
};
let mut result = sig
.verify(namespace, &digest)
.map_err(|e| format!("Signature verification failed: {e}"));
if result.is_ok()
&& let Some(rev) = &args.sshfp
&& revoked(rev, &sig.public_key)?
{
result = Err("Signature key is revoked".into());
}
let mut principal = None;
if validate && result.is_ok() {
let allowed =
read_allowed_signers(require(&args.file, "allowed signers file (-f)")?, now)?;
let id = require(&args.identity, "signer identity (-I)")?;
let found = allowed
.iter()
.any(|s| s.accepts(&sig.public_key, id, now) && s.allows_namespace(namespace));
result = if found {
principal = Some(id);
Ok(())
} else {
Err(format!(
"Signature verification failed: no allowed signer \"{id}\" \
for this key and namespace \"{namespace}\""
))
};
}
match (result, pk) {
(Ok(()), Ok(pk)) => {
if !args.quiet {
let fp = pk.sha256_fingerprint();
let cert = if sig.is_certificate() { "-CERT" } else { "" };
let ty = format!("{}{cert}", algorithm_label(&pk));
match principal {
Some(p) => {
println!("Good \"{namespace}\" signature for {p} with {ty} key {fp}")
}
None => println!("Good \"{namespace}\" signature with {ty} key {fp}"),
}
}
if print_pubkey {
match puressh::cert::Certificate::parse(&sig.public_key) {
Ok(c) => println!("{}", c.to_openssh_line("")),
Err(_) => {
let line = pk.to_authorized_keys_line();
let bare: Vec<&str> = line.split_whitespace().take(2).collect();
println!("{}", bare.join(" "));
}
}
}
Ok(0)
}
(res, _) => {
if let Err(e) = res {
eprintln!("{e}");
}
if !args.quiet {
println!("Could not verify signature.");
}
Ok(255)
}
}
}
fn revoked(path: &str, key: &[u8]) -> Result<bool, String> {
let data = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let cert = puressh::cert::Certificate::parse(key).ok();
let plain = cert.as_ref().map_or(key, |c| c.embedded_pubkey_blob());
if let Ok(krl) = puressh::krl::Krl::parse(&data) {
return Ok(krl.is_revoked_key(plain)
|| cert.as_ref().is_some_and(|c| {
krl.is_revoked_cert(&c.signature_key_blob, c.serial, &c.key_id)
|| krl.is_revoked_key(&c.signature_key_blob)
}));
}
let text = String::from_utf8_lossy(&data);
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let listed = match puressh::cert::Certificate::parse_openssh_line(line) {
Ok(c) => c.embedded_pubkey_blob().to_vec(),
Err(_) => match PublicKey::parse_authorized_keys_line(line) {
Ok(pk) => pk.wire_blob(),
Err(_) => continue,
},
};
if listed == plain
|| cert
.as_ref()
.is_some_and(|c| listed == c.signature_key_blob)
{
return Ok(true);
}
}
Ok(false)
}
fn find_principals(args: &Args, now: i64) -> Result<i32, String> {
let sig = read_signature(require(&args.sig_file, "signature file (-s)")?)?;
let allowed = read_allowed_signers(require(&args.file, "allowed signers file (-f)")?, now)?;
let mut found = false;
for s in &allowed {
if s.is_key(&sig.public_key, now) {
for p in s.principals.split(',') {
println!("{p}");
}
found = true;
} else if let Some(ps) = s.certified_principals(&sig.public_key, now) {
println!("{}", ps.join(","));
found = true;
}
}
if found {
Ok(0)
} else {
Err("No principal matched.".into())
}
}
fn match_principals(args: &Args) -> Result<i32, String> {
let id = require(&args.identity, "signer identity (-I)")?;
let allowed =
read_allowed_signers(require(&args.file, "allowed signers file (-f)")?, now())?;
let mut found = false;
for s in allowed.iter().filter(|s| s.matches_principal(id)) {
println!("{}", s.principals);
found = true;
}
if found {
Ok(0)
} else {
Err("No principal matched.".into())
}
}
}
fn run_sshfp(args: &Args, host: &str) -> Result<i32, String> {
let mut only: Option<u8> = None;
for o in &args.sig_options {
match o.strip_prefix("hashalg=") {
Some(a) if a.eq_ignore_ascii_case("sha1") => only = Some(1),
Some(a) if a.eq_ignore_ascii_case("sha256") => only = Some(2),
Some(a) => return Err(format!("Unsupported hash algorithm \"{a}\"")),
None => return Err(format!("Invalid option \"{o}\"")),
}
}
let files: Vec<String> = match &args.file {
Some(f) => vec![f.clone()],
None => ["rsa", "ecdsa", "ed25519", "mldsa44_ed25519"]
.iter()
.map(|t| format!("/etc/ssh/ssh_host_{t}_key.pub"))
.filter(|p| Path::new(p).exists())
.collect(),
};
for f in files {
let pk = load_public(&f)?;
let Some(alg) = pk.sshfp_algorithm() else {
continue;
};
for (fptype, digest) in [(1u8, sha1(&pk.wire_blob())), (2, sha256(&pk.wire_blob()))] {
if only.is_some_and(|t| t != fptype) {
continue;
}
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
if args.sshfp_generic {
println!(
"{host} IN TYPE44 \\# {} {alg:02x} {fptype:02x} {hex}",
2 + digest.len()
);
} else {
println!("{host} IN SSHFP {alg} {fptype} {hex}");
}
}
}
Ok(0)
}
mod krl {
use super::{Args, PublicKey, load_public, read_to_string};
use puressh::cert::Certificate;
use puressh::krl::Krl;
fn decode_fp(v: &str) -> Option<(bool, Vec<u8>)> {
let (sha256, b64) = if let Some(b) = v.strip_prefix("SHA256:") {
(true, b)
} else {
(false, v.strip_prefix("SHA1:")?)
};
let mut padded = b64.to_string();
while padded.len() % 4 != 0 {
padded.push('=');
}
let raw = purecrypto::der::base64_decode(&padded).ok()?;
let want = if sha256 { 32 } else { 20 };
(raw.len() == want).then_some((sha256, raw))
}
fn revoke_line_key(krl: &mut Krl, line: &str) -> Result<(), String> {
if let Ok(cert) = Certificate::parse_openssh_line(line) {
if cert.serial != 0 || cert.key_id.is_empty() {
krl.revoke_cert_serials(&cert.signature_key_blob, cert.serial, cert.serial);
} else {
krl.revoke_cert_key_id(&cert.signature_key_blob, &cert.key_id);
}
return Ok(());
}
let pk = PublicKey::parse_authorized_keys_line(line).map_err(|e| e.to_string())?;
krl.revoke_key(&pk.wire_blob());
Ok(())
}
fn parse_serial(v: &str) -> Option<u64> {
match v.strip_prefix("0x") {
Some(h) => u64::from_str_radix(h, 16).ok(),
None => v.parse().ok(),
}
}
fn update_from(krl: &mut Krl, ca: Option<&[u8]>, path: &str) -> Result<(), String> {
let text = if path == "-" {
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)
.map_err(|e| format!("stdin: {e}"))?;
s
} else {
read_to_string(path)?
};
for (n, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let at = |e: String| format!("{path}:{}: {e}", n + 1);
let need_ca = || ca.ok_or_else(|| at("missing CA key (-s)".into()));
let (kw, val) = match line.split_once(':') {
Some((k, v)) if !k.contains(' ') => (k.to_ascii_lowercase(), v.trim()),
_ => (String::new(), line),
};
match kw.as_str() {
"serial" => {
let ca = need_ca()?;
let (lo, hi) = match val.split_once('-') {
Some((a, b)) => (parse_serial(a.trim()), parse_serial(b.trim())),
None => (parse_serial(val), parse_serial(val)),
};
let (Some(lo), Some(hi)) = (lo, hi) else {
return Err(at(format!("invalid serial \"{val}\"")));
};
if lo > hi {
return Err(at(format!("invalid serial range \"{val}\"")));
}
krl.revoke_cert_serials(ca, lo, hi);
}
"id" => krl.revoke_cert_key_id(need_ca()?, val),
"key" => revoke_line_key(krl, val).map_err(at)?,
"sha1" | "sha256" => {
let blob = PublicKey::parse_authorized_keys_line(val)
.map(|k| k.wire_blob())
.map_err(|e| at(e.to_string()))?;
if kw == "sha1" {
krl.revoke_sha1(super::sha1(&blob).try_into().expect("20 bytes"));
} else {
krl.revoke_sha256(super::sha256(&blob).try_into().expect("32 bytes"));
}
}
"hash" => match decode_fp(val) {
Some((true, raw)) => krl.revoke_sha256(raw.try_into().expect("32 bytes")),
Some((false, raw)) => krl.revoke_sha1(raw.try_into().expect("20 bytes")),
None => return Err(at(format!("invalid fingerprint \"{val}\""))),
},
_ => revoke_line_key(krl, line).map_err(at)?,
}
}
Ok(())
}
pub(super) fn build(args: &Args) -> Result<i32, String> {
let path = args.file.as_deref().ok_or("-f krl_file is required")?;
let mut krl = if args.krl_update {
let data = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
Krl::parse(&data).map_err(|e| format!("{path}: {e}"))?
} else {
Krl::new()
};
let ca = match &args.sig_file {
Some(f) if f.eq_ignore_ascii_case("none") => Some(Vec::new()),
Some(f) => Some(load_public(f)?.wire_blob()),
None => None,
};
if let Some(z) = &args.serial {
krl.set_version(
z.trim_start_matches('+')
.parse()
.map_err(|_| format!("Invalid KRL version \"{z}\""))?,
);
}
for f in &args.rest {
update_from(&mut krl, ca.as_deref(), f)?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
std::fs::write(path, krl.to_bytes(now)).map_err(|e| format!("{path}: {e}"))?;
Ok(0)
}
pub(super) fn query(args: &Args) -> Result<i32, String> {
let path = args.file.as_deref().ok_or("-f krl_file is required")?;
let data = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let krl = Krl::parse(&data).map_err(|e| format!("{path}: {e}"))?;
if args.fingerprint {
println!("# KRL version {}", krl.version());
if !krl.comment().is_empty() {
println!("# Comment: {}", krl.comment());
}
}
let mut revoked_any = false;
for f in &args.rest {
let text = read_to_string(f)?;
for line in text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let (revoked, comment) = if let Ok(cert) = Certificate::parse_openssh_line(line) {
let r =
krl.is_revoked_cert(&cert.signature_key_blob, cert.serial, &cert.key_id)
|| krl.is_revoked_key(cert.embedded_pubkey_blob())
|| krl.is_revoked_key(&cert.signature_key_blob);
(r, line.split_whitespace().nth(2).unwrap_or("").to_string())
} else {
let pk = PublicKey::parse_authorized_keys_line(line)
.map_err(|e| format!("{f}: {e}"))?;
(
krl.is_revoked_key(&pk.wire_blob()),
pk.comment().to_string(),
)
};
let label = if comment.is_empty() {
f.clone()
} else {
format!("{f} ({comment})")
};
println!("{label}: {}", if revoked { "REVOKED" } else { "ok" });
revoked_any |= revoked;
}
}
Ok(if revoked_any { 1 } else { 0 })
}
}
mod certs {
use super::{Args, PublicKey, algorithm_label, format_local_time, local_offset, now};
use purecrypto::rng::{OsRng, RngCore};
use puressh::cert::{
CertType, Certificate, CertificateBuilder, DEFAULT_USER_EXTENSIONS, encode_option_string,
};
fn parse_relative(s: &str, now: u64) -> Option<u64> {
let (neg, body) = match s.as_bytes().first()? {
b'+' => (false, &s[1..]),
b'-' => (true, &s[1..]),
_ => return None,
};
if body.is_empty() {
return None;
}
let mut total = 0u64;
let mut num = String::new();
for c in body.chars() {
if c.is_ascii_digit() {
num.push(c);
continue;
}
let n: u64 = num.parse().ok()?;
num.clear();
let unit = match c.to_ascii_lowercase() {
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86_400,
'w' => 604_800,
_ => return None,
};
total = total.checked_add(n.checked_mul(unit)?)?;
}
if !num.is_empty() {
total = total.checked_add(num.parse().ok()?)?;
}
if neg {
Some(now.saturating_sub(total))
} else {
now.checked_add(total)
}
}
fn parse_absolute(s: &str) -> Option<u64> {
if let Some(hex) = s.strip_prefix("0x") {
return u64::from_str_radix(hex, 16).ok();
}
let t = puressh::sshsig::parse_time(s, local_offset(now()))?;
u64::try_from(t).ok()
}
fn parse_validity(spec: &str, now: u64) -> Result<(u64, u64), String> {
let bad = || format!("Invalid certificate life specification {spec}");
let (from, to) = match spec.split_once(':') {
Some((f, t)) => {
let from = match f {
"always" => 0,
f if f.starts_with(['+', '-']) => parse_relative(f, now).ok_or_else(bad)?,
f => parse_absolute(f).ok_or_else(bad)?,
};
let to = match t {
"forever" => u64::MAX,
t if t.starts_with(['+', '-']) => parse_relative(t, now).ok_or_else(bad)?,
t => parse_absolute(t).ok_or_else(bad)?,
};
(from, to)
}
None if spec.starts_with('+') => (
(now / 60).saturating_sub(1) * 60,
parse_relative(spec, now).ok_or_else(bad)?,
),
None => return Err(bad()),
};
if from >= to {
return Err("Empty certificate validity interval".into());
}
Ok((from, to))
}
fn validity_text(from: u64, to: u64) -> String {
match (from, to) {
(0, u64::MAX) => "forever".into(),
(0, to) => format!("before {}", format_local_time(to)),
(from, u64::MAX) => format!("after {}", format_local_time(from)),
(from, to) => format!(
"from {} to {}",
format_local_time(from),
format_local_time(to)
),
}
}
fn set(list: &mut Vec<(String, Vec<u8>)>, name: &str, data: Vec<u8>) {
list.retain(|(n, _)| n != name);
list.push((name.to_string(), data));
}
fn apply_options(b: &mut CertificateBuilder, options: &[String]) -> Result<(), String> {
for o in options {
let lower = o.to_ascii_lowercase();
let standard = DEFAULT_USER_EXTENSIONS.iter().find_map(|e| {
let short = e["permit-".len()..].to_ascii_lowercase();
if lower == e.to_ascii_lowercase() {
Some((*e, true))
} else if lower == format!("no-{short}") {
Some((*e, false))
} else {
None
}
});
if lower == "clear" {
b.critical_options.clear();
b.extensions.clear();
} else if let Some((e, on)) = standard {
if on {
set(&mut b.extensions, e, Vec::new());
} else {
b.extensions.retain(|(n, _)| n != e);
}
} else if lower == "no-touch-required" {
set(&mut b.extensions, "no-touch-required", Vec::new());
} else if lower == "verify-required" {
set(&mut b.critical_options, "verify-required", Vec::new());
} else if let Some(v) = strip_ci(o, "force-command=") {
set(
&mut b.critical_options,
"force-command",
encode_option_string(v),
);
} else if let Some(v) = strip_ci(o, "source-address=") {
set(
&mut b.critical_options,
"source-address",
encode_option_string(v),
);
} else if let Some(v) = strip_ci(o, "critical:") {
let (name, data) = custom(v);
set(&mut b.critical_options, name, data);
} else if let Some(v) = strip_ci(o, "extension:") {
let (name, data) = custom(v);
set(&mut b.extensions, name, data);
} else {
return Err(format!("Unsupported certificate option \"{o}\""));
}
}
Ok(())
}
fn strip_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
s.get(..prefix.len())
.filter(|p| p.eq_ignore_ascii_case(prefix))
.map(|_| &s[prefix.len()..])
}
fn custom(v: &str) -> (&str, Vec<u8>) {
match v.split_once('=') {
Some((n, val)) => (n, encode_option_string(val)),
None => (v, Vec::new()),
}
}
fn cert_path(path: &str) -> String {
format!("{}-cert.pub", path.strip_suffix(".pub").unwrap_or(path))
}
pub(super) fn sign(args: &Args) -> Result<i32, String> {
let ca_path = args.sig_file.as_deref().unwrap_or_default();
let key_id = args
.identity
.as_deref()
.ok_or("Must specify key id (-I) when certifying")?;
if args.rest.is_empty() {
return Err("Too few arguments.".into());
}
let now = now() as u64;
let (valid_after, valid_before) = match &args.validity {
Some(spec) => parse_validity(spec, now)?,
None => (0, u64::MAX),
};
let (mut serial, auto_increment) = match args.serial.as_deref() {
None => (0, false),
Some(z) => {
let (digits, inc) = match z.strip_prefix('+') {
Some(d) => (d, true),
None => (z, false),
};
let n = digits
.parse::<u64>()
.map_err(|_| format!("Invalid serial number \"{z}\""))?;
(n, inc)
}
};
let principals: Vec<String> = args
.namespace
.as_deref()
.map(|n| n.split(',').map(str::to_string).collect())
.unwrap_or_default();
let (mut signer, ca_pk) = super::sig::load_signer(ca_path, args.use_agent)?;
let cert_type = if args.host_cert {
CertType::Host
} else {
CertType::User
};
for path in &args.rest {
let pk = super::load_public_any(path)?;
let mut nonce = vec![0u8; 32];
OsRng.fill_bytes(&mut nonce);
let mut b = CertificateBuilder::new(pk.wire_blob(), cert_type, nonce);
b.serial = serial;
b.key_id = key_id.to_string();
b.valid_principals = principals.clone();
b.valid_after = valid_after;
b.valid_before = valid_before;
if cert_type == CertType::User {
b.extensions = DEFAULT_USER_EXTENSIONS
.iter()
.map(|e| (e.to_string(), Vec::new()))
.collect();
}
apply_options(&mut b, &args.sig_options)?;
let cert = b
.sign(&ca_pk.wire_blob(), |data| signer.sign(&ca_pk, data))
.map_err(|e| format!("Couldn't certify key {path}: {e}"))?;
let out = cert_path(path);
std::fs::write(&out, cert.to_openssh_line(pk.comment()) + "\n")
.map_err(|e| format!("Could not write certified key to {out}: {e}"))?;
if !args.quiet {
if principals.is_empty() {
eprintln!("Warning: certificate will contain no principals (-n)");
}
let kind = if args.host_cert { "host" } else { "user" };
let who = if principals.is_empty() {
String::new()
} else {
format!("for {} ", principals.join(","))
};
eprintln!(
"Signed {kind} key {out}: id \"{key_id}\" serial {serial} {who}valid {}",
validity_text(valid_after, valid_before)
);
}
if auto_increment {
serial = serial.wrapping_add(1);
}
}
Ok(0)
}
fn describe(known_flags: &[&str], (name, data): &(String, Vec<u8>)) -> String {
if matches!(name.as_str(), "force-command" | "source-address")
&& let Some(v) = Certificate::option_string(data)
{
format!("{name} {v}")
} else if data.is_empty() && known_flags.contains(&name.as_str()) {
name.clone()
} else if data.is_empty() {
format!("{name} UNKNOWN FLAG OPTION")
} else {
let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
format!("{name} UNKNOWN OPTION: {hex} (len {})", data.len())
}
}
fn print_list(title: &str, items: Vec<String>) {
if items.is_empty() {
println!(" {title}: (none)");
} else {
println!(" {title}: ");
for i in items {
println!(" {i}");
}
}
}
pub(super) fn list(args: &Args) -> Result<i32, String> {
let path = args.file.as_deref().ok_or("-f is required")?;
let text = std::fs::read_to_string(path)
.map_err(|e| format!("{path}: {}", super::io_reason(&e)))?;
let cert = Certificate::parse_openssh_line(text.trim())
.map_err(|_| format!("{}{path}:1 is not a certificate", super::RAW))?;
let key = PublicKey::parse_wire_blob(cert.embedded_pubkey_blob())
.map_err(|e| format!("{path}: {e}"))?;
let ca = PublicKey::parse_wire_blob(&cert.signature_key_blob)
.map_err(|e| format!("{path}: {e}"))?;
let kind = match cert.cert_type {
CertType::User => "user",
CertType::Host => "host",
};
println!("{path}:");
println!(" Type: {} {kind} certificate", cert.key_type);
println!(
" Public key: {}-CERT {}",
algorithm_label(&key),
key.sha256_fingerprint()
);
println!(
" Signing CA: {} {} (using {})",
algorithm_label(&ca),
ca.sha256_fingerprint(),
cert.ca_algorithm().unwrap_or("unknown")
);
println!(" Key ID: \"{}\"", cert.key_id);
println!(" Serial: {}", cert.serial);
println!(
" Valid: {}",
validity_text(cert.valid_after, cert.valid_before)
);
print_list("Principals", cert.valid_principals.clone());
print_list(
"Critical Options",
cert.critical_options
.iter()
.map(|o| describe(&["verify-required"], o))
.collect(),
);
let mut known = DEFAULT_USER_EXTENSIONS.to_vec();
known.push("no-touch-required");
print_list(
"Extensions",
cert.extensions
.iter()
.map(|o| describe(&known, o))
.collect(),
);
Ok(0)
}
}
fn main() -> ExitCode {
match run() {
Ok(code) => {
let clamped = code.clamp(0, 255) as u8;
ExitCode::from(clamped)
}
Err(code) => {
let clamped = code.clamp(0, 255) as u8;
ExitCode::from(clamped)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rsa_1024_is_rejected_before_writing() {
let args = Args {
type_: Some("rsa".into()),
bits: Some(1024),
file: Some("/tmp/puressh-ssh-keygen-test-should-not-be-written".into()),
..Args::default()
};
let err = run_generate(&args).unwrap_err();
assert!(
err.contains("at least 2048 bits"),
"expected RSA floor error, got: {err}"
);
assert!(!std::path::Path::new(args.file.as_ref().unwrap()).exists());
}
#[test]
fn rsa_just_below_floor_is_rejected() {
let args = Args {
type_: Some("rsa".into()),
bits: Some(2047),
file: Some("/tmp/puressh-ssh-keygen-test-should-not-be-written".into()),
..Args::default()
};
let err = run_generate(&args).unwrap_err();
assert!(err.contains("at least 2048 bits"), "got: {err}");
}
fn parse(args: &[&str]) -> Result<Args, ArgError> {
let v: Vec<String> = args.iter().map(|s| s.to_string()).collect();
parse_args(&v)
}
#[test]
fn getopt_as_openssh() {
let a = parse(&["-lvvvf", "key", "-E", "SHA384", "rest", "-q"]).unwrap();
assert!(a.fingerprint && a.visual && !a.quiet);
assert_eq!(a.verbose, 3);
assert_eq!(a.file.as_deref(), Some("key"));
assert_eq!(a.fp_hash.as_deref(), Some("sha384"));
assert_eq!(a.rest, ["rest", "-q"]);
let a = parse(&["-X", "-o", "-c", "-Zchacha20-poly1305@openssh.com"]).unwrap();
assert!(a.import && a.change_comment);
assert_eq!(a.cipher.as_deref(), Some("chacha20-poly1305@openssh.com"));
assert_eq!(
parse(&["-m", "ssh2"]).unwrap().format.as_deref(),
Some("RFC4716")
);
assert_eq!(
parse(&["-m", "hexdump"]).unwrap().format.as_deref(),
Some("HEXDUMP")
);
}
#[test]
fn option_values_are_checked_as_openssh() {
let fatal = |args: &[&str]| match parse(args) {
Err(ArgError::Fatal(m)) => m,
_ => panic!("{args:?} accepted"),
};
assert_eq!(fatal(&["-Z", "rc4"]), "Invalid OpenSSH-format cipher 'rc4'");
assert_eq!(fatal(&["-b", "0"]), "Bits has bad value 0 (too small)");
assert_eq!(fatal(&["-a", "x"]), "Invalid number: x (invalid)");
assert_eq!(
fatal(&["-E", "sha224"]),
"Invalid hash algorithm \"sha224\""
);
assert_eq!(
fatal(&["-m", "der"]),
"Unsupported conversion format \"der\""
);
assert_eq!(fatal(&["-M", "x"]), "Unsupported moduli option x");
assert_eq!(fatal(&["-z", "+x"]), "Invalid serial number \"x\"");
assert!(parse(&["-z", "+5"]).is_ok());
assert_eq!(
parse(&["-%"]).err(),
Some(ArgError::Usage(Some("unknown option -- %".into())))
);
assert_eq!(
parse(&["-f"]).err(),
Some(ArgError::Usage(Some(
"option requires an argument -- f".into()
)))
);
}
#[test]
fn hexdump_as_openssh() {
let d: Vec<u8> = (0u8..18).chain(*b"ab").collect();
assert_eq!(
hexdump(&d),
"buffer len = 20\n\
0000: 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f ................\n\
0016: 10 11 61 62 ..ab\n"
);
}
#[test]
fn change_comment_keeps_passphrase_and_cipher() {
let dir = std::env::temp_dir().join(format!("puressh-keygen-c-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("k").to_string_lossy().into_owned();
let sk = PrivateKey::generate_ed25519(&mut OsRng, "old".into());
write_private(&file, &sk.to_openssh_pem(Some(b"pw")).unwrap()).unwrap();
let args = Args {
change_comment: true,
file: Some(file.clone()),
comment: Some("new".into()),
old_pass: Some(Zeroizing::new("pw".into())),
cipher: Some("aes256-gcm@openssh.com".into()),
rounds: Some(1),
..Args::default()
};
assert_eq!(run_change_comment(&args).unwrap(), 0);
let back = PrivateKey::parse_pem(&fs::read_to_string(&file).unwrap(), Some(b"pw")).unwrap();
assert_eq!(back.comment(), "new");
assert_eq!(back.public_key().wire_blob(), sk.public_key().wire_blob());
let pub_line = fs::read_to_string(format!("{file}.pub")).unwrap();
assert!(pub_line.trim_end().ends_with(" new"), "{pub_line}");
let bad = Args {
old_pass: Some(Zeroizing::new("nope".into())),
..args
};
assert!(run_change_comment(&bad).unwrap_err().starts_with(FATAL));
let _ = fs::remove_dir_all(&dir);
}
}
mod moduli {
use super::{Args, local_offset, now};
use purecrypto::bignum::{BoxedMontModulus, BoxedUint};
use purecrypto::rng::{OsRng, RngCore};
use std::io::{BufRead, Write};
const MODULI_TYPE_UNKNOWN: u32 = 0;
const MODULI_TYPE_UNSTRUCTURED: u32 = 1;
const MODULI_TYPE_SAFE: u32 = 2;
const MODULI_TYPE_SCHNORR: u32 = 3;
const MODULI_TYPE_SOPHIE_GERMAIN: u32 = 4;
const MODULI_TYPE_STRONG: u32 = 5;
const MODULI_TESTS_COMPOSITE: u32 = 0x01;
const MODULI_TESTS_SIEVE: u32 = 0x02;
const MODULI_TESTS_MILLER_RABIN: u32 = 0x04;
const QSIZE_MINIMUM: u32 = 511;
const TEST_MAXIMUM: u32 = 1 << 16;
const TEST_MINIMUM: u32 = QSIZE_MINIMUM + 1;
const TRIAL_MINIMUM: u32 = 4;
const TINY_NUMBER: u32 = 1 << 16;
const SMALL_MAXIMUM: u64 = 0xffff_ffff;
const LARGE_MAXIMUM: u32 = 127;
const DEFAULT_BITS: u32 = 3072;
type Num = Vec<u8>;
fn trim(mut n: Num) -> Num {
let z = n.iter().take_while(|b| **b == 0).count();
n.drain(..z.min(n.len().saturating_sub(1)));
n
}
fn from_hex(s: &str) -> Option<Num> {
let s = s.trim();
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let padded = if s.len() % 2 == 1 {
format!("0{s}")
} else {
s.to_string()
};
let bytes = (0..padded.len())
.step_by(2)
.map(|i| u8::from_str_radix(&padded[i..i + 2], 16).ok())
.collect::<Option<Vec<u8>>>()?;
Some(trim(bytes))
}
fn to_hex(n: &[u8]) -> String {
let s: String = n.iter().map(|b| format!("{b:02X}")).collect();
let t = s.trim_start_matches('0');
if t.is_empty() { "0".into() } else { t.into() }
}
fn bits(n: &[u8]) -> u32 {
match n.iter().position(|b| *b != 0) {
Some(i) => (n.len() - i) as u32 * 8 - n[i].leading_zeros(),
None => 0,
}
}
fn mod_word(n: &[u8], w: u64) -> u64 {
n.iter().fold(0u64, |r, b| (r * 256 + u64::from(*b)) % w)
}
fn limbs(n: &[u8]) -> Vec<u64> {
let pad = (8 - n.len() % 8) % 8;
let mut padded = vec![0u8; pad];
padded.extend_from_slice(n);
padded
.as_chunks::<8>()
.0
.iter()
.map(|c| u64::from_be_bytes(*c))
.collect()
}
fn mod_limbs(n: &[u64], w: u64) -> u64 {
let w = u128::from(w);
n.iter()
.fold(0u128, |r, l| ((r << 64) | u128::from(*l)) % w) as u64
}
fn add_small(n: &[u8], k: u64) -> Num {
let mut out = n.to_vec();
let mut carry = k;
for b in out.iter_mut().rev() {
if carry == 0 {
break;
}
let v = u64::from(*b) + (carry & 0xff);
*b = v as u8;
carry = (carry >> 8) + (v >> 8);
}
while carry > 0 {
out.insert(0, carry as u8);
carry >>= 8;
}
trim(out)
}
fn shl1(n: &[u8]) -> Num {
let mut out = vec![0u8; n.len() + 1];
for (i, b) in n.iter().enumerate() {
out[i] |= b >> 7;
out[i + 1] = b << 1;
}
trim(out)
}
fn shr1(n: &[u8]) -> Num {
let mut out = vec![0u8; n.len()];
let mut carry = 0u8;
for (i, b) in n.iter().enumerate() {
out[i] = (b >> 1) | carry;
carry = b << 7;
}
trim(out)
}
fn small_primes() -> Vec<u64> {
(3u64..2000)
.step_by(2)
.filter(|n| {
(3..)
.step_by(2)
.take_while(|d| d * d <= *n)
.all(|d| n % d != 0)
})
.collect()
}
fn is_probable_prime(n: &[u8], rounds: u32, primes: &[u64]) -> bool {
if bits(n) < 3 || n[n.len() - 1] & 1 == 0 {
return false;
}
for &p in primes {
if mod_word(n, p) == 0 {
return bits(n) <= 11 && n.iter().fold(0u64, |a, b| a * 256 + u64::from(*b)) == p;
}
}
let len = n.len();
let nn = BoxedUint::from_be_bytes(n);
let mont = BoxedMontModulus::new(&nn);
let mut n1 = n.to_vec();
n1[len - 1] &= 0xfe; let n_minus_1 = BoxedUint::from_be_bytes(&n1);
let s = n1
.iter()
.rev()
.position(|b| *b != 0)
.map_or(0, |i| i * 8 + n1[len - 1 - i].trailing_zeros() as usize);
let d = n_minus_1.shr_bits(s);
let n_minus_3 = BoxedUint::from_be_bytes(&{
let mut v = n1.clone();
let mut borrow = 2u16;
for b in v.iter_mut().rev() {
let cur = u16::from(*b);
if cur >= borrow {
*b = (cur - borrow) as u8;
break;
}
*b = (cur + 256 - borrow) as u8;
borrow = 1;
}
v
});
let eq = |a: &BoxedUint, b: &BoxedUint| a.to_be_bytes(len) == b.to_be_bytes(len);
let one = BoxedUint::from_u64(1);
let mut rng = OsRng;
'rounds: for _ in 0..rounds {
let mut r = vec![0u8; len + 8];
rng.fill_bytes(&mut r);
let a = BoxedUint::from_be_bytes(&r)
.reduce(&n_minus_3)
.add(&BoxedUint::from_u64(2));
let mut x = mont.pow_public(&a, &d);
if eq(&x, &one) || eq(&x, &n_minus_1) {
continue;
}
for _ in 1..s {
x = mont.mul_mod(&x, &x);
if eq(&x, &n_minus_1) {
continue 'rounds;
}
if eq(&x, &one) {
return false;
}
}
return false;
}
true
}
fn ctime(t: i64) -> String {
let local = t + local_offset(t);
let days = local.div_euclid(86_400);
let secs = local.rem_euclid(86_400);
let (y, m, d) = civil(days);
const WD: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
const MON: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
format!(
"{} {} {d:2} {:02}:{:02}:{:02} {y}",
WD[days.rem_euclid(7) as usize],
MON[(m - 1) as usize],
secs / 3600,
secs / 60 % 60,
secs % 60
)
}
fn civil(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
fn line(
otype: u32,
tests: u32,
tries: u32,
size: u32,
generator: u32,
modulus: &[u8],
) -> String {
let t = now();
let (y, m, d) = civil(t.div_euclid(86_400));
let s = t.rem_euclid(86_400);
format!(
"{y:04}{m:02}{d:02}{:02}{:02}{:02} {otype} {tests} {tries} {size} {generator:x} {}\n",
s / 3600,
s / 60 % 60,
s % 60,
to_hex(modulus)
)
}
fn open_out(path: &str, append: bool) -> Result<Box<dyn Write>, String> {
if path == "-" {
return Ok(Box::new(std::io::stdout()));
}
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.append(append)
.truncate(!append)
.open(path)
.map_err(|e| {
if append {
format!("Couldn't open moduli file \"{path}\": {e}")
} else {
format!("Couldn't open modulus candidate file \"{path}\": {e}")
}
})?;
Ok(Box::new(f))
}
pub(super) fn run(mode: &str, args: &Args) -> Result<i32, String> {
let out = args.rest.first().ok_or("Moduli output file is required")?;
match mode {
"generate" => generate(args, out),
"screen" => screen(args, out),
other => Err(format!("Unsupported moduli option {other}")),
}
}
fn generate(args: &Args, out_path: &str) -> Result<i32, String> {
let mut start = None;
let mut power = 0u32;
for o in &args.sig_options {
if let Some(v) = o.strip_prefix("start=") {
start = Some(from_hex(v).ok_or("Invalid start point.")?);
} else if let Some(v) = o.strip_prefix("bits=") {
power = v
.parse()
.ok()
.filter(|b| *b >= 1)
.ok_or_else(|| format!("Invalid number: {v}"))?;
} else {
return Err(format!(
"Option \"{o}\" is unsupported for moduli generation"
));
}
}
if power == 0 {
power = DEFAULT_BITS;
}
if power > TEST_MAXIMUM {
return Err(format!("Too many bits: {power} > {TEST_MAXIMUM}"));
}
if power < TEST_MINIMUM {
return Err(format!("Too few bits: {power} < {TEST_MINIMUM}"));
}
let mut out = open_out(out_path, false)?;
let power = power - 1;
let largewords = (LARGE_MAXIMUM as usize) << 18;
let largebits = (largewords * 32) as u64;
let mut large = vec![0u32; largewords];
let mut largetries = 0u32;
let set = |sieve: &mut [u32], n: u64| sieve[(n >> 5) as usize] |= 1 << (n & 31);
let test = |sieve: &[u32], n: u64| sieve[(n >> 5) as usize] & (1 << (n & 31)) != 0;
let mut base = match start {
Some(s) => s,
None => {
let nbytes = power.div_ceil(8) as usize;
let mut b = vec![0u8; nbytes];
OsRng.fill_bytes(&mut b);
let extra = nbytes as u32 * 8 - power;
b[0] &= 0xff >> extra;
let top = power - 1 - (nbytes as u32 - 1) * 8;
b[0] |= 1 << top;
if top > 0 {
b[0] |= 1 << (top - 1);
} else if nbytes > 1 {
b[1] |= 0x80;
}
b
}
};
let last = base.len() - 1;
base[last] |= 1;
let t0 = now();
eprintln!(
"{} Sieve next {} plus {power}-bit",
ctime(t0),
(largebits * 2) as u32
);
let base_limbs = limbs(&base);
let mut sieve_large = |s: u64, large: &mut [u32]| {
largetries += 1;
let r = mod_limbs(&base_limbs, s);
let mut u = if r == 0 { 0 } else { s - r };
if u < largebits * 2 {
if u & 1 == 1 {
u += s;
}
u /= 2;
while u < largebits {
set(large, u);
u += s;
}
}
let r = (2 * r + 1) % s;
let mut u = if r == 0 { 0 } else { s - r };
if u < largebits * 4 {
while u & 3 != 0 {
if SMALL_MAXIMUM - u < s {
return;
}
u += s;
}
u /= 4;
while u < largebits {
set(large, u);
u += s;
}
}
};
let tinywords = (TINY_NUMBER >> 6) as usize;
let tinybits = (tinywords * 32) as u64;
let mut tiny = vec![0u32; tinywords];
let mut small = vec![0u32; tinywords];
let smallbits = tinybits;
for i in 0..tinybits {
if test(&tiny, i) {
continue;
}
let t = 2 * i + 3;
let mut j = i + t;
while j < tinybits {
set(&mut tiny, j);
j += t;
}
sieve_large(t, &mut large);
}
let mut smallbase = u64::from(TINY_NUMBER) + 3;
while smallbase < SMALL_MAXIMUM - u64::from(TINY_NUMBER) {
for i in 0..tinybits {
if test(&tiny, i) {
continue;
}
let t = 2 * i + 3;
let r = smallbase % t;
let mut s = if r == 0 { 0 } else { t - r };
if s & 1 == 1 {
s += t;
}
s /= 2;
while s < smallbits {
set(&mut small, s);
s += t;
}
}
for i in 0..smallbits {
if !test(&small, i) {
sieve_large(2 * i + smallbase, &mut large);
}
}
small.iter_mut().for_each(|w| *w = 0);
smallbase += u64::from(TINY_NUMBER);
}
let t1 = now();
eprintln!(
"{} Sieved with {largetries} small primes in {} seconds",
ctime(t1),
t1 - t0
);
let mut found = 0u32;
for j in 0..largebits {
if test(&large, j) {
continue;
}
let q = add_small(&base, 2 * j);
out.write_all(
line(
MODULI_TYPE_SOPHIE_GERMAIN,
MODULI_TESTS_SIEVE,
largetries,
power - 1,
0,
&q,
)
.as_bytes(),
)
.map_err(|e| format!("write: {e}"))?;
found += 1;
}
let _ = out.flush();
eprintln!("{} Found {found} candidates", ctime(now()));
Ok(0)
}
fn screen(args: &Args, out_path: &str) -> Result<i32, String> {
let mut checkpoint: Option<String> = None;
let mut generator_wanted = 0u32;
let mut start_lineno = 0u64;
let mut lines = 0u64;
let mut trials = 0u32;
for o in &args.sig_options {
if let Some(v) = o.strip_prefix("lines=") {
lines = v.parse().unwrap_or(0);
} else if let Some(v) = o.strip_prefix("start-line=") {
start_lineno = v.parse().unwrap_or(0);
} else if let Some(v) = o.strip_prefix("checkpoint=") {
checkpoint = Some(v.to_string());
} else if let Some(v) = o.strip_prefix("generator=") {
generator_wanted = v
.parse()
.ok()
.filter(|g| *g >= 1)
.ok_or_else(|| format!("Generator invalid: {v}"))?;
} else if let Some(v) = o.strip_prefix("prime-tests=") {
trials = v
.parse()
.ok()
.filter(|t| *t >= 1)
.ok_or_else(|| format!("Invalid number: {v}"))?;
} else {
return Err(format!(
"Option \"{o}\" is unsupported for moduli screening"
));
}
}
let trials = if trials == 0 { 100 } else { trials };
if trials < TRIAL_MINIMUM {
return Err(format!("Minimum primality trials is {TRIAL_MINIMUM}"));
}
let input: Box<dyn BufRead> = match args.file.as_deref() {
Some(f) if f != "-" => Box::new(std::io::BufReader::new(
std::fs::File::open(f)
.map_err(|e| format!("Couldn't open modulus candidate file \"{f}\": {e}"))?,
)),
_ => Box::new(std::io::BufReader::new(std::io::stdin())),
};
let mut out = open_out(out_path, true)?;
let end_lineno = if lines == 0 {
u64::MAX
} else {
start_lineno + lines
};
let mut last_processed = 0u64;
if let Some(cp) = &checkpoint
&& let Ok(text) = std::fs::read_to_string(cp)
{
match text.trim().parse::<u64>() {
Ok(n) => {
eprintln!("Loaded checkpoint from '{cp}' line {n}");
last_processed = n;
}
Err(_) => eprintln!("Failed to load checkpoint from '{cp}'"),
}
}
let last_processed = last_processed.max(start_lineno);
let primes = small_primes();
let t0 = now();
let (mut count_in, mut count_out, mut count_possible) = (0u64, 0u32, 0u32);
for l in input.lines() {
let Ok(lp) = l else { break };
if count_in >= end_lineno {
break;
}
count_in += 1;
if count_in <= last_processed {
continue;
}
if let Some(cp) = &checkpoint {
let tmp = format!("{cp}.{:010}", OsRng.next_u32());
if std::fs::write(&tmp, format!("{count_in}\n")).is_err()
|| std::fs::rename(&tmp, cp).is_err()
{
let _ = std::fs::remove_file(&tmp);
eprintln!("failed to write to checkpoint file '{cp}'");
}
}
if lp.len() < 14 || lp.starts_with('!') || lp.starts_with('#') {
continue;
}
let mut f = lp[14..].split_whitespace();
let mut num = |radix: u32| {
f.next()
.and_then(|v| u32::from_str_radix(v, radix).ok())
.unwrap_or(0)
};
let in_type = num(10);
let in_tests = num(10);
let mut in_tries = num(10);
let mut in_size = num(10);
let mut generator = num(16);
let Some(modulus) = f.next().and_then(from_hex) else {
continue;
};
if in_tests & MODULI_TESTS_COMPOSITE != 0 {
continue;
}
let (p, q) = match in_type {
MODULI_TYPE_SOPHIE_GERMAIN => {
in_size += 1;
generator = 0;
(add_small(&shl1(&modulus), 1), modulus)
}
MODULI_TYPE_UNSTRUCTURED
| MODULI_TYPE_SAFE
| MODULI_TYPE_SCHNORR
| MODULI_TYPE_STRONG
| MODULI_TYPE_UNKNOWN => {
let q = shr1(&modulus);
(modulus, q)
}
_ => continue,
};
if bits(&p) != in_size + 1 || in_size < QSIZE_MINIMUM {
continue;
}
in_tries = if in_tests & MODULI_TESTS_MILLER_RABIN != 0 {
in_tries + trials
} else {
trials
};
if generator == 0 {
if mod_word(&p, 24) == 11 {
generator = 2;
} else if matches!(mod_word(&p, 10), 3 | 7) {
generator = 5;
}
}
if generator_wanted > 0 && generator_wanted != generator {
continue;
}
if generator == 0 {
continue;
}
count_possible += 1;
if !is_probable_prime(&q, 1, &primes)
|| !is_probable_prime(&p, trials, &primes)
|| !is_probable_prime(&q, trials - 1, &primes)
{
continue;
}
out.write_all(
line(
MODULI_TYPE_SAFE,
in_tests | MODULI_TESTS_MILLER_RABIN,
in_tries,
in_size,
generator,
&p,
)
.as_bytes(),
)
.map_err(|e| format!("write: {e}"))?;
count_out += 1;
}
let _ = out.flush();
if let Some(cp) = &checkpoint {
let _ = std::fs::remove_file(cp);
}
eprintln!(
"{} Found {count_out} safe primes of {count_possible} candidates in {} seconds",
ctime(now()),
now() - t0
);
Ok(0)
}
}
#[cfg(feature = "security-key")]
mod security_key {
use super::{
Args, RAW, ask, encode_private, read_new_passphrase_confirmed, read_passphrase_interactive,
write_private, write_public,
};
use purecrypto::zeroize::Zeroizing;
use puressh::Error;
use puressh::key::PrivateKey;
use puressh::sk::{self, Algorithm, SkError};
use std::io::Write;
pub(super) static PROVIDER: std::sync::OnceLock<String> = std::sync::OnceLock::new();
fn provider(args: &Args) -> Result<std::sync::Arc<dyn sk::Provider>, String> {
let path = sk::provider_path(args.sk_provider.as_deref());
sk::open_provider(&path).map_err(|e| format!("{RAW}{e}"))
}
struct TtyPrompt(String);
impl sk::Prompt for TtyPrompt {
fn touch(&self, key: &PrivateKey) {
let pk = key.public_key();
eprintln!(
"Confirm user presence for key {} {}",
pk.type_label(),
pk.sha256_fingerprint()
);
}
fn pin(&self, key: &PrivateKey) -> Option<Zeroizing<String>> {
let prompt = format!(
"Enter PIN for {} key {}: ",
key.public_key().type_label(),
self.0
);
read_passphrase_interactive(&prompt)
.ok()
.filter(|p| !p.is_empty())
}
}
pub(super) fn signer(
path: &str,
key: PrivateKey,
) -> Result<Box<dyn puressh::hostkey::HostKey + Send>, String> {
let provider = sk::lazy_provider(&sk::provider_path(PROVIDER.get().map(String::as_str)));
let prompt = std::sync::Arc::new(TtyPrompt(path.to_string()));
sk::SkSigner::new(key, provider, Some(prompt))
.map(|s| Box::new(s) as Box<dyn puressh::hostkey::HostKey + Send>)
.map_err(|e| format!("{path}: {e}"))
}
fn read_pin() -> Result<Option<Zeroizing<String>>, String> {
let pin = read_passphrase_interactive("Enter PIN for authenticator: ")?;
Ok((!pin.is_empty()).then_some(pin))
}
pub(super) fn enroll(
args: &Args,
ty: &str,
) -> Result<(PrivateKey, Option<Attestation>), String> {
let alg = Algorithm::from_name(ty).ok_or("unsupported key type")?;
let mut flags = sk::USER_PRESENCE_REQD;
let (mut device, mut user, mut application) = (None, None, None);
let mut challenge = None;
let mut attestation_path = None;
for o in &args.sig_options {
let lower = o.to_ascii_lowercase();
let value = |prefix: &str| {
lower
.starts_with(prefix)
.then(|| o[prefix.len()..].to_string())
};
if lower == "no-touch-required" {
flags &= !sk::USER_PRESENCE_REQD;
} else if lower == "verify-required" {
flags |= sk::USER_VERIFICATION_REQD;
} else if lower == "resident" {
flags |= sk::RESIDENT_KEY;
} else if let Some(v) = value("device=") {
device = Some(v);
} else if let Some(v) = value("user=") {
user = Some(v);
} else if let Some(v) = value("challenge=") {
challenge = Some(std::fs::read(&v).map_err(|e| {
format!("{RAW}Unable to load FIDO enrollment challenge \"{v}\": {e}")
})?);
} else if let Some(v) = value("write-attestation=") {
attestation_path = Some(v);
} else if let Some(v) = value("application=") {
if !v.starts_with("ssh:") {
return Err(format!(
"{RAW}FIDO application string must begin with \"ssh:\""
));
}
application = Some(v);
} else {
return Err(format!(
"{RAW}Option \"{o}\" is unsupported for FIDO authenticator enrollment"
));
}
}
let provider = provider(args)?;
let options = sk::options(device.as_deref(), user.as_deref());
let app = application.clone().unwrap_or_else(|| "ssh:".into());
let mut pin: Option<Zeroizing<String>> = None;
let mut again = false;
let mut tries = 0;
let enrolled = loop {
if !args.quiet {
println!(
"You may need to touch your authenticator{} to authorize key generation.",
if again { " again" } else { "" }
);
}
let _ = std::io::stdout().flush();
match sk::enroll(
&*provider,
alg,
&app,
flags,
pin.as_deref().map(|p| p.as_str()),
challenge.as_deref(),
&options,
) {
Ok(e) => break e,
Err(Error::SecurityKey(SkError::CredentialExists))
if flags & sk::RESIDENT_KEY != 0 && flags & sk::FORCE_OPERATION == 0 =>
{
println!(
"A resident key scoped to '{}' with user id '{}' already exists.",
application.as_deref().unwrap_or("ssh:"),
user.as_deref().unwrap_or("null")
);
if !ask("Overwrite key in token (y/n)? ")
.is_some_and(|a| a.starts_with(['y', 'Y']))
{
return Err(format!(
"{RAW}Key enrollment failed: {}",
SkError::CredentialExists
));
}
flags |= sk::FORCE_OPERATION;
}
Err(Error::SecurityKey(SkError::PinRequired)) => {
if pin.take().is_some() {
eprintln!("PIN incorrect");
}
tries += 1;
if tries >= 3 {
return Err(format!("{RAW}Too many incorrect PINs"));
}
pin = read_pin()?;
}
Err(e) => return Err(format!("{RAW}Key enrollment failed: {e}")),
}
again = true;
};
let attestation = attestation_path.map(|p| (p, enrolled.attestation));
Ok((enrolled.key, attestation))
}
pub(super) type Attestation = (String, Vec<u8>);
fn suffix(application: &str, user_id: &[u8]) -> String {
let mut out = application
.strip_prefix("ssh://")
.or_else(|| application.strip_prefix("ssh:"))
.unwrap_or(application)
.to_string();
let len = user_id.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
if len > 0 {
let mut user = String::from_utf8_lossy(&user_id[..len]).into_owned();
if user.contains('/') || user.contains("..") || user.contains('\\') {
user = user_id[..len].iter().map(|b| format!("{b:02x}")).collect();
}
if !out.is_empty() {
out.push('_');
}
out.push_str(&user);
}
out
}
pub(super) fn download(args: &Args) -> Result<i32, String> {
let provider = provider(args)?;
let pin = read_pin()?;
if !args.quiet {
println!("You may need to touch your authenticator to authorize key download.");
}
let device = args
.sig_options
.iter()
.find_map(|o| o.strip_prefix("device="));
let keys = match sk::load_resident_keys(
&*provider,
pin.as_deref().map(|p| p.as_str()),
&sk::options(device, None),
) {
Ok(k) => k,
Err(e) => {
eprintln!("Unable to load resident keys: {e}");
return Ok(255);
}
};
drop(pin);
if keys.is_empty() {
eprintln!("No keys to download");
}
let mut pass: Option<Zeroizing<String>> = None;
for rk in keys {
let mut key = rk.key;
let sk = key.sk_credential().ok_or("not a security key")?;
let application = sk.application.clone();
let ext = suffix(&application, &rk.user_id);
let kind = if matches!(key, PrivateKey::SkEcdsaP256 { .. }) {
"ecdsa_sk"
} else {
"ed25519_sk"
};
let path = format!("id_{kind}_rk{}{ext}", if ext.is_empty() { "" } else { "_" });
if std::path::Path::new(&path).exists() {
println!("{path} already exists.");
if !ask("Overwrite (y/n)? ").is_some_and(|a| a.starts_with(['y', 'Y'])) {
return Ok(255);
}
}
key.set_comment(application.clone());
if pass.is_none() {
pass = Some(match (&args.old_pass, &args.new_pass) {
(Some(p), _) | (None, Some(p)) => p.clone(),
(None, None) => read_new_passphrase_confirmed(&format!(
"Enter passphrase for \"{path}\" (empty for no passphrase): "
))?,
});
}
let pem = encode_private(&key, args, pass.as_deref().map_or("", |p| p.as_str()))
.map_err(|e| format!("{RAW}Saving key \"{path}\" failed: {e}"))?;
write_private(&path, &pem)?;
if !args.quiet {
println!(
"Saved {} key{}{} to {path}",
key.public_key().type_label(),
if ext.is_empty() { "" } else { " " },
if ext.is_empty() {
""
} else {
application.as_str()
},
);
}
let mut line = key.public_key().to_authorized_keys_line();
line.push('\n');
write_public(&format!("{path}.pub"), &line)?;
}
Ok(0)
}
#[cfg(test)]
mod tests {
#[test]
fn suffix_matches_openssh() {
assert_eq!(super::suffix("ssh:", &[0; 32]), "");
assert_eq!(super::suffix("ssh:work", &[0; 32]), "work");
assert_eq!(super::suffix("ssh://srv", b"alice\0\0"), "srv_alice");
assert_eq!(super::suffix("ssh:", b"bob"), "bob");
assert_eq!(super::suffix("ssh:", b"../x"), "2e2e2f78");
}
}
}