CH-J Toolsserverhåndtering over SSH
Menu
Offentliggjort kilde

CH-J Tools

Gennemse mapper og filer til en specifik applikationsudgivelse.

Download kilde ZIP
CH-J / Server Manager 1704 filer
0.1.2-beta Tilføjet: · Bygget:
keys.rs Tilføjet: Dato ikke registreret 44,9 KB · 776 linjer
Kode Download fil
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::{HashMap, VecDeque};
19use std::time::{Duration, Instant};
20use std::sync::Arc;
21use zeroize::Zeroizing;
23const TTL: Duration = Duration::from_secs(10 * 60);
24pub const PASSWORD_SETS: [(&str, &str); 4] = [("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"), ("lower", "abcdefghijklmnopqrstuvwxyz"), ("digits", "123456789"), ("special", "-@)\u{a8}\u{a7}'=+")];
25pub const PASSWORD_MIN: usize = 4;
26pub const PASSWORD_MAX: usize = 70;
27const PASSWORD_GROUP: usize = 5;
29#[derive(Debug, Clone)]
30pub struct Input {
31 pub kind: String,
32 pub bits: u32,
33 pub passphrase: Zeroizing<String>,
34 pub format: String,
35 pub comment: String,
38pub fn normalize(payload: &Value) -> CoreResult<Input> {
39 let kind = payload.get("type").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("ed25519").to_lowercase();
40 if !["ed25519", "rsa", "ecdsa", "dsa", "rsa1", "mldsa44-ed25519"].contains(&kind.as_str()) {
41 return Err(CoreError::plain("Unsupported key type."));
42 }
43 if (kind == "dsa" || kind == "rsa1") && payload.get("legacyConfirmed") != Some(&Value::Bool(true)) {
44 return Err(CoreError::plain("Legacy key generation requires explicit confirmation."));
45 }
46 let allowed: &[u32] = match kind.as_str() { "rsa" => &[2048, 3072, 4096], "ecdsa" => &[256, 384, 521], "dsa" | "rsa1" => &[1024], _ => &[] };
47 let default = match kind.as_str() { "rsa" => 4096, "ecdsa" => 256, "dsa" | "rsa1" => 1024, _ => 0 };
48 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);
49 let bits = requested.filter(|b| allowed.contains(b)).unwrap_or(default);
50 let passphrase = payload.get("passphrase").and_then(Value::as_str).unwrap_or("").to_string();
51 if passphrase.encode_utf16().count() > 1024 || passphrase.contains('\0') {
52 return Err(CoreError::plain("Invalid key passphrase."));
53 }
54 let format = payload.get("privateKeyFormat").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("pem").to_lowercase();
55 if !["pem", "openssh", "ppk"].contains(&format.as_str()) {
56 return Err(CoreError::plain("Unsupported private key format."));
57 }
58 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();
59 Ok(Input { kind, bits, passphrase: Zeroizing::new(passphrase), format, comment })
62// --- SSH wire helpers --------------------------------------------------------------------------
64fn ssh_u32(v: usize) -> [u8; 4] {
65 (v as u32).to_be_bytes()
68fn ssh_string(data: &[u8]) -> Vec<u8> {
69 let mut out = ssh_u32(data.len()).to_vec();
70 out.extend_from_slice(data);
71 out
74fn strip_zeros(data: &[u8]) -> &[u8] {
75 let mut d = data;
76 while d.len() > 1 && d[0] == 0 {
77 d = &d[1..];
78 }
79 d
82fn ssh_mpint(data: &[u8]) -> Vec<u8> {
83 let d = strip_zeros(data);
84 if d.is_empty() || d.iter().all(|b| *b == 0) {
85 return ssh_string(&[]);
86 }
87 if d[0] & 0x80 != 0 {
88 let mut v = vec![0u8];
89 v.extend_from_slice(d);
90 return ssh_string(&v);
91 }
92 ssh_string(d)
95fn pem_block(name: &str, raw: &[u8]) -> String {
96 let b64 = B64.encode(raw);
97 let lines: Vec<&str> = b64.as_bytes().chunks(70).map(|c| std::str::from_utf8(c).unwrap()).collect();
98 format!("-----BEGIN {name}-----\n{}\n-----END {name}-----\n", lines.join("\n"))
101enum Numbers {
102 Ed25519 { seed: Zeroizing<Vec<u8>>, public: Vec<u8> },
103 Rsa { n: Vec<u8>, e: Vec<u8>, d: Zeroizing<Vec<u8>>, p: Zeroizing<Vec<u8>>, q: Zeroizing<Vec<u8>>, qi: Zeroizing<Vec<u8>> },
104 Ecdsa { d: Zeroizing<Vec<u8>>, point: Vec<u8> },
105 Dsa { p: Vec<u8>, q: Vec<u8>, g: Vec<u8>, y: Vec<u8>, x: Zeroizing<Vec<u8>> },
108struct Generated {
109 numbers: Numbers,
110 pkcs8_der: Zeroizing<Vec<u8>>,
113fn generate_pair(kind: &str, bits: u32) -> CoreResult<Generated> {
114 let failure = |e: String| CoreError::plain(format!("Key generation failed: {e}"));
115 match kind {
116 "ed25519" => {
117 let key = ed25519_dalek::SigningKey::generate(&mut OsRng);
118 let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;
119 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()) })
120 }
121 "rsa" | "rsa1" => {
122 use rsa::traits::{PrivateKeyParts, PublicKeyParts};
123 let mut key = rsa::RsaPrivateKey::new(&mut OsRng, bits as usize).map_err(|e| failure(e.to_string()))?;
124 key.precompute().map_err(|e| failure(e.to_string()))?;
125 let primes = key.primes();
126 let qi = key.crt_coefficient().ok_or_else(|| failure("missing CRT coefficient".into()))?;
127 let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;
128 Ok(Generated {
129 numbers: Numbers::Rsa {
130 n: key.n().to_bytes_be(),
131 e: key.e().to_bytes_be(),
132 d: Zeroizing::new(key.d().to_bytes_be()),
133 p: Zeroizing::new(primes[0].to_bytes_be()),
134 q: Zeroizing::new(primes[1].to_bytes_be()),
135 qi: Zeroizing::new(qi.to_bytes_be()),
136 },
137 pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()),
138 })
139 }
140 "ecdsa" => {
141 macro_rules! ec {
142 ($curve:ident) => {{
143 use $curve::elliptic_curve::sec1::ToEncodedPoint;
144 let secret = $curve::SecretKey::random(&mut OsRng);
145 let der = secret.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;
146 let point = secret.public_key().to_encoded_point(false).as_bytes().to_vec();
147 Generated { numbers: Numbers::Ecdsa { d: Zeroizing::new(secret.to_bytes().to_vec()), point }, pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()) }
148 }};
149 }
150 Ok(match bits { 384 => ec!(p384), 521 => ec!(p521), _ => ec!(p256) })
151 }
152 _ => {
153 #[allow(deprecated)] // DSA-1024 is the explicitly confirmed legacy option
154 let components = dsa::Components::generate(&mut OsRng, dsa::KeySize::DSA_1024_160);
155 let key = dsa::SigningKey::generate(&mut OsRng, components);
156 let der = key.to_pkcs8_der().map_err(|e| failure(e.to_string()))?;
157 let c = key.verifying_key().components();
158 Ok(Generated {
159 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()) },
160 pkcs8_der: Zeroizing::new(der.as_bytes().to_vec()),
161 })
162 }
163 }
166struct Material {
167 algorithm: String,
168 curve: Option<String>,
169 blob: Vec<u8>,
170 line: String,
173fn curve_names(bits: u32) -> (&'static str, &'static str) {
174 match bits { 384 => ("ecdsa-sha2-nistp384", "nistp384"), 521 => ("ecdsa-sha2-nistp521", "nistp521"), _ => ("ecdsa-sha2-nistp256", "nistp256") }
177fn public_material(kind: &str, bits: u32, numbers: &Numbers, comment: &str) -> Material {
178 let suffix = if comment.is_empty() { String::new() } else { format!(" {comment}") };
179 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 };
180 match numbers {
181 Numbers::Ed25519 { public, .. } => finish("ssh-ed25519", None, [ssh_string(b"ssh-ed25519"), ssh_string(public)].concat()),
182 Numbers::Rsa { n, e, .. } if kind == "rsa1" => {
183 let to_dec = |b: &[u8]| num_bigint_dig::BigUint::from_bytes_be(b).to_str_radix(10);
184 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)) }
185 }
186 Numbers::Rsa { n, e, .. } => finish("ssh-rsa", None, [ssh_string(b"ssh-rsa"), ssh_mpint(e), ssh_mpint(n)].concat()),
187 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()),
188 Numbers::Ecdsa { point, .. } => {
189 let (algorithm, curve) = curve_names(bits);
190 finish(algorithm, Some(curve), [ssh_string(algorithm.as_bytes()), ssh_string(curve.as_bytes()), ssh_string(point)].concat())
191 }
192 }
195fn openssh_private(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing<String> {
196 let mut check = [0u8; 4];
197 OsRng.fill(&mut check);
198 let algorithm = ssh_string(material.algorithm.as_bytes());
199 let fields: Vec<u8> = match numbers {
200 Numbers::Ed25519 { seed, public } => [algorithm, ssh_string(public), ssh_string(&[seed.as_slice(), public.as_slice()].concat())].concat(),
201 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(),
202 Numbers::Dsa { p, q, g, y, x } => [algorithm, ssh_mpint(p), ssh_mpint(q), ssh_mpint(g), ssh_mpint(y), ssh_mpint(x)].concat(),
203 Numbers::Ecdsa { d, point } => [algorithm, ssh_string(material.curve.as_deref().unwrap_or("").as_bytes()), ssh_string(point), ssh_mpint(d)].concat(),
204 };
205 let mut private = Zeroizing::new([check.as_slice(), check.as_slice(), &fields, &ssh_string(comment.as_bytes())].concat());
206 let padding = (8 - private.len() % 8) % 8;
207 for i in 1..=padding {
208 private.push(i as u8);
209 }
210 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());
211 Zeroizing::new(pem_block("OPENSSH PRIVATE KEY", &raw))
214fn ppk_v2(numbers: &Numbers, material: &Material, comment: &str) -> Zeroizing<String> {
215 let private: Zeroizing<Vec<u8>> = Zeroizing::new(match numbers {
216 // PuTTY stores the EdDSA private integer little-endian and unsigned (`put_mp_le_unsigned`);
217 // 0.1.0 wrote seed‖public here, which PuTTY rejects.
218 Numbers::Ed25519 { seed, .. } => {
219 let used = seed.iter().rposition(|b| *b != 0).map(|i| i + 1).unwrap_or(0);
220 ssh_string(&seed[..used])
221 }
222 Numbers::Rsa { d, p, q, qi, .. } => [ssh_mpint(d), ssh_mpint(p), ssh_mpint(q), ssh_mpint(qi)].concat(),
223 Numbers::Dsa { x, .. } => ssh_mpint(x),
224 Numbers::Ecdsa { d, .. } => ssh_mpint(d),
225 });
226 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());
227 let mac_key = Sha1::digest(b"putty-private-key-file-mac-key");
228 let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&mac_key).expect("hmac");
229 mac.update(&mac_data);
230 let tag = hex::encode(mac.finalize().into_bytes());
231 let lines = |data: &[u8]| -> Vec<String> {
232 let b64 = B64.encode(data);
233 if b64.is_empty() { vec![String::new()] } else { b64.as_bytes().chunks(64).map(|c| String::from_utf8_lossy(c).to_string()).collect() }
234 };
235 let (public_lines, private_lines) = (lines(&material.blob), Zeroizing::new(lines(&private)));
236 let mut out = vec![
237 format!("PuTTY-User-Key-File-2: {}", material.algorithm),
238 "Encryption: none".into(),
239 format!("Comment: {comment}"),
240 format!("Public-Lines: {}", public_lines.len()),
241 ];
242 out.extend(public_lines);
243 out.push(format!("Private-Lines: {}", private_lines.len()));
244 out.extend(private_lines.iter().cloned());
245 out.push(format!("Private-MAC: {tag}"));
246 Zeroizing::new(out.join("\n") + "\n")
249fn export_pem(pkcs8_der: &[u8], passphrase: &str) -> CoreResult<Zeroizing<String>> {
250 if passphrase.is_empty() {
251 return Ok(Zeroizing::new(pem_block("PRIVATE KEY", pkcs8_der)));
252 }
253 let mut salt = [0u8; 16];
254 let mut iv = [0u8; 16];
255 OsRng.fill(&mut salt);
256 OsRng.fill(&mut iv);
257 let params = pkcs5::pbes2::Parameters::pbkdf2_sha256_aes256cbc(2048, &salt, &iv).map_err(|e| CoreError::plain(e.to_string()))?;
258 let info = pkcs8::PrivateKeyInfo::try_from(pkcs8_der).map_err(|e| CoreError::plain(e.to_string()))?;
259 let encrypted = info.encrypt_with_params(params, passphrase.as_bytes()).map_err(|e| CoreError::plain(e.to_string()))?;
260 Ok(Zeroizing::new(pem_block("ENCRYPTED PRIVATE KEY", encrypted.as_bytes())))
263fn choose_format(input: &Input) -> CoreResult<(String, String)> {
264 if input.kind == "rsa1" {
265 if !input.passphrase.is_empty() {
266 return Err(CoreError::plain("SSH1 RSA PPK export cannot be protected by a passphrase. Remove it or use a modern key type."));
267 }
268 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()));
269 }
270 if !input.passphrase.is_empty() && input.format != "pem" {
271 let name = if input.format == "ppk" { "PPK v2" } else { "OpenSSH" };
272 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.")));
273 }
274 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() }))
277pub fn generate(payload: &Value) -> CoreResult<Value> {
278 let input = normalize(payload)?;
279 if input.kind=="mldsa44-ed25519" { return generate_composite_ssh(&input); }
280 let generated = generate_pair(&input.kind, input.bits)?;
281 let material = public_material(&input.kind, input.bits, &generated.numbers, &input.comment);
282 let canonical_public = public_material(if input.kind == "rsa1" { "rsa" } else { &input.kind }, input.bits, &generated.numbers, &input.comment).line;
283 let pem = export_pem(&generated.pkcs8_der, &input.passphrase)?;
284 let (format, notice) = choose_format(&input)?;
285 let openssh = if format == "openssh" { openssh_private(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) };
286 let ppk = if format == "ppk" { ppk_v2(&generated.numbers, &material, &input.comment) } else { Zeroizing::new(String::new()) };
287 let private = match format.as_str() { "openssh" => openssh.as_str(), "ppk" => ppk.as_str(), _ => pem.as_str() };
288 let base = match input.kind.as_str() { "ed25519" => "id_ed25519", "rsa" => "id_rsa", "ecdsa" => "id_ecdsa", "dsa" => "id_dsa", _ => "id_rsa1" };
289 Ok(json!({
290 "generationId": uuid::Uuid::new_v4().to_string(),
291 "type": input.kind,
292 "bits": input.bits,
293 "privateKeyFormat": format,
294 "privateKey": private,
295 "privateKeyPem": pem.as_str(),
296 "privateKeyOpenSSH": openssh.as_str(),
297 "privateKeyPpk": ppk.as_str(),
298 "publicKey": material.line,
299 "publicKeyCanonical": canonical_public,
300 "publicKeyOpenSsh": material.line,
301 "suggestedName": if format == "ppk" { format!("{base}.ppk") } else { base.to_string() },
302 "legacy": input.kind == "dsa" || input.kind == "rsa1",
303 "notice": notice
304 }))
307fn generate_composite_ssh(input:&Input)->CoreResult<Value> {
308 use russh::keys::ssh_key::{PrivateKey,private::KeypairData,Kdf,Cipher};
309 use rand::RngCore;
310 if input.format!="openssh" {return Err(CoreError::plain("Hybrid SSH keys require the OpenSSH private-key format."));}
311 let mut seeds=Zeroizing::new([0u8;64]);OsRng.fill_bytes(&mut *seeds);
312 let pair=russh::keys::ssh_key::composite::from_seeds(*seeds).map_err(|_|CoreError::plain("Hybrid SSH key generation failed."))?;
313 let key=PrivateKey::new(KeypairData::Other(pair),input.comment.as_str()).map_err(|_|CoreError::plain("Hybrid SSH key generation failed."))?;
314 let public=key.public_key().to_openssh().map_err(|_|CoreError::plain("Hybrid SSH public export failed."))?;
315 let key=if input.passphrase.is_empty(){key}else{
316 let mut salt=vec![0u8;16];OsRng.fill_bytes(&mut salt);
317 key.encrypt_with(Cipher::Aes256Ctr,Kdf::Bcrypt{salt,rounds:24},OsRng.next_u32(),input.passphrase.as_bytes()).map_err(|_|CoreError::plain("Hybrid SSH key protection failed."))?
318 };
319 let private=Zeroizing::new(key.to_openssh(Default::default()).map_err(|_|CoreError::plain("Hybrid SSH private export failed."))?.to_string());
320 Ok(json!({"generationId":uuid::Uuid::new_v4().to_string(),"type":"mldsa44-ed25519","algorithm":"ssh-mldsa44-ed25519","postQuantum":true,"hybrid":true,"bits":0,"privateKeyFormat":"openssh","privateKey":private.as_str(),"privateKeyPem":"","privateKeyOpenSSH":private.as_str(),"privateKeyPpk":"","publicKey":public,"publicKeyCanonical":public,"publicKeyOpenSsh":public,"suggestedName":"id_mldsa44_ed25519","publicKeyFormat":"openssh","privateFileName":"id_mldsa44_ed25519","publicFileName":"id_mldsa44_ed25519.pub","legacy":false,"notice":""}))
323pub fn generate_password(options: &Value) -> CoreResult<Value> {
324 let length = options.get("length").and_then(Value::as_f64).filter(|l| l.fract() == 0.0).map(|l| l as usize);
325 let Some(length) = length.filter(|l| (PASSWORD_MIN..=PASSWORD_MAX).contains(l)) else {
326 return Err(CoreError::plain(format!("Password length must be {PASSWORD_MIN}-{PASSWORD_MAX} characters.")));
327 };
328 let grouped = options.get("grouped") == Some(&Value::Bool(true));
329 let sets: Vec<Vec<char>> = PASSWORD_SETS
330 .iter()
331 .filter(|(key, _)| options.get(*key) == Some(&Value::Bool(true)))
332 .map(|(_, chars)| chars.chars().filter(|c| !(grouped && *c == '-')).collect::<Vec<_>>())
333 .filter(|set| !set.is_empty())
334 .collect();
335 if sets.is_empty() {
336 return Err(CoreError::plain("Select at least one character set."));
337 }
338 let mut pool: Vec<char> = Vec::new();
339 for c in sets.iter().flatten() {
340 if !pool.contains(c) {
341 pool.push(*c);
342 }
343 }
344 let separator = |index: usize| grouped && (index + 1) % PASSWORD_GROUP == 0;
345 let slots = (0..length).filter(|i| !separator(*i)).count();
346 if slots < sets.len() {
347 return Err(CoreError::plain(format!("The length leaves {slots} random characters, but {} character sets are selected.", sets.len())));
348 }
349 for _ in 0..10_000 {
350 let chars: Vec<char> = (0..slots).map(|_| pool[OsRng.gen_range(0..pool.len())]).collect();
351 if !sets.iter().all(|set| chars.iter().any(|c| set.contains(c))) {
352 continue;
353 }
354 let mut next = 0;
355 let password: String = (0..length)
356 .map(|i| {
357 if separator(i) {
358 '-'
359 } else {
360 next += 1;
361 chars[next - 1]
362 }
363 })
364 .collect();
365 return Ok(json!({ "password": password, "length": length, "randomCharacters": slots, "poolSize": pool.len(), "entropyBits": (slots as f64 * (pool.len() as f64).log2()).floor() as u64 }));
366 }
367 Err(CoreError::plain("The password could not be generated with these settings."))
370struct Entry<T> {
371 owner: String,
372 id: String,
373 value: T,
374 created: Instant,
377/// Bounded in-memory caches for save/copy (never persisted, never logged).
378#[derive(Default)]
379pub struct KeyGeneratorService {
380 generations: Mutex<VecDeque<Entry<Value>>>,
381 passwords: Mutex<VecDeque<Entry<Zeroizing<String>>>>,
382 owners: Arc<Mutex<HashMap<String, u64>>>,
383 progress: Arc<Mutex<HashMap<String, Value>>>,
384 edits: Mutex<VecDeque<Entry<Value>>>,
385 mode: Mutex<(u64, String)>,
388/// Owner-scoped progress. Drop clears busy even on input errors or worker panics.
389pub struct GenerationJob {
390 owner:String, request:String, epoch:u64, started:Instant,
391 owners:Arc<Mutex<HashMap<String,u64>>>,progress:Arc<Mutex<HashMap<String,Value>>>,
393impl GenerationJob {
394 pub fn step(&self,phase:&str)->CoreResult<()> {
395 let owners=self.owners.lock();
396 if owners.get(&self.owner).copied()!=Some(self.epoch) {return Err(CoreError::plain("Key operation cancelled."));}
397 self.progress.lock().insert(self.owner.clone(),json!({"requestId":self.request,"phase":phase,"running":true,"elapsedMs":self.started.elapsed().as_millis() as u64}));
398 Ok(())
399 }
401impl Drop for GenerationJob {
402 fn drop(&mut self) {
403 let owners=self.owners.lock();
404 if owners.get(&self.owner).copied()==Some(self.epoch) {
405 if let Some(state)=self.progress.lock().get_mut(&self.owner) {if state["requestId"]==self.request {state["running"]=json!(false);state["elapsedMs"]=json!(self.started.elapsed().as_millis() as u64);}}
406 }
407 }
409impl KeyGeneratorService {
410 pub async fn generate_owned(&self, owner: &str, payload: &Value) -> CoreResult<Value> {
411 let job = self.begin_operation(owner, payload)?;
412 let epoch = job.epoch;
413 let payload = payload.clone();
414 let (output, identity) = tokio::task::spawn_blocking(move || {
415 if payload["family"] == "openpgp" { return crate::core::openpgp::generate_progress(&payload, &|phase|job.step(phase)); }
416 job.step("primary")?;
417 let mut output = generate(&payload)?;
418 // Derive identity only from the public half of the Core-generated pair.
419 // This also supports legacy private formats that the SSH signing parser
420 // cannot load (DSA PKCS#8 / SSH1 PPK) without converting the export.
421 let mut identity = crate::core::keychain::parse(output["publicKeyCanonical"].as_str().unwrap_or("").as_bytes(), payload["comment"].as_str().unwrap_or(""), "")?;
422 identity["private"] = output["privateKey"].clone();
423 identity["publicExport"] = output["publicKey"].clone();
424 identity["signSupported"] = json!(output["type"] != "rsa1" && crate::core::ssh::keys::decode_private_key(output["privateKey"].as_str().unwrap_or(""),payload["passphrase"].as_str().filter(|s| !s.is_empty())).is_ok());
425 identity["type"] = output["type"].clone(); identity["bits"] = output["bits"].clone();
426 identity["privateKeyFormat"] = output["privateKeyFormat"].clone();
427 output["family"] = json!("ssh");
428 output["files"] = json!([
429 {"name":output["suggestedName"],"kind":"private","sensitive":true,"content":output["privateKey"]},
430 {"name":format!("{}.pub",output["suggestedName"].as_str().unwrap_or("id_key")),"kind":"public","sensitive":false,"content":output["publicKey"]}
431 ]);
432 Ok::<_, CoreError>((output,identity))
433 }).await.map_err(|_| CoreError::plain("Key generation failed."))??;
434 let owners = self.owners.lock();
435 if owners.get(owner).copied() != Some(epoch) { return Err(CoreError::plain("Key generation cancelled.")); }
436 let mut list = self.generations.lock();
437 list.retain(|e| e.created.elapsed() <= TTL);
438 list.push_back(Entry { owner:owner.into(), id: output["generationId"].as_str().unwrap_or("").to_string(), value: json!({"output":output,"identity":identity}), created: Instant::now() });
439 while list.len() > 20 { list.pop_front(); }
440 Ok(list.back().unwrap().value["output"].clone())
441 }
443 pub fn begin_operation(&self,owner:&str,payload:&Value)->CoreResult<GenerationJob> {
444 let mut owners=self.owners.lock();
445 let epoch=*owners.entry(owner.to_string()).or_insert(0);
446 let mut states=self.progress.lock();
447 if states.get(owner).is_some_and(|s|s["running"]==true) {return Err(CoreError::plain("Another key operation is still running."));}
448 let request=payload["requestId"].as_str().filter(|s|s.len()<=80).unwrap_or("").to_string();
449 states.insert(owner.into(),json!({"requestId":request,"phase":"prepare","running":true,"elapsedMs":0}));
450 Ok(GenerationJob{owner:owner.into(),request,epoch,started:Instant::now(),owners:self.owners.clone(),progress:self.progress.clone()})
451 }
452 pub fn state_owned(&self,owner:&str)->Value {
453 let mut state=self.mode();
454 state["openpgpAlgorithms"]=super::openpgp_algorithms::catalog();
455 state["progress"]=self.progress.lock().get(owner).cloned().unwrap_or(Value::Null);
456 let mut edits=self.edits.lock(); edits.retain(|e|e.created.elapsed()<=TTL);
457 state["editContext"]=edits.iter().rev().find(|e|e.owner==owner).map(|e|json!({"token":e.id,"identity":e.value["identity"]})).unwrap_or(Value::Null);
458 state
459 }
460 pub fn clear_edit(&self,owner:&str) {self.edits.lock().retain(|e|e.owner!=owner);}
461 pub fn select_edit(&self,owner:&str,entry:&Value,generation:u64)->Value {
462 let token=uuid::Uuid::new_v4().to_string();
463 let value=json!({"id":entry["id"],"public":entry["public"],"identity":crate::core::keychain::describe(entry),"vaultGeneration":generation});
464 let mut edits=self.edits.lock();edits.retain(|e|e.owner!=owner && e.created.elapsed()<=TTL);
465 edits.push_back(Entry{owner:owner.into(),id:token.clone(),value,created:Instant::now()});
466 while edits.len()>20 {edits.pop_front();}
467 json!({"token":token})
468 }
469 pub fn edit_context(&self,owner:&str,token:&str)->CoreResult<Value> {
470 self.edits.lock().iter().find(|e|e.owner==owner && e.id==token && e.created.elapsed()<=TTL).map(|e|e.value.clone()).ok_or_else(||CoreError::plain("Key editing expired. Open it again from Keychain."))
471 }
472 pub fn with_edit_context<T>(&self,owner:&str,token:&str,expected:&Value,commit:impl FnOnce()->CoreResult<T>)->CoreResult<T> {
473 // Same lock order as cleanup_module. Hold authorization through the final
474 // vault commit so a close or changed selection cannot slip between them.
475 let _owners=self.owners.lock();let edits=self.edits.lock();
476 if !edits.iter().any(|e|e.owner==owner && e.id==token && e.created.elapsed()<=TTL && e.value==*expected) {return Err(CoreError::plain("Key selection changed or editing expired."));}
477 commit()
478 }
479 pub fn refresh_edit(&self,owner:&str,token:&str,entry:&Value) {
480 if let Some(e)=self.edits.lock().iter_mut().find(|e|e.owner==owner && e.id==token) {e.value["public"]=entry["public"].clone();e.value["identity"]=crate::core::keychain::describe(entry);}
481 }
482 pub async fn edit_generated(&self,owner:&str,payload:&Value)->CoreResult<Value> {
483 let id=payload["generationId"].as_str().unwrap_or("");
484 let before=self.cached(owner,id)?;
485 if before["output"]["family"]!="openpgp" {return Err(CoreError::plain("Choose an OpenPGP identity."));}
486 let job=self.begin_operation(owner,payload)?;let epoch=job.epoch;
487 let entry=before["identity"].clone();let p=payload.clone();
488 let next=tokio::task::spawn_blocking(move ||crate::core::openpgp::edit(&entry,&p,&|phase|job.step(phase))).await.map_err(|_|CoreError::plain("Key editing failed."))??;
489 let output=crate::core::openpgp::artifacts(&next,id,before["output"]["files"][2]["content"].as_str().unwrap_or(""));
490 let owners=self.owners.lock();if owners.get(owner).copied()!=Some(epoch) {return Err(CoreError::plain("Key editing cancelled."));}
491 let mut cache=self.generations.lock();
492 let current=cache.iter_mut().find(|e|e.owner==owner && e.id==id && e.created.elapsed()<=TTL).ok_or_else(||CoreError::plain("Generated key expired."))?;
493 if current.value!=before {return Err(CoreError::plain("Key changed during editing. Try again."));}
494 current.value=json!({"output":output,"identity":next});
495 Ok(output)
496 }
497 fn cached(&self, owner: &str, id: &str) -> CoreResult<Value> {
498 let list = self.generations.lock();
499 list.iter().find(|e| e.owner == owner && e.id == id && e.created.elapsed() <= TTL).map(|e| e.value.clone()).ok_or_else(|| CoreError::plain("Generated key expired. Generate it again."))
500 }
501 pub fn generated_owned(&self, owner: &str, id: &str) -> CoreResult<Value> { Ok(self.cached(owner,id)?["output"].clone()) }
502 pub fn identity_owned(&self, owner: &str, id: &str) -> CoreResult<Value> { Ok(self.cached(owner,id)?["identity"].clone()) }
503 pub fn select_mode(&self, mode: &str) -> CoreResult<Value> {
504 if !["ssh","openpgp","password"].contains(&mode) { return Err(CoreError::plain("Unknown generator mode.")); }
505 let mut state = self.mode.lock(); state.0 += 1; state.1 = mode.into();
506 Ok(json!({"revision":state.0,"mode":state.1}))
507 }
508 pub fn mode(&self) -> Value {
509 let state = self.mode.lock(); json!({"revision":state.0,"mode":if state.1.is_empty() {"ssh"} else {state.1.as_str()}})
510 }
511 pub fn cleanup_module(&self, module_id: &str) {
512 let prefix = format!("{module_id}:");
513 let mut owners = self.owners.lock();
514 for (owner,epoch) in owners.iter_mut().filter(|(owner,_)| owner.starts_with(&prefix)) { let _ = owner; *epoch += 1; }
515 self.generations.lock().retain(|e| !e.owner.starts_with(&prefix));
516 self.passwords.lock().retain(|e| !e.owner.starts_with(&prefix));
517 self.progress.lock().retain(|owner,_| !owner.starts_with(&prefix));
518 self.edits.lock().retain(|e| !e.owner.starts_with(&prefix));
519 }
521 pub fn generate_password_owned(&self, owner: &str, options: &Value) -> CoreResult<Value> {
522 let mut result = generate_password(options)?;
523 let id = uuid::Uuid::new_v4().to_string();
524 let mut list = self.passwords.lock();
525 list.push_back(Entry { owner:owner.into(), id: id.clone(), value: Zeroizing::new(result["password"].as_str().unwrap_or("").to_string()), created: Instant::now() });
526 while list.len() > 20 {
527 list.pop_front();
528 }
529 result["passwordId"] = json!(id);
530 Ok(result)
531 }
533 pub fn password_owned(&self, owner: &str, id: &str) -> CoreResult<Zeroizing<String>> {
534 let mut list = self.passwords.lock();
535 match list.iter().position(|e| e.owner == owner && e.id == id) {
536 Some(index) if list[index].created.elapsed() <= TTL => Ok(list[index].value.clone()),
537 Some(index) => {
538 list.remove(index);
539 Err(CoreError::plain("Generated password expired. Generate it again."))
540 }
541 None => Err(CoreError::plain("Generated password expired. Generate it again.")),
542 }
543 }
546/// Writes private (0600) and public (0644) key files.
547pub fn save_pair(private_path: &std::path::Path, generated: &Value) -> CoreResult<Value> {
548 let public_path = std::path::PathBuf::from(format!("{}.pub", private_path.to_string_lossy()));
549 let write = |path: &std::path::Path, content: &str, mode: u32| -> CoreResult<()> {
550 use std::io::Write;
551 let mut options = std::fs::OpenOptions::new();
552 options.write(true).create(true).truncate(true);
553 #[cfg(unix)]
554 {
555 use std::os::unix::fs::OpenOptionsExt;
556 options.mode(mode);
557 }
558 options.open(path)?.write_all(content.as_bytes())?;
559 #[cfg(unix)]
560 {
561 use std::os::unix::fs::PermissionsExt;
562 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
563 }
564 let _ = mode;
565 Ok(())
566 };
567 write(private_path, generated["privateKey"].as_str().unwrap_or(""), 0o600)?;
568 write(&public_path, generated["publicKey"].as_str().unwrap_or(""), 0o644)?;
569 Ok(json!({ "canceled": false, "privatePath": private_path.to_string_lossy(), "publicPath": public_path.to_string_lossy(), "privateKeyFormat": generated["privateKeyFormat"] }))
572#[cfg(test)]
573mod tests {
574 use super::*;
576 #[tokio::test]
577 async fn progress_and_edits_remain_owner_scoped_and_closing_cancels_work() {
578 let service=KeyGeneratorService::default();let owner="chj.key-generator:fixture";
579 let job=service.begin_operation(owner,&json!({"requestId":"fixture-progress"})).unwrap();job.step("primary").unwrap();
580 assert_eq!(service.state_owned(owner)["progress"]["phase"],"primary");
581 assert!(service.state_owned("foreign:window")["progress"].is_null());assert!(service.begin_operation(owner,&json!({})).is_err());
582 service.cleanup_module("chj.key-generator");assert!(job.step("protect").is_err());drop(job);assert!(service.state_owned(owner)["progress"].is_null());
583 let output=service.generate_owned(owner,&json!({"family":"openpgp","name":"Editable generated fixture","subkeys":[]})).await.unwrap();
584 let id=output["generationId"].as_str().unwrap();
585 assert!(service.edit_generated("foreign:window",&json!({"generationId":id,"operation":"addSubkey","subkey":{"role":"sign"}})).await.is_err());
586 let edited=service.edit_generated(owner,&json!({"generationId":id,"operation":"addSubkey","subkey":{"role":"sign"}})).await.unwrap();
587 assert_eq!(edited["generationId"],id);assert_eq!(edited["identity"]["fingerprint"],output["identity"]["fingerprint"]);assert_eq!(edited["identity"]["subkeys"].as_array().unwrap().len(),1);
588 assert_eq!(service.generated_owned(owner,id).unwrap(),edited);assert_eq!(service.state_owned(owner)["progress"]["running"],false);
589 let identity=service.identity_owned(owner,id).unwrap();let edit=service.select_edit(owner,&identity,42);
590 assert!(service.edit_context("foreign:window",edit["token"].as_str().unwrap()).is_err());
591 let state=service.state_owned(owner);assert!(!state["editContext"]["identity"].as_object().unwrap().contains_key("private"));
592 let context=service.edit_context(owner,edit["token"].as_str().unwrap()).unwrap();
593 assert!(service.with_edit_context(owner,edit["token"].as_str().unwrap(),&context,||Ok(())).is_ok());
594 service.select_edit(owner,&identity,42);
595 assert!(service.with_edit_context(owner,edit["token"].as_str().unwrap(),&context,||Ok(())).is_err());
596 service.cleanup_module("chj.key-generator");assert!(service.edit_context(owner,edit["token"].as_str().unwrap()).is_err());
597 }
598 #[tokio::test]
599 async fn every_generated_ssh_type_is_owned_exportable_and_can_be_stored_in_keychain() {
600 let owner = "chj.key-generator:window-fixture"; let service = KeyGeneratorService::default();
601 let root = tempfile::tempdir().unwrap(); let vault = crate::core::vault::VaultStore::new(root.path()); vault.create("fixture-vault-password").await.unwrap();
602 for (kind,bits) in [("ed25519",0),("rsa",2048),("ecdsa",256),("ecdsa",384),("ecdsa",521),("dsa",1024),("rsa1",1024),("mldsa44-ed25519",0)] {
603 let result = service.generate_owned(owner,&json!({"type":kind,"bits":bits,"privateKeyFormat":"openssh","legacyConfirmed":true})).await.unwrap_or_else(|error| panic!("generation failed for {kind}/{bits}: {}",error.code));
604 let id = result["generationId"].as_str().unwrap();
605 assert!(service.generated_owned("another:window",id).is_err());
606 assert!(service.identity_owned("another:window",id).is_err());
607 let entry = service.identity_owned(owner,id).unwrap();
608 assert_eq!(entry["private"],result["privateKey"]);
609 crate::core::keychain::store(&vault,entry).unwrap();
610 }
611 assert_eq!(crate::core::keychain::list(&vault).unwrap().as_array().unwrap().len(),8);
612 service.cleanup_module("chj.key-generator");
613 assert!(service.generations.lock().is_empty());
614 }
616 #[test]
617 fn ed25519_openssh_is_parseable() {
618 let key = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "comment": "test@chj"})).unwrap();
619 let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap();
620 assert_eq!(parsed.public_key().to_openssh().unwrap(), key["publicKey"].as_str().unwrap());
621 }
623 #[test]
624 fn ecdsa_and_ppk_and_encrypted_pem() {
625 for bits in [256, 384, 521] {
626 let key = generate(&json!({"type": "ecdsa", "bits": bits, "privateKeyFormat": "openssh"})).unwrap();
627 let parsed = russh::keys::ssh_key::PrivateKey::from_openssh(key["privateKey"].as_str().unwrap()).unwrap();
628 assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1));
629 }
630 for (kind, bits) in [("rsa", 2048), ("ecdsa", 256), ("ecdsa", 521)] {
631 let ppk = generate(&json!({"type": kind, "bits": bits, "privateKeyFormat": "ppk", "comment": "c"})).unwrap();
632 let parsed = russh::keys::decode_secret_key(ppk["privateKey"].as_str().unwrap(), None).unwrap();
633 assert_eq!(parsed.public_key().to_openssh().unwrap().split(' ').nth(1), ppk["publicKey"].as_str().unwrap().split(' ').nth(1));
634 }
635 let encrypted = generate(&json!({"type": "ed25519", "privateKeyFormat": "openssh", "passphrase": "geheim"})).unwrap();
636 assert_eq!(encrypted["privateKeyFormat"], "pem");
637 assert!(encrypted["privateKey"].as_str().unwrap().starts_with("-----BEGIN ENCRYPTED PRIVATE KEY-----"));
638 assert!(russh::keys::decode_secret_key(encrypted["privateKey"].as_str().unwrap(), Some("geheim")).is_ok());
639 }
641 /// Ed25519 PPK: MAC and the little-endian private integer as PuTTY writes it.
642 #[test]
643 fn ed25519_ppk_matches_putty_layout() {
644 let ppk = generate(&json!({"type": "ed25519", "privateKeyFormat": "ppk", "comment": "k"})).unwrap();
645 let text = ppk["privateKey"].as_str().unwrap();
646 let lines: Vec<&str> = text.lines().collect();
647 assert_eq!(lines[0], "PuTTY-User-Key-File-2: ssh-ed25519");
648 let public_count: usize = lines[3].strip_prefix("Public-Lines: ").unwrap().parse().unwrap();
649 let public = B64.decode(lines[4..4 + public_count].concat()).unwrap();
650 let at = 4 + public_count;
651 assert_eq!(lines[at], "Private-Lines: 1");
652 let private = B64.decode(lines[at + 1]).unwrap();
653 let len = u32::from_be_bytes(private[..4].try_into().unwrap()) as usize;
654 assert!(len <= 32 && private.len() == 4 + len);
655 let mut seed = [0u8; 32];
656 seed[..len].copy_from_slice(&private[4..]);
657 let derived = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();
658 assert_eq!(&public[public.len() - 32..], derived.as_slice());
659 let mac_data = [ssh_string(b"ssh-ed25519"), ssh_string(b"none"), ssh_string(b"k"), ssh_string(&public), ssh_string(&private)].concat();
660 let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&Sha1::digest(b"putty-private-key-file-mac-key")).unwrap();
661 mac.update(&mac_data);
662 assert_eq!(lines[at + 2], format!("Private-MAC: {}", hex::encode(mac.finalize().into_bytes())));
663 }
665 #[test]
666 fn legacy_requires_confirmation_and_dsa_works() {
667 assert!(generate(&json!({"type": "dsa"})).is_err());
668 let dsa = generate(&json!({"type": "dsa", "legacyConfirmed": true, "privateKeyFormat": "openssh"})).unwrap();
669 assert!(dsa["publicKey"].as_str().unwrap().starts_with("ssh-dss "));
670 assert_eq!(dsa["legacy"], true);
671 let rsa1 = generate(&json!({"type": "rsa1", "legacyConfirmed": true})).unwrap();
672 assert!(rsa1["publicKey"].as_str().unwrap().starts_with("1024 65537 "));
673 assert_eq!(rsa1["privateKeyFormat"], "ppk");
674 }
676 /// Independent check with OpenSSH's ssh-keygen (skipped when it is not installed).
677 #[test]
678 fn ssh_keygen_reads_generated_keys() {
679 // ssh-keygen may wait on the console instead of failing (Windows OpenSSH); never wait forever.
680 fn ssh_keygen(args: &[&std::ffi::OsStr]) -> std::io::Result<std::process::Output> {
681 let mut child = std::process::Command::new("ssh-keygen")
682 .args(args)
683 .stdin(std::process::Stdio::null())
684 .stdout(std::process::Stdio::piped())
685 .stderr(std::process::Stdio::piped())
686 .spawn()?;
687 let started = std::time::Instant::now();
688 while child.try_wait()?.is_none() {
689 if started.elapsed() > std::time::Duration::from_secs(20) {
690 let _ = child.kill();
691 panic!("ssh-keygen {args:?} did not finish within 20 s");
692 }
693 std::thread::sleep(std::time::Duration::from_millis(50));
694 }
695 child.wait_with_output()
696 }
698 let dir = tempfile::tempdir().unwrap();
699 let cases = [
700 json!({"type": "ed25519", "privateKeyFormat": "openssh"}),
701 // (OpenSSH cannot read Ed25519 PKCS#8 at all — same as 0.1.0; covered via russh above.)
702 json!({"type": "rsa", "bits": 3072, "privateKeyFormat": "pem", "passphrase": "pw-test"}),
703 json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "openssh"}),
704 json!({"type": "rsa", "bits": 2048, "privateKeyFormat": "pem"}),
705 json!({"type": "ecdsa", "bits": 384, "privateKeyFormat": "openssh"}),
706 json!({"type": "ecdsa", "bits": 521, "privateKeyFormat": "pem", "passphrase": "pw-test"}),
707 ];
708 for (index, case) in cases.iter().enumerate() {
709 let key = generate(case).unwrap();
710 let path = dir.path().join(format!("k{index}"));
711 save_pair(&path, &key).unwrap();
712 let passphrase = case["passphrase"].as_str().unwrap_or("");
713 // (No "ssh-keygen -?" probe: Windows OpenSSH never returns from it.)
714 let output = match ssh_keygen(&["-y".as_ref(), "-P".as_ref(), passphrase.as_ref(), "-f".as_ref(), path.as_os_str()]) {
715 Ok(output) => output,
716 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
717 eprintln!("SKIPPED: ssh-keygen not available");
718 return;
719 }
720 Err(error) => panic!("{case}: {error}"),
721 };
722 assert!(output.status.success(), "{case}: {}", String::from_utf8_lossy(&output.stderr));
723 let derived = String::from_utf8_lossy(&output.stdout);
724 assert_eq!(derived.trim().split(' ').nth(1), key["publicKey"].as_str().unwrap().split(' ').nth(1), "{case}");
725 #[cfg(unix)]
726 {
727 use std::os::unix::fs::PermissionsExt;
728 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
729 }
730 }
731 }
733 #[test]
734 fn passwords() {
735 let result = generate_password(&json!({"length": 24, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).unwrap();
736 let password = result["password"].as_str().unwrap();
737 assert_eq!(password.chars().count(), 24);
738 for (i, c) in password.chars().enumerate() {
739 if (i + 1) % 5 == 0 {
740 assert_eq!(c, '-');
741 } else {
742 assert_ne!(c, '-');
743 assert_ne!(c, '0');
744 }
745 }
746 assert!(generate_password(&json!({"length": 3, "upper": true})).is_err());
747 assert!(generate_password(&json!({"length": 10})).is_err());
748 assert!(generate_password(&json!({"length": 5, "upper": true, "lower": true, "digits": true, "special": true, "grouped": true})).is_ok());
749 }
752/// Generated bundle names are produced by the Core; renderers choose only a token/index.
753pub fn save_artifact(path: &std::path::Path, generated: &Value, index: usize) -> CoreResult<Value> {
754 let file = generated["files"].as_array().and_then(|files| files.get(index)).ok_or_else(|| CoreError::plain("Generated file was not found."))?;
755 let content = file["content"].as_str().ok_or_else(|| CoreError::plain("Generated file was not found."))?;
756 crate::core::storage::atomic_write_file(path, content.as_bytes(), if file["sensitive"] == true {0o600} else {0o644})?;
757 Ok(json!({"saved":true}))
759pub fn save_bundle(directory: &std::path::Path, generated: &Value) -> CoreResult<Value> {
760 use std::io::Write;
761 let files = generated["files"].as_array().ok_or_else(|| CoreError::plain("Generated files were not found."))?;
762 for file in files {
763 let name = file["name"].as_str().unwrap_or("");
764 if name.is_empty() || name.contains(['/', '\\']) || directory.join(name).exists() { return Err(CoreError::plain("Choose another folder: a generated filename already exists or is invalid.")); }
765 }
766 for file in files {
767 let path = directory.join(file["name"].as_str().unwrap());
768 let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true);
769 #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(if file["sensitive"] == true {0o600} else {0o644}); }
770 let mut handle = options.open(path)?; handle.write_all(file["content"].as_str().unwrap_or("").as_bytes())?; handle.sync_all()?;
771 }
772 Ok(json!({"saved":true,"count":files.len()}))
775/// OpenPGP algorithm catalog for the main Keychain view (adding subkeys).
776pub fn openpgp_algorithm_catalog() -> serde_json::Value { super::openpgp_algorithms::catalog() }
SHA-25677da389848a46ab81888c0661ad7e0563d663dbfb6b842d31b5cd2deefa10b54

CH-J Proprietary Software License 1.14 · Kilden leveres under CH-J Proprietary Software License 1.14. Dens tilgængelighed ændrer ikke licensbetingelserne eller giver yderligere rettigheder.

Arkiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Filer