CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
keys.rs Hinzugefügt: Datum nicht erfasst 28,7 KB · 570 Zeilen
Code Datei herunterladen
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-1024
4//! and SSH1 RSA. Exports: PEM PKCS#8 (passphrase → PBES2/PBKDF2-SHA256/AES-256-CBC like the
5//! Node runtime of 0.1.0), unencrypted OpenSSH and PuTTY PPK v2. Generated material is kept in
6//! memory only (max 20, 10 minutes) so it can be saved through a Core dialog.
8use crate::core::error::{CoreError, CoreResult};
9use base64::engine::general_purpose::STANDARD as B64;
10use base64::Engine;
11use hmac::{Hmac, Mac};
12use parking_lot::Mutex;
13use pkcs8::EncodePrivateKey;
14use rand::rngs::OsRng;
15use rand::Rng;
16use serde_json::{json, Value};
17use sha1::{Digest as _, Sha1};
18use std::collections::VecDeque;
19use std::time::{Duration, Instant};
20use zeroize::Zeroizing;
22const TTL: Duration = Duration::from_secs(10 * 60);
23pub const PASSWORD_SETS: [(&str, &str); 4] = [("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"), ("lower", "abcdefghijklmnopqrstuvwxyz"), ("digits", "123456789"), ("special", "-@)\u{a8}\u{a7}'=+")];
24pub const PASSWORD_MIN: usize = 4;
25pub const PASSWORD_MAX: usize = 70;
26const PASSWORD_GROUP: usize = 5;
28#[derive(Debug, Clone)]
29pub struct Input {
30 pub kind: String,
31 pub bits: u32,
32 pub passphrase: Zeroizing<String>,
33 pub format: String,
34 pub comment: String,
37pub 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 })
61// --- SSH wire helpers --------------------------------------------------------------------------
63fn ssh_u32(v: usize) -> [u8; 4] {
64 (v as u32).to_be_bytes()
67fn ssh_string(data: &[u8]) -> Vec<u8> {
68 let mut out = ssh_u32(data.len()).to_vec();
69 out.extend_from_slice(data);
70 out
73fn strip_zeros(data: &[u8]) -> &[u8] {
74 let mut d = data;
75 while d.len() > 1 && d[0] == 0 {
76 d = &d[1..];
77 }
78 d
81fn 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)
94fn 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"))
100enum 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>> },
107struct Generated {
108 numbers: Numbers,
109 pkcs8_der: Zeroizing<Vec<u8>>,
112fn 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 option
153 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 }
165struct Material {
166 algorithm: String,
167 curve: Option<String>,
168 blob: Vec<u8>,
169 line: String,
172fn 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") }
176fn 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 }
194fn 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))
213fn 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")
248fn 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())))
262fn 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() }))
276pub 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": notice
300 }))
303pub 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_SETS
310 .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."))
350struct Entry<T> {
351 id: String,
352 value: T,
353 created: Instant,
356/// Bounded in-memory caches for save/copy (never persisted, never logged).
357#[derive(Default)]
358pub struct KeyGeneratorService {
359 generations: Mutex<VecDeque<Entry<Value>>>,
360 passwords: Mutex<VecDeque<Entry<Zeroizing<String>>>>,
363impl 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 }
406/// Writes private (0600) and public (0644) key files.
407pub 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"] }))
432#[cfg(test)]
433mod 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 }
SHA-2563eae1fba15e5a7535a1dc7c6c61428577f036d05a042e21f463b3c2c204f191c

CH-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 Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien