//! Local SSH key and password generation (port of `keys/keyGeneratorService.js`). //! //! Ed25519, RSA 2048/3072/4096, ECDSA P-256/384/521 and explicitly confirmed legacy DSA-1024 //! and SSH1 RSA. Exports: PEM PKCS#8 (passphrase → PBES2/PBKDF2-SHA256/AES-256-CBC like the //! Node runtime of 0.1.0), unencrypted OpenSSH and PuTTY PPK v2. Generated material is kept in //! memory only (max 20, 10 minutes) so it can be saved through a Core dialog. use crate::core::error::{CoreError, CoreResult}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use hmac::{Hmac, Mac}; use parking_lot::Mutex; use pkcs8::EncodePrivateKey; use rand::rngs::OsRng; use rand::Rng; use serde_json::{json, Value}; use sha1::{Digest as _, Sha1}; use std::collections::VecDeque; use std::time::{Duration, Instant}; use zeroize::Zeroizing; const TTL: Duration = Duration::from_secs(10 * 60); pub const PASSWORD_SETS: [(&str, &str); 4] = [("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"), ("lower", "abcdefghijklmnopqrstuvwxyz"), ("digits", "123456789"), ("special", "-@)\u{a8}\u{a7}'=+")]; pub const PASSWORD_MIN: usize = 4; pub const PASSWORD_MAX: usize = 70; const PASSWORD_GROUP: usize = 5; #[derive(Debug, Clone)] pub struct Input { pub kind: String, pub bits: u32, pub passphrase: Zeroizing, pub format: String, pub comment: String, } pub fn normalize(payload: &Value) -> CoreResult { let kind = payload.get("type").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("ed25519").to_lowercase(); if !["ed25519", "rsa", "ecdsa", "dsa", "rsa1"].contains(&kind.as_str()) { return Err(CoreError::plain("Unsupported key type.")); } if (kind == "dsa" || kind == "rsa1") && payload.get("legacyConfirmed") != Some(&Value::Bool(true)) { return Err(CoreError::plain("Legacy key generation requires explicit confirmation.")); } let allowed: &[u32] = match kind.as_str() { "rsa" => &[2048, 3072, 4096], "ecdsa" => &[256, 384, 521], "dsa" | "rsa1" => &[1024], _ => &[] }; let default = match kind.as_str() { "rsa" => 4096, "ecdsa" => 256, "dsa" | "rsa1" => 1024, _ => 0 }; let requested = payload.get("bits").and_then(|b| b.as_f64().or_else(|| b.as_str().and_then(|s| s.parse().ok()))).map(|b| b as u32); let bits = requested.filter(|b| allowed.contains(b)).unwrap_or(default); let passphrase = payload.get("passphrase").and_then(Value::as_str).unwrap_or("").to_string(); if passphrase.encode_utf16().count() > 1024 || passphrase.contains('\0') { return Err(CoreError::plain("Invalid key passphrase.")); } let format = payload.get("privateKeyFormat").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("pem").to_lowercase(); if !["pem", "openssh", "ppk"].contains(&format.as_str()) { return Err(CoreError::plain("Unsupported private key format.")); } let comment: String = payload.get("comment").and_then(Value::as_str).unwrap_or("").split(['\r', '\n', '\t']).filter(|s| !s.is_empty()).collect::>().join(" ").trim().chars().take(200).collect(); Ok(Input { kind, bits, passphrase: Zeroizing::new(passphrase), format, comment }) } // --- SSH wire helpers -------------------------------------------------------------------------- fn ssh_u32(v: usize) -> [u8; 4] { (v as u32).to_be_bytes() } fn ssh_string(data: &[u8]) -> Vec { let mut out = ssh_u32(data.len()).to_vec(); out.extend_from_slice(data); out } fn strip_zeros(data: &[u8]) -> &[u8] { let mut d = data; while d.len() > 1 && d[0] == 0 { d = &d[1..]; } d } fn ssh_mpint(data: &[u8]) -> Vec { let d = strip_zeros(data); if d.is_empty() || d.iter().all(|b| *b == 0) { return ssh_string(&[]); } if d[0] & 0x80 != 0 { let mut v = vec![0u8]; v.extend_from_slice(d); return ssh_string(&v); } ssh_string(d) } fn pem_block(name: &str, raw: &[u8]) -> String { let b64 = B64.encode(raw); let lines: Vec<&str> = b64.as_bytes().chunks(70).map(|c| std::str::from_utf8(c).unwrap()).collect(); format!("-----BEGIN {name}-----\n{}\n-----END {name}-----\n", lines.join("\n")) } enum Numbers { Ed25519 { seed: Zeroizing>, public: Vec }, Rsa { n: Vec, e: Vec, d: Zeroizing>, p: Zeroizing>, q: Zeroizing>, qi: Zeroizing> }, Ecdsa { d: Zeroizing>, point: Vec }, Dsa { p: Vec, q: Vec, g: Vec, y: Vec, x: Zeroizing> }, } struct Generated { numbers: Numbers, pkcs8_der: Zeroizing>, } fn generate_pair(kind: &str, bits: u32) -> CoreResult { let failure = |e: String| CoreError::plain(format!("Key generation failed: {e}")); match kind { "ed25519" => { let key = ed25519_dalek::SigningKey::generate(&mut OsRng); let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?; Ok(Generated { numbers: Numbers::Ed25519 { seed: Zeroizing::new(key.to_bytes().to_vec()), public: key.verifying_key().to_bytes().to_vec() }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()) }) } "rsa" | "rsa1" => { use rsa::traits::{PrivateKeyParts, PublicKeyParts}; let mut key = rsa::RsaPrivateKey::new(&mut OsRng, bits as usize).map_err(|e| failure(e.to_string()))?; key.precompute().map_err(|e| failure(e.to_string()))?; let primes = key.primes(); let qi = key.crt_coefficient().ok_or_else(|| failure("missing CRT coefficient".into()))?; let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?; Ok(Generated { numbers: Numbers::Rsa { n: key.n().to_bytes_be(), e: key.e().to_bytes_be(), d: Zeroizing::new(key.d().to_bytes_be()), p: Zeroizing::new(primes[0].to_bytes_be()), q: Zeroizing::new(primes[1].to_bytes_be()), qi: Zeroizing::new(qi.to_bytes_be()), }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()), }) } "ecdsa" => { macro_rules! ec { ($curve:ident) => {{ use $curve::elliptic_curve::sec1::ToEncodedPoint; let secret = $curve::SecretKey::random(&mut OsRng); let der = secret.to_pkcs8_der().map_err(|e| failure(e.to_string()))?; let point = secret.public_key().to_encoded_point(false).as_bytes().to_vec(); Generated { numbers: Numbers::Ecdsa { d: Zeroizing::new(secret.to_bytes().to_vec()), point }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()) } }}; } Ok(match bits { 384 => ec!(p384), 521 => ec!(p521), _ => ec!(p256) }) } _ => { #[allow(deprecated)] // DSA-1024 is the explicitly confirmed legacy option let components = dsa::Components::generate(&mut OsRng, dsa::KeySize::DSA_1024_160); let key = dsa::SigningKey::generate(&mut OsRng, components); let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?; let c = key.verifying_key().components(); Ok(Generated { numbers: Numbers::Dsa { p: c.p().to_bytes_be(), q: c.q().to_bytes_be(), g: c.g().to_bytes_be(), y: key.verifying_key().y().to_bytes_be(), x: Zeroizing::new(key.x().to_bytes_be()) }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()), }) } } } struct Material { algorithm: String, curve: Option, blob: Vec, line: String, } fn curve_names(bits: u32) -> (&'static str, &'static str) { match bits { 384 => ("ecdsa-sha2-nistp384", "nistp384"), 521 => ("ecdsa-sha2-nistp521", "nistp521"), _ => ("ecdsa-sha2-nistp256", "nistp256") } } fn public_material(kind: &str, bits: u32, numbers: &Numbers, comment: &str) -> Material { let suffix = if comment.is_empty() { String::new() } else { format!(" {comment}") }; let finish = |algorithm: &str, curve: Option<&str>, blob: Vec| Material { algorithm: algorithm.into(), curve: curve.map(str::to_string), line: format!("{algorithm} {}{suffix}", B64.encode(&blob)), blob }; match numbers { Numbers::Ed25519 { public, .. } => finish("ssh-ed25519", None, [ssh_string(b"ssh-ed25519"), ssh_string(public)].concat()), Numbers::Rsa { n, e, .. } if kind == "rsa1" => { let to_dec = |b: &[u8]| num_bigint_dig::BigUint::from_bytes_be(b).to_str_radix(10); Material { algorithm: "ssh1-rsa".into(), curve: None, blob: [ssh_mpint(e), ssh_mpint(n)].concat(), line: format!("{bits} {} {}{suffix}", to_dec(e), to_dec(n)) } } Numbers::Rsa { n, e, .. } => finish("ssh-rsa", None, [ssh_string(b"ssh-rsa"), ssh_mpint(e), ssh_mpint(n)].concat()), Numbers::Dsa { p, q, g, y, .. } => finish("ssh-dss", None, [ssh_string(b"ssh-dss"), ssh_mpint(p), ssh_mpint(q), ssh_mpint(g), ssh_mpint(y)].concat()), Numbers::Ecdsa { point, .. } => { let (algorithm, curve) = curve_names(bits); finish(algorithm, Some(curve), [ssh_string(algorithm.as_bytes()), ssh_string(curve.as_bytes()), ssh_string(point)].concat()) } } } fn openssh_private(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing { let mut check = [0u8; 4]; OsRng.fill(&mut check); let algorithm = ssh_string(material.algorithm.as_bytes()); let fields: Vec = match numbers { Numbers::Ed25519 { seed, public } => [algorithm, ssh_string(public), ssh_string(&[seed.as_slice(), public.as_slice()].concat())].concat(), Numbers::Rsa { n, e, d, p, q, qi } => [algorithm, ssh_mpint(n), ssh_mpint(e), ssh_mpint(d), ssh_mpint(qi), ssh_mpint(p), ssh_mpint(q)].concat(), Numbers::Dsa { p, q, g, y, x } => [algorithm, ssh_mpint(p), ssh_mpint(q), ssh_mpint(g), ssh_mpint(y), ssh_mpint(x)].concat(), Numbers::Ecdsa { d, point } => [algorithm, ssh_string(material.curve.as_deref().unwrap_or("").as_bytes()), ssh_string(point), ssh_mpint(d)].concat(), }; let mut private = Zeroizing::new([check.as_slice(), check.as_slice(), &fields, &ssh_string(comment.as_bytes())].concat()); let padding = (8 - private.len() % 8) % 8; for i in 1..=padding { private.push(i as u8); } let raw = Zeroizing::new([b"openssh-key-v1\0".as_slice(), &ssh_string(b"none"), &ssh_string(b"none"), &ssh_string(&[]), &ssh_u32(1), &ssh_string(&material.blob), &ssh_string(&private)].concat()); Zeroizing::new(pem_block("OPENSSH PRIVATE KEY", &raw)) } fn ppk_v2(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing { let private: Zeroizing> = Zeroizing::new(match numbers { // PuTTY stores the EdDSA private integer little-endian and unsigned (`put_mp_le_unsigned`); // 0.1.0 wrote seed‖public here, which PuTTY rejects. Numbers::Ed25519 { seed, .. } => { let used = seed.iter().rposition(|b| *b != 0).map(|i| i + 1).unwrap_or(0); ssh_string(&seed[..used]) } Numbers::Rsa { d, p, q, qi, .. } => [ssh_mpint(d), ssh_mpint(p), ssh_mpint(q), ssh_mpint(qi)].concat(), Numbers::Dsa { x, .. } => ssh_mpint(x), Numbers::Ecdsa { d, .. } => ssh_mpint(d), }); let mac_data = Zeroizing::new([ssh_string(material.algorithm.as_bytes()), ssh_string(b"none"), ssh_string(comment.as_bytes()), ssh_string(&material.blob), ssh_string(&private)].concat()); let mac_key = Sha1::digest(b"putty-private-key-file-mac-key"); let mut mac = as Mac>::new_from_slice(&mac_key).expect("hmac"); mac.update(&mac_data); let tag = hex::encode(mac.finalize().into_bytes()); let lines = |data: &[u8]| -> Vec { let b64 = B64.encode(data); if b64.is_empty() { vec![String::new()] } else { b64.as_bytes().chunks(64).map(|c| String::from_utf8_lossy(c).to_string()).collect() } }; let (public_lines, private_lines) = (lines(&material.blob), Zeroizing::new(lines(&private))); let mut out = vec![ format!("PuTTY-User-Key-File-2: {}", material.algorithm), "Encryption: none".into(), format!("Comment: {comment}"), format!("Public-Lines: {}", public_lines.len()), ]; out.extend(public_lines); out.push(format!("Private-Lines: {}", private_lines.len())); out.extend(private_lines.iter().cloned()); out.push(format!("Private-MAC: {tag}")); Zeroizing::new(out.join("\n") + "\n") } fn export_pem(pkcs8_der: &[u8], passphrase: &str) -> CoreResult> { if passphrase.is_empty() { return Ok(Zeroizing::new(pem_block("PRIVATE KEY", pkcs8_der))); } let mut salt = [0u8; 16]; let mut iv = [0u8; 16]; OsRng.fill(&mut salt); OsRng.fill(&mut iv); let params = pkcs5::pbes2::Parameters::pbkdf2_sha256_aes256cbc(2048, &salt, &iv).map_err(|e| CoreError::plain(e.to_string()))?; let info = pkcs8::PrivateKeyInfo::try_from(pkcs8_der).map_err(|e| CoreError::plain(e.to_string()))?; let encrypted = info.encrypt_with_params(params, passphrase.as_bytes()).map_err(|e| CoreError::plain(e.to_string()))?; Ok(Zeroizing::new(pem_block("ENCRYPTED PRIVATE KEY", encrypted.as_bytes()))) } fn choose_format(input: &Input) -> CoreResult<(String, String)> { if input.kind == "rsa1" { if !input.passphrase.is_empty() { return Err(CoreError::plain("SSH1 RSA PPK export cannot be protected by a passphrase. Remove it or use a modern key type.")); } return Ok(("ppk".into(), if input.format == "ppk" { "SSH1 RSA je zastaralý formát." } else { "SSH1 RSA se ukládá jako PuTTY PPK v2." }.into())); } if !input.passphrase.is_empty() && input.format != "pem" { let name = if input.format == "ppk" { "PPK v2" } else { "OpenSSH" }; return Ok(("pem".into(), format!("{name} export s passphrase není ve vestavěném backendu dostupný; byl bezpečně použit šifrovaný PEM/PKCS#8."))); } Ok((input.format.clone(), if input.format == "ppk" { "PPK v2 je určen hlavně pro kompatibilitu se staršími nástroji PuTTY.".into() } else { String::new() })) } pub fn generate(payload: &Value) -> CoreResult { let input = normalize(payload)?; let generated = generate_pair(&input.kind, input.bits)?; let material = public_material(&input.kind, input.bits, &generated.numbers, &input.comment); let pem = export_pem(&generated.pkcs8_der, &input.passphrase)?; let (format, notice) = choose_format(&input)?; let openssh = if format == "openssh" { openssh_private(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) }; let ppk = if format == "ppk" { ppk_v2(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) }; let private = match format.as_str() { "openssh" => openssh.as_str(), "ppk" => ppk.as_str(), _ => pem.as_str() }; let base = match input.kind.as_str() { "ed25519" => "id_ed25519", "rsa" => "id_rsa", "ecdsa" => "id_ecdsa", "dsa" => "id_dsa", _ => "id_rsa1" }; Ok(json!({ "generationId": uuid::Uuid::new_v4().to_string(), "type": input.kind, "bits": input.bits, "privateKeyFormat": format, "privateKey": private, "privateKeyPem": pem.as_str(), "privateKeyOpenSSH": openssh.as_str(), "privateKeyPpk": ppk.as_str(), "publicKey": material.line, "publicKeyOpenSsh": material.line, "suggestedName": if format == "ppk" { format!("{base}.ppk") } else { base.to_string() }, "legacy": input.kind == "dsa" || input.kind == "rsa1", "notice": notice })) } pub fn generate_password(options: &Value) -> CoreResult { let length = options.get("length").and_then(Value::as_f64).filter(|l| l.fract() == 0.0).map(|l| l as usize); let Some(length) = length.filter(|l| (PASSWORD_MIN..=PASSWORD_MAX).contains(l)) else { return Err(CoreError::plain(format!("Password length must be {PASSWORD_MIN}-{PASSWORD_MAX} characters."))); }; let grouped = options.get("grouped") == Some(&Value::Bool(true)); let sets: Vec> = PASSWORD_SETS .iter() .filter(|(key, _)| options.get(*key) == Some(&Value::Bool(true))) .map(|(_, chars)| chars.chars().filter(|c| !(grouped && *c == '-')).collect::>()) .filter(|set| !set.is_empty()) .collect(); if sets.is_empty() { return Err(CoreError::plain("Select at least one character set.")); } let mut pool: Vec = Vec::new(); for c in sets.iter().flatten() { if !pool.contains(c) { pool.push(*c); } } let separator = |index: usize| grouped && (index + 1) % PASSWORD_GROUP == 0; let slots = (0..length).filter(|i| !separator(*i)).count(); if slots < sets.len() { return Err(CoreError::plain(format!("The length leaves {slots} random characters, but {} character sets are selected.", sets.len()))); } for _ in 0..10_000 { let chars: Vec = (0..slots).map(|_| pool[OsRng.gen_range(0..pool.len())]).collect(); if !sets.iter().all(|set| chars.iter().any(|c| set.contains(c))) { continue; } let mut next = 0; let password: String = (0..length) .map(|i| { if separator(i) { '-' } else { next += 1; chars[next - 1] } }) .collect(); return Ok(json!({ "password": password, "length": length, "randomCharacters": slots, "poolSize": pool.len(), "entropyBits": (slots as f64 * (pool.len() as f64).log2()).floor() as u64 })); } Err(CoreError::plain("The password could not be generated with these settings.")) } struct Entry { id: String, value: T, created: Instant, } /// Bounded in-memory caches for save/copy (never persisted, never logged). #[derive(Default)] pub struct KeyGeneratorService { generations: Mutex>>, passwords: Mutex>>>, } impl KeyGeneratorService { pub async fn generate(&self, payload: &Value) -> CoreResult { let payload = payload.clone(); // RSA 4096 generation is CPU heavy: run it off the async workers. let result = tokio::task::spawn_blocking(move || generate(&payload)).await.map_err(|_| CoreError::plain("Key generation failed."))??; let mut list = self.generations.lock(); list.push_back(Entry { id: result["generationId"].as_str().unwrap_or("").to_string(), value: result.clone(), created: Instant::now() }); while list.len() > 20 { list.pop_front(); } Ok(result) } pub fn generated(&self, id: &str) -> CoreResult { let list = self.generations.lock(); list.iter().find(|e| e.id == id && e.created.elapsed() <= TTL).map(|e| e.value.clone()).ok_or_else(|| CoreError::plain("Generated key expired. Generate it again.")) } pub fn generate_password(&self, options: &Value) -> CoreResult { let mut result = generate_password(options)?; let id = uuid::Uuid::new_v4().to_string(); let mut list = self.passwords.lock(); list.push_back(Entry { id: id.clone(), value: Zeroizing::new(result["password"].as_str().unwrap_or("").to_string()), created: Instant::now() }); while list.len() > 20 { list.pop_front(); } result["passwordId"] = json!(id); Ok(result) } pub fn password(&self, id: &str) -> CoreResult> { let mut list = self.passwords.lock(); match list.iter().position(|e| e.id == id) { Some(index) if list[index].created.elapsed() <= TTL => Ok(list[index].value.clone()), Some(index) => { list.remove(index); Err(CoreError::plain("Generated password expired. Generate it again.")) } None => Err(CoreError::plain("Generated password expired. Generate it again.")), } } } /// Writes private (0600) and public (0644) key files. pub fn save_pair(private_path: &std::path::Path, generated: &Value) -> CoreResult { let public_path = std::path::PathBuf::from(format!("{}.pub", private_path.to_string_lossy())); let write = |path: &std::path::Path, content: &str, mode: u32| -> CoreResult<()> { use std::io::Write; let mut options = std::fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(mode); } options.open(path)?.write_all(content.as_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)); } let _ = mode; Ok(()) }; write(private_path, generated["privateKey"].as_str().unwrap_or(""), 0o600)?; write(&public_path, generated["publicKey"].as_str().unwrap_or(""), 0o644)?; Ok(json!({ "canceled": false, "privatePath": private_path.to_string_lossy(), "publicPath": public_path.to_string_lossy(), "privateKeyFormat": generated["privateKeyFormat"] })) } #[cfg(test)] mod tests { use super::*; #[test] fn ed25519_openssh_is_parseable() { let key = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "comment": "test@chj"})).unwrap(); let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap(); assert_eq!(parsed.public_key().to_openssh().unwrap(), key["publicKey"].as_str().unwrap()); } #[test] fn ecdsa_and_ppk_and_encrypted_pem() { for bits in [256, 384, 521] { let key = generate(&json!({"type": "ecdsa", "bits": bits, "privateKeyFormat": "openssh"})).unwrap(); let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap(); assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1)); } for (kind, bits) in [("rsa", 2048), ("ecdsa", 256), ("ecdsa", 521)] { let ppk = generate(&json!({"type": kind, "bits": bits, "privateKeyFormat": "ppk", "comment": "c"})).unwrap(); let parsed = russh::keys::decode_secret_key(ppk["privateKey"].as_str().unwrap(), None).unwrap(); assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), ppk["publicKey"].as_str().unwrap().split(' ').nth(1)); } let encrypted = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "passphrase": "geheim"})).unwrap(); assert_eq!(encrypted["privateKeyFormat"], "pem"); assert!(encrypted["privateKey"].as_str().unwrap().starts_with("-----BEGIN ENCRYPTED PRIVATE KEY-----")); assert!(russh::keys::decode_secret_key(encrypted["privateKey"].as_str().unwrap(), Some("geheim")).is_ok()); } /// Ed25519 PPK: MAC and the little-endian private integer as PuTTY writes it. #[test] fn ed25519_ppk_matches_putty_layout() { let ppk = generate(&json!({"type": "ed25519", "privateKeyFormat": "ppk", "comment": "k"})).unwrap(); let text = ppk["privateKey"].as_str().unwrap(); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines[0], "PuTTY-User-Key-File-2: ssh-ed25519"); let public_count: usize = lines[3].strip_prefix("Public-Lines: ").unwrap().parse().unwrap(); let public = B64.decode(lines[4..4 + public_count].concat()).unwrap(); let at = 4 + public_count; assert_eq!(lines[at], "Private-Lines: 1"); let private = B64.decode(lines[at + 1]).unwrap(); let len = u32::from_be_bytes(private[..4].try_into().unwrap()) as usize; assert!(len <= 32 && private.len() == 4 + len); let mut seed = [0u8; 32]; seed[..len].copy_from_slice(&private[4..]); let derived = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes(); assert_eq!(&public[public.len() - 32..], derived.as_slice()); let mac_data = [ssh_string(b"ssh-ed25519"), ssh_string(b"none"), ssh_string(b"k"), ssh_string(&public), ssh_string(&private)].concat(); let mut mac = as Mac>::new_from_slice(&Sha1::digest(b"putty-private-key-file-mac-key")).unwrap(); mac.update(&mac_data); assert_eq!(lines[at + 2], format!("Private-MAC: {}", hex::encode(mac.finalize().into_bytes()))); } #[test] fn legacy_requires_confirmation_and_dsa_works() { assert!(generate(&json!({"type": "dsa"})).is_err()); let dsa = generate(&json!({"type": "dsa", "legacyConfirmed": true, "privateKeyFormat": "openssh"})).unwrap(); assert!(dsa["publicKey"].as_str().unwrap().starts_with("ssh-dss ")); assert_eq!(dsa["legacy"], true); let rsa1 = generate(&json!({"type": "rsa1", "legacyConfirmed": true})).unwrap(); assert!(rsa1["publicKey"].as_str().unwrap().starts_with("1024 65537 ")); assert_eq!(rsa1["privateKeyFormat"], "ppk"); } /// Independent check with OpenSSH's ssh-keygen (skipped when it is not installed). #[test] fn ssh_keygen_reads_generated_keys() { // ssh-keygen may wait on the console instead of failing (Windows OpenSSH); never wait forever. fn ssh_keygen(args: &[&std::ffi::OsStr]) -> std::io::Result { let mut child = std::process::Command::new("ssh-keygen") .args(args) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn()?; let started = std::time::Instant::now(); while child.try_wait()?.is_none() { if started.elapsed() > std::time::Duration::from_secs(20) { let _ = child.kill(); panic!("ssh-keygen {args:?} did not finish within 20 s"); } std::thread::sleep(std::time::Duration::from_millis(50)); } child.wait_with_output() } let dir = tempfile::tempdir().unwrap(); let cases = [ json!({"type": "ed25519", "privateKeyFormat": "openssh"}), // (OpenSSH cannot read Ed25519 PKCS#8 at all — same as 0.1.0; covered via russh above.) json!({"type": "rsa", "bits": 3072, "privateKeyFormat": "pem", "passphrase": "pw-test"}), json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "openssh"}), json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "pem"}), json!({"type": "ecdsa", "bits": 384, "privateKeyFormat": "openssh"}), json!({"type": "ecdsa", "bits": 521, "privateKeyFormat": "pem", "passphrase": "pw-test"}), ]; for (index, case) in cases.iter().enumerate() { let key = generate(case).unwrap(); let path = dir.path().join(format!("k{index}")); save_pair(&path, &key).unwrap(); let passphrase = case["passphrase"].as_str().unwrap_or(""); // (No "ssh-keygen -?" probe: Windows OpenSSH never returns from it.) let output = match ssh_keygen(&["-y".as_ref(), "-P".as_ref(), passphrase.as_ref(), "-f".as_ref(), path.as_os_str()]) { Ok(output) => output, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { eprintln!("SKIPPED: ssh-keygen not available"); return; } Err(error) => panic!("{case}: {error}"), }; assert!(output.status.success(), "{case}: {}", String::from_utf8_lossy(&output.stderr)); let derived = String::from_utf8_lossy(&output.stdout); assert_eq!(derived.trim().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1), "{case}"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); } } } #[test] fn passwords() { let result = generate_password(&json!({"length": 24, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).unwrap(); let password = result["password"].as_str().unwrap(); assert_eq!(password.chars().count(), 24); for (i, c) in password.chars().enumerate() { if (i + 1) % 5 == 0 { assert_eq!(c, '-'); } else { assert_ne!(c, '-'); assert_ne!(c, '0'); } } assert!(generate_password(&json!({"length": 3, "upper": true})).is_err()); assert!(generate_password(&json!({"length": 10})).is_err()); assert!(generate_password(&json!({"length": 5, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).is_ok()); } }