Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
//! Local SSH key and password generation (port of `keys/keyGeneratorService.js`).2
//!3
//! Ed25519, RSA 2048/3072/4096, ECDSA P-256/384/521 and explicitly confirmed legacy DSA-10244
//! and SSH1 RSA. Exports: PEM PKCS#8 (passphrase → PBES2/PBKDF2-SHA256/AES-256-CBC like the5
//! Node runtime of 0.1.0), unencrypted OpenSSH and PuTTY PPK v2. Generated material is kept in6
//! memory only (max 20, 10 minutes) so it can be saved through a Core dialog.8
use crate::core::error::{CoreError, CoreResult};9
use base64::engine::general_purpose::STANDARD as B64;10
use base64::Engine;11
use hmac::{Hmac, Mac};12
use parking_lot::Mutex;13
use pkcs8::EncodePrivateKey;14
use rand::rngs::OsRng;15
use rand::Rng;16
use serde_json::{json, Value};17
use sha1::{Digest as _, Sha1};18
use std::collections::VecDeque;19
use std::time::{Duration, Instant};20
use zeroize::Zeroizing;22
const TTL: Duration = Duration::from_secs(10 * 60);23
pub const PASSWORD_SETS: [(&str, &str); 4] = [("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"), ("lower", "abcdefghijklmnopqrstuvwxyz"), ("digits", "123456789"), ("special", "-@)\u{a8}\u{a7}'=+")];24
pub const PASSWORD_MIN: usize = 4;25
pub const PASSWORD_MAX: usize = 70;26
const PASSWORD_GROUP: usize = 5;28
#[derive(Debug, Clone)]29
pub struct Input {30
pub kind: String,31
pub bits: u32,32
pub passphrase: Zeroizing<String>,33
pub format: String,34
pub comment: String,35
}37
pub fn normalize(payload: &Value) -> CoreResult<Input> {38
let kind = payload.get("type").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("ed25519").to_lowercase();39
if !["ed25519", "rsa", "ecdsa", "dsa", "rsa1"].contains(&kind.as_str()) {40
return Err(CoreError::plain("Unsupported key type."));41
}42
if (kind == "dsa" || kind == "rsa1") && payload.get("legacyConfirmed") != Some(&Value::Bool(true)) {43
return Err(CoreError::plain("Legacy key generation requires explicit confirmation."));44
}45
let allowed: &[u32] = match kind.as_str() { "rsa" => &[2048, 3072, 4096], "ecdsa" => &[256, 384, 521], "dsa" | "rsa1" => &[1024], _ => &[] };46
let default = match kind.as_str() { "rsa" => 4096, "ecdsa" => 256, "dsa" | "rsa1" => 1024, _ => 0 };47
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);48
let bits = requested.filter(|b| allowed.contains(b)).unwrap_or(default);49
let passphrase = payload.get("passphrase").and_then(Value::as_str).unwrap_or("").to_string();50
if passphrase.encode_utf16().count() > 1024 || passphrase.contains('\0') {51
return Err(CoreError::plain("Invalid key passphrase."));52
}53
let format = payload.get("privateKeyFormat").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("pem").to_lowercase();54
if !["pem", "openssh", "ppk"].contains(&format.as_str()) {55
return Err(CoreError::plain("Unsupported private key format."));56
}57
let comment: String = payload.get("comment").and_then(Value::as_str).unwrap_or("").split(['\r', '\n', '\t']).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ").trim().chars().take(200).collect();58
Ok(Input { kind, bits, passphrase: Zeroizing::new(passphrase), format, comment })59
}61
// --- SSH wire helpers --------------------------------------------------------------------------63
fn ssh_u32(v: usize) -> [u8; 4] {64
(v as u32).to_be_bytes()65
}67
fn ssh_string(data: &[u8]) -> Vec<u8> {68
let mut out = ssh_u32(data.len()).to_vec();69
out.extend_from_slice(data);70
out71
}73
fn strip_zeros(data: &[u8]) -> &[u8] {74
let mut d = data;75
while d.len() > 1 && d[0] == 0 {76
d = &d[1..];77
}78
d79
}81
fn ssh_mpint(data: &[u8]) -> Vec<u8> {82
let d = strip_zeros(data);83
if d.is_empty() || d.iter().all(|b| *b == 0) {84
return ssh_string(&[]);85
}86
if d[0] & 0x80 != 0 {87
let mut v = vec![0u8];88
v.extend_from_slice(d);89
return ssh_string(&v);90
}91
ssh_string(d)92
}94
fn pem_block(name: &str, raw: &[u8]) -> String {95
let b64 = B64.encode(raw);96
let lines: Vec<&str> = b64.as_bytes().chunks(70).map(|c| std::str::from_utf8(c).unwrap()).collect();97
format!("-----BEGIN {name}-----\n{}\n-----END {name}-----\n", lines.join("\n"))98
}100
enum Numbers {101
Ed25519 { seed: Zeroizing<Vec<u8>>, public: Vec<u8> },102
Rsa { n: Vec<u8>, e: Vec<u8>, d: Zeroizing<Vec<u8>>, p: Zeroizing<Vec<u8>>, q: Zeroizing<Vec<u8>>, qi: Zeroizing<Vec<u8>> },103
Ecdsa { d: Zeroizing<Vec<u8>>, point: Vec<u8> },104
Dsa { p: Vec<u8>, q: Vec<u8>, g: Vec<u8>, y: Vec<u8>, x: Zeroizing<Vec<u8>> },105
}107
struct Generated {108
numbers: Numbers,109
pkcs8_der: Zeroizing<Vec<u8>>,110
}112
fn generate_pair(kind: &str, bits: u32) -> CoreResult<Generated> {113
let failure = |e: String| CoreError::plain(format!("Key generation failed: {e}"));114
match kind {115
"ed25519" => {116
let key = ed25519_dalek::SigningKey::generate(&mut OsRng);117
let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;118
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()) })119
}120
"rsa" | "rsa1" => {121
use rsa::traits::{PrivateKeyParts, PublicKeyParts};122
let mut key = rsa::RsaPrivateKey::new(&mut OsRng, bits as usize).map_err(|e| failure(e.to_string()))?;123
key.precompute().map_err(|e| failure(e.to_string()))?;124
let primes = key.primes();125
let qi = key.crt_coefficient().ok_or_else(|| failure("missing CRT coefficient".into()))?;126
let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;127
Ok(Generated {128
numbers: Numbers::Rsa {129
n: key.n().to_bytes_be(),130
e: key.e().to_bytes_be(),131
d: Zeroizing::new(key.d().to_bytes_be()),132
p: Zeroizing::new(primes[0].to_bytes_be()),133
q: Zeroizing::new(primes[1].to_bytes_be()),134
qi: Zeroizing::new(qi.to_bytes_be()),135
},136
pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()),137
})138
}139
"ecdsa" => {140
macro_rules! ec {141
($curve:ident) => {{142
use $curve::elliptic_curve::sec1::ToEncodedPoint;143
let secret = $curve::SecretKey::random(&mut OsRng);144
let der = secret.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;145
let point = secret.public_key().to_encoded_point(false).as_bytes().to_vec();146
Generated { numbers: Numbers::Ecdsa { d: Zeroizing::new(secret.to_bytes().to_vec()), point }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()) }147
}};148
}149
Ok(match bits { 384 => ec!(p384), 521 => ec!(p521), _ => ec!(p256) })150
}151
_ => {152
#[allow(deprecated)] // DSA-1024 is the explicitly confirmed legacy option153
let components = dsa::Components::generate(&mut OsRng, dsa::KeySize::DSA_1024_160);154
let key = dsa::SigningKey::generate(&mut OsRng, components);155
let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;156
let c = key.verifying_key().components();157
Ok(Generated {158
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()) },159
pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()),160
})161
}162
}163
}165
struct Material {166
algorithm: String,167
curve: Option<String>,168
blob: Vec<u8>,169
line: String,170
}172
fn curve_names(bits: u32) -> (&'static str, &'static str) {173
match bits { 384 => ("ecdsa-sha2-nistp384", "nistp384"), 521 => ("ecdsa-sha2-nistp521", "nistp521"), _ => ("ecdsa-sha2-nistp256", "nistp256") }174
}176
fn public_material(kind: &str, bits: u32, numbers: &Numbers, comment: &str) -> Material {177
let suffix = if comment.is_empty() { String::new() } else { format!(" {comment}") };178
let finish = |algorithm: &str, curve: Option<&str>, blob: Vec<u8>| Material { algorithm: algorithm.into(), curve: curve.map(str::to_string), line: format!("{algorithm} {}{suffix}", B64.encode(&blob)), blob };179
match numbers {180
Numbers::Ed25519 { public, .. } => finish("ssh-ed25519", None, [ssh_string(b"ssh-ed25519"), ssh_string(public)].concat()),181
Numbers::Rsa { n, e, .. } if kind == "rsa1" => {182
let to_dec = |b: &[u8]| num_bigint_dig::BigUint::from_bytes_be(b).to_str_radix(10);183
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)) }184
}185
Numbers::Rsa { n, e, .. } => finish("ssh-rsa", None, [ssh_string(b"ssh-rsa"), ssh_mpint(e), ssh_mpint(n)].concat()),186
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()),187
Numbers::Ecdsa { point, .. } => {188
let (algorithm, curve) = curve_names(bits);189
finish(algorithm, Some(curve), [ssh_string(algorithm.as_bytes()), ssh_string(curve.as_bytes()), ssh_string(point)].concat())190
}191
}192
}194
fn openssh_private(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing<String> {195
let mut check = [0u8; 4];196
OsRng.fill(&mut check);197
let algorithm = ssh_string(material.algorithm.as_bytes());198
let fields: Vec<u8> = match numbers {199
Numbers::Ed25519 { seed, public } => [algorithm, ssh_string(public), ssh_string(&[seed.as_slice(), public.as_slice()].concat())].concat(),200
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(),201
Numbers::Dsa { p, q, g, y, x } => [algorithm, ssh_mpint(p), ssh_mpint(q), ssh_mpint(g), ssh_mpint(y), ssh_mpint(x)].concat(),202
Numbers::Ecdsa { d, point } => [algorithm, ssh_string(material.curve.as_deref().unwrap_or("").as_bytes()), ssh_string(point), ssh_mpint(d)].concat(),203
};204
let mut private = Zeroizing::new([check.as_slice(), check.as_slice(), &fields, &ssh_string(comment.as_bytes())].concat());205
let padding = (8 - private.len() % 8) % 8;206
for i in 1..=padding {207
private.push(i as u8);208
}209
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());210
Zeroizing::new(pem_block("OPENSSH PRIVATE KEY", &raw))211
}213
fn ppk_v2(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing<String> {214
let private: Zeroizing<Vec<u8>> = Zeroizing::new(match numbers {215
// PuTTY stores the EdDSA private integer little-endian and unsigned (`put_mp_le_unsigned`);216
// 0.1.0 wrote seed‖public here, which PuTTY rejects.217
Numbers::Ed25519 { seed, .. } => {218
let used = seed.iter().rposition(|b| *b != 0).map(|i| i + 1).unwrap_or(0);219
ssh_string(&seed[..used])220
}221
Numbers::Rsa { d, p, q, qi, .. } => [ssh_mpint(d), ssh_mpint(p), ssh_mpint(q), ssh_mpint(qi)].concat(),222
Numbers::Dsa { x, .. } => ssh_mpint(x),223
Numbers::Ecdsa { d, .. } => ssh_mpint(d),224
});225
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());226
let mac_key = Sha1::digest(b"putty-private-key-file-mac-key");227
let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&mac_key).expect("hmac");228
mac.update(&mac_data);229
let tag = hex::encode(mac.finalize().into_bytes());230
let lines = |data: &[u8]| -> Vec<String> {231
let b64 = B64.encode(data);232
if b64.is_empty() { vec![String::new()] } else { b64.as_bytes().chunks(64).map(|c| String::from_utf8_lossy(c).to_string()).collect() }233
};234
let (public_lines, private_lines) = (lines(&material.blob), Zeroizing::new(lines(&private)));235
let mut out = vec![236
format!("PuTTY-User-Key-File-2: {}", material.algorithm),237
"Encryption: none".into(),238
format!("Comment: {comment}"),239
format!("Public-Lines: {}", public_lines.len()),240
];241
out.extend(public_lines);242
out.push(format!("Private-Lines: {}", private_lines.len()));243
out.extend(private_lines.iter().cloned());244
out.push(format!("Private-MAC: {tag}"));245
Zeroizing::new(out.join("\n") + "\n")246
}248
fn export_pem(pkcs8_der: &[u8], passphrase: &str) -> CoreResult<Zeroizing<String>> {249
if passphrase.is_empty() {250
return Ok(Zeroizing::new(pem_block("PRIVATE KEY", pkcs8_der)));251
}252
let mut salt = [0u8; 16];253
let mut iv = [0u8; 16];254
OsRng.fill(&mut salt);255
OsRng.fill(&mut iv);256
let params = pkcs5::pbes2::Parameters::pbkdf2_sha256_aes256cbc(2048, &salt, &iv).map_err(|e| CoreError::plain(e.to_string()))?;257
let info = pkcs8::PrivateKeyInfo::try_from(pkcs8_der).map_err(|e| CoreError::plain(e.to_string()))?;258
let encrypted = info.encrypt_with_params(params, passphrase.as_bytes()).map_err(|e| CoreError::plain(e.to_string()))?;259
Ok(Zeroizing::new(pem_block("ENCRYPTED PRIVATE KEY", encrypted.as_bytes())))260
}262
fn choose_format(input: &Input) -> CoreResult<(String, String)> {263
if input.kind == "rsa1" {264
if !input.passphrase.is_empty() {265
return Err(CoreError::plain("SSH1 RSA PPK export cannot be protected by a passphrase. Remove it or use a modern key type."));266
}267
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()));268
}269
if !input.passphrase.is_empty() && input.format != "pem" {270
let name = if input.format == "ppk" { "PPK v2" } else { "OpenSSH" };271
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.")));272
}273
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() }))274
}276
pub fn generate(payload: &Value) -> CoreResult<Value> {277
let input = normalize(payload)?;278
let generated = generate_pair(&input.kind, input.bits)?;279
let material = public_material(&input.kind, input.bits, &generated.numbers, &input.comment);280
let pem = export_pem(&generated.pkcs8_der, &input.passphrase)?;281
let (format, notice) = choose_format(&input)?;282
let openssh = if format == "openssh" { openssh_private(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) };283
let ppk = if format == "ppk" { ppk_v2(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) };284
let private = match format.as_str() { "openssh" => openssh.as_str(), "ppk" => ppk.as_str(), _ => pem.as_str() };285
let base = match input.kind.as_str() { "ed25519" => "id_ed25519", "rsa" => "id_rsa", "ecdsa" => "id_ecdsa", "dsa" => "id_dsa", _ => "id_rsa1" };286
Ok(json!({287
"generationId": uuid::Uuid::new_v4().to_string(),288
"type": input.kind,289
"bits": input.bits,290
"privateKeyFormat": format,291
"privateKey": private,292
"privateKeyPem": pem.as_str(),293
"privateKeyOpenSSH": openssh.as_str(),294
"privateKeyPpk": ppk.as_str(),295
"publicKey": material.line,296
"publicKeyOpenSsh": material.line,297
"suggestedName": if format == "ppk" { format!("{base}.ppk") } else { base.to_string() },298
"legacy": input.kind == "dsa" || input.kind == "rsa1",299
"notice": notice300
}))301
}303
pub fn generate_password(options: &Value) -> CoreResult<Value> {304
let length = options.get("length").and_then(Value::as_f64).filter(|l| l.fract() == 0.0).map(|l| l as usize);305
let Some(length) = length.filter(|l| (PASSWORD_MIN..=PASSWORD_MAX).contains(l)) else {306
return Err(CoreError::plain(format!("Password length must be {PASSWORD_MIN}-{PASSWORD_MAX} characters.")));307
};308
let grouped = options.get("grouped") == Some(&Value::Bool(true));309
let sets: Vec<Vec<char>> = PASSWORD_SETS310
.iter()311
.filter(|(key, _)| options.get(*key) == Some(&Value::Bool(true)))312
.map(|(_, chars)| chars.chars().filter(|c| !(grouped && *c == '-')).collect::<Vec<_>>())313
.filter(|set| !set.is_empty())314
.collect();315
if sets.is_empty() {316
return Err(CoreError::plain("Select at least one character set."));317
}318
let mut pool: Vec<char> = Vec::new();319
for c in sets.iter().flatten() {320
if !pool.contains(c) {321
pool.push(*c);322
}323
}324
let separator = |index: usize| grouped && (index + 1) % PASSWORD_GROUP == 0;325
let slots = (0..length).filter(|i| !separator(*i)).count();326
if slots < sets.len() {327
return Err(CoreError::plain(format!("The length leaves {slots} random characters, but {} character sets are selected.", sets.len())));328
}329
for _ in 0..10_000 {330
let chars: Vec<char> = (0..slots).map(|_| pool[OsRng.gen_range(0..pool.len())]).collect();331
if !sets.iter().all(|set| chars.iter().any(|c| set.contains(c))) {332
continue;333
}334
let mut next = 0;335
let password: String = (0..length)336
.map(|i| {337
if separator(i) {338
'-'339
} else {340
next += 1;341
chars[next - 1]342
}343
})344
.collect();345
return Ok(json!({ "password": password, "length": length, "randomCharacters": slots, "poolSize": pool.len(), "entropyBits": (slots as f64 * (pool.len() as f64).log2()).floor() as u64 }));346
}347
Err(CoreError::plain("The password could not be generated with these settings."))348
}350
struct Entry<T> {351
id: String,352
value: T,353
created: Instant,354
}356
/// Bounded in-memory caches for save/copy (never persisted, never logged).357
#[derive(Default)]358
pub struct KeyGeneratorService {359
generations: Mutex<VecDeque<Entry<Value>>>,360
passwords: Mutex<VecDeque<Entry<Zeroizing<String>>>>,361
}363
impl KeyGeneratorService {364
pub async fn generate(&self, payload: &Value) -> CoreResult<Value> {365
let payload = payload.clone();366
// RSA 4096 generation is CPU heavy: run it off the async workers.367
let result = tokio::task::spawn_blocking(move || generate(&payload)).await.map_err(|_| CoreError::plain("Key generation failed."))??;368
let mut list = self.generations.lock();369
list.push_back(Entry { id: result["generationId"].as_str().unwrap_or("").to_string(), value: result.clone(), created: Instant::now() });370
while list.len() > 20 {371
list.pop_front();372
}373
Ok(result)374
}376
pub fn generated(&self, id: &str) -> CoreResult<Value> {377
let list = self.generations.lock();378
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."))379
}381
pub fn generate_password(&self, options: &Value) -> CoreResult<Value> {382
let mut result = generate_password(options)?;383
let id = uuid::Uuid::new_v4().to_string();384
let mut list = self.passwords.lock();385
list.push_back(Entry { id: id.clone(), value: Zeroizing::new(result["password"].as_str().unwrap_or("").to_string()), created: Instant::now() });386
while list.len() > 20 {387
list.pop_front();388
}389
result["passwordId"] = json!(id);390
Ok(result)391
}393
pub fn password(&self, id: &str) -> CoreResult<Zeroizing<String>> {394
let mut list = self.passwords.lock();395
match list.iter().position(|e| e.id == id) {396
Some(index) if list[index].created.elapsed() <= TTL => Ok(list[index].value.clone()),397
Some(index) => {398
list.remove(index);399
Err(CoreError::plain("Generated password expired. Generate it again."))400
}401
None => Err(CoreError::plain("Generated password expired. Generate it again.")),402
}403
}404
}406
/// Writes private (0600) and public (0644) key files.407
pub fn save_pair(private_path: &std::path::Path, generated: &Value) -> CoreResult<Value> {408
let public_path = std::path::PathBuf::from(format!("{}.pub", private_path.to_string_lossy()));409
let write = |path: &std::path::Path, content: &str, mode: u32| -> CoreResult<()> {410
use std::io::Write;411
let mut options = std::fs::OpenOptions::new();412
options.write(true).create(true).truncate(true);413
#[cfg(unix)]414
{415
use std::os::unix::fs::OpenOptionsExt;416
options.mode(mode);417
}418
options.open(path)?.write_all(content.as_bytes())?;419
#[cfg(unix)]420
{421
use std::os::unix::fs::PermissionsExt;422
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));423
}424
let _ = mode;425
Ok(())426
};427
write(private_path, generated["privateKey"].as_str().unwrap_or(""), 0o600)?;428
write(&public_path, generated["publicKey"].as_str().unwrap_or(""), 0o644)?;429
Ok(json!({ "canceled": false, "privatePath": private_path.to_string_lossy(), "publicPath": public_path.to_string_lossy(), "privateKeyFormat": generated["privateKeyFormat"] }))430
}432
#[cfg(test)]433
mod tests {434
use super::*;436
#[test]437
fn ed25519_openssh_is_parseable() {438
let key = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "comment": "test@chj"})).unwrap();439
let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap();440
assert_eq!(parsed.public_key().to_openssh().unwrap(), key["publicKey"].as_str().unwrap());441
}443
#[test]444
fn ecdsa_and_ppk_and_encrypted_pem() {445
for bits in [256, 384, 521] {446
let key = generate(&json!({"type": "ecdsa", "bits": bits, "privateKeyFormat": "openssh"})).unwrap();447
let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap();448
assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1));449
}450
for (kind, bits) in [("rsa", 2048), ("ecdsa", 256), ("ecdsa", 521)] {451
let ppk = generate(&json!({"type": kind, "bits": bits, "privateKeyFormat": "ppk", "comment": "c"})).unwrap();452
let parsed = russh::keys::decode_secret_key(ppk["privateKey"].as_str().unwrap(), None).unwrap();453
assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), ppk["publicKey"].as_str().unwrap().split(' ').nth(1));454
}455
let encrypted = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "passphrase": "geheim"})).unwrap();456
assert_eq!(encrypted["privateKeyFormat"], "pem");457
assert!(encrypted["privateKey"].as_str().unwrap().starts_with("-----BEGIN ENCRYPTED PRIVATE KEY-----"));458
assert!(russh::keys::decode_secret_key(encrypted["privateKey"].as_str().unwrap(), Some("geheim")).is_ok());459
}461
/// Ed25519 PPK: MAC and the little-endian private integer as PuTTY writes it.462
#[test]463
fn ed25519_ppk_matches_putty_layout() {464
let ppk = generate(&json!({"type": "ed25519", "privateKeyFormat": "ppk", "comment": "k"})).unwrap();465
let text = ppk["privateKey"].as_str().unwrap();466
let lines: Vec<&str> = text.lines().collect();467
assert_eq!(lines[0], "PuTTY-User-Key-File-2: ssh-ed25519");468
let public_count: usize = lines[3].strip_prefix("Public-Lines: ").unwrap().parse().unwrap();469
let public = B64.decode(lines[4..4 + public_count].concat()).unwrap();470
let at = 4 + public_count;471
assert_eq!(lines[at], "Private-Lines: 1");472
let private = B64.decode(lines[at + 1]).unwrap();473
let len = u32::from_be_bytes(private[..4].try_into().unwrap()) as usize;474
assert!(len <= 32 && private.len() == 4 + len);475
let mut seed = [0u8; 32];476
seed[..len].copy_from_slice(&private[4..]);477
let derived = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();478
assert_eq!(&public[public.len() - 32..], derived.as_slice());479
let mac_data = [ssh_string(b"ssh-ed25519"), ssh_string(b"none"), ssh_string(b"k"), ssh_string(&public), ssh_string(&private)].concat();480
let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&Sha1::digest(b"putty-private-key-file-mac-key")).unwrap();481
mac.update(&mac_data);482
assert_eq!(lines[at + 2], format!("Private-MAC: {}", hex::encode(mac.finalize().into_bytes())));483
}485
#[test]486
fn legacy_requires_confirmation_and_dsa_works() {487
assert!(generate(&json!({"type": "dsa"})).is_err());488
let dsa = generate(&json!({"type": "dsa", "legacyConfirmed": true, "privateKeyFormat": "openssh"})).unwrap();489
assert!(dsa["publicKey"].as_str().unwrap().starts_with("ssh-dss "));490
assert_eq!(dsa["legacy"], true);491
let rsa1 = generate(&json!({"type": "rsa1", "legacyConfirmed": true})).unwrap();492
assert!(rsa1["publicKey"].as_str().unwrap().starts_with("1024 65537 "));493
assert_eq!(rsa1["privateKeyFormat"], "ppk");494
}496
/// Independent check with OpenSSH's ssh-keygen (skipped when it is not installed).497
#[test]498
fn ssh_keygen_reads_generated_keys() {499
// ssh-keygen may wait on the console instead of failing (Windows OpenSSH); never wait forever.500
fn ssh_keygen(args: &[&std::ffi::OsStr]) -> std::io::Result<std::process::Output> {501
let mut child = std::process::Command::new("ssh-keygen")502
.args(args)503
.stdin(std::process::Stdio::null())504
.stdout(std::process::Stdio::piped())505
.stderr(std::process::Stdio::piped())506
.spawn()?;507
let started = std::time::Instant::now();508
while child.try_wait()?.is_none() {509
if started.elapsed() > std::time::Duration::from_secs(20) {510
let _ = child.kill();511
panic!("ssh-keygen {args:?} did not finish within 20 s");512
}513
std::thread::sleep(std::time::Duration::from_millis(50));514
}515
child.wait_with_output()516
}518
let dir = tempfile::tempdir().unwrap();519
let cases = [520
json!({"type": "ed25519", "privateKeyFormat": "openssh"}),521
// (OpenSSH cannot read Ed25519 PKCS#8 at all — same as 0.1.0; covered via russh above.)522
json!({"type": "rsa", "bits": 3072, "privateKeyFormat": "pem", "passphrase": "pw-test"}),523
json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "openssh"}),524
json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "pem"}),525
json!({"type": "ecdsa", "bits": 384, "privateKeyFormat": "openssh"}),526
json!({"type": "ecdsa", "bits": 521, "privateKeyFormat": "pem", "passphrase": "pw-test"}),527
];528
for (index, case) in cases.iter().enumerate() {529
let key = generate(case).unwrap();530
let path = dir.path().join(format!("k{index}"));531
save_pair(&path, &key).unwrap();532
let passphrase = case["passphrase"].as_str().unwrap_or("");533
// (No "ssh-keygen -?" probe: Windows OpenSSH never returns from it.)534
let output = match ssh_keygen(&["-y".as_ref(), "-P".as_ref(), passphrase.as_ref(), "-f".as_ref(), path.as_os_str()]) {535
Ok(output) => output,536
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {537
eprintln!("SKIPPED: ssh-keygen not available");538
return;539
}540
Err(error) => panic!("{case}: {error}"),541
};542
assert!(output.status.success(), "{case}: {}", String::from_utf8_lossy(&output.stderr));543
let derived = String::from_utf8_lossy(&output.stdout);544
assert_eq!(derived.trim().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1), "{case}");545
#[cfg(unix)]546
{547
use std::os::unix::fs::PermissionsExt;548
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);549
}550
}551
}553
#[test]554
fn passwords() {555
let result = generate_password(&json!({"length": 24, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).unwrap();556
let password = result["password"].as_str().unwrap();557
assert_eq!(password.chars().count(), 24);558
for (i, c) in password.chars().enumerate() {559
if (i + 1) % 5 == 0 {560
assert_eq!(c, '-');561
} else {562
assert_ne!(c, '-');563
assert_ne!(c, '0');564
}565
}566
assert!(generate_password(&json!({"length": 3, "upper": true})).is_err());567
assert!(generate_password(&json!({"length": 10})).is_err());568
assert!(generate_password(&json!({"length": 5, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).is_ok());569
}570
}SHA-256
3eae1fba15e5a7535a1dc7c6c61428577f036d05a042e21f463b3c2c204f191cCH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df