已發布的原始碼
下載原始碼 ZIP
CH-J Tools
瀏覽特定應用程式版本的目錄與檔案。
CH-J / Server Manager
1704 個檔案
0.1.2-beta
新增時間: · 建置時間:
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::{HashMap, VecDeque};19
use std::time::{Duration, Instant};20
use std::sync::Arc;21
use zeroize::Zeroizing;23
const TTL: Duration = Duration::from_secs(10 * 60);24
pub const PASSWORD_SETS: [(&str, &str); 4] = [("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"), ("lower", "abcdefghijklmnopqrstuvwxyz"), ("digits", "123456789"), ("special", "-@)\u{a8}\u{a7}'=+")];25
pub const PASSWORD_MIN: usize = 4;26
pub const PASSWORD_MAX: usize = 70;27
const PASSWORD_GROUP: usize = 5;29
#[derive(Debug, Clone)]30
pub struct Input {31
pub kind: String,32
pub bits: u32,33
pub passphrase: Zeroizing<String>,34
pub format: String,35
pub comment: String,36
}38
pub 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 })60
}62
// --- SSH wire helpers --------------------------------------------------------------------------64
fn ssh_u32(v: usize) -> [u8; 4] {65
(v as u32).to_be_bytes()66
}68
fn ssh_string(data: &[u8]) -> Vec<u8> {69
let mut out = ssh_u32(data.len()).to_vec();70
out.extend_from_slice(data);71
out72
}74
fn strip_zeros(data: &[u8]) -> &[u8] {75
let mut d = data;76
while d.len() > 1 && d[0] == 0 {77
d = &d[1..];78
}79
d80
}82
fn 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)93
}95
fn 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"))99
}101
enum 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>> },106
}108
struct Generated {109
numbers: Numbers,110
pkcs8_der: Zeroizing<Vec<u8>>,111
}113
fn 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 option154
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
}164
}166
struct Material {167
algorithm: String,168
curve: Option<String>,169
blob: Vec<u8>,170
line: String,171
}173
fn 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") }175
}177
fn 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
}193
}195
fn 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))212
}214
fn 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")247
}249
fn 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())))261
}263
fn 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() }))275
}277
pub 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": notice304
}))305
}307
fn 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":""}))321
}323
pub 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_SETS330
.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."))368
}370
struct Entry<T> {371
owner: String,372
id: String,373
value: T,374
created: Instant,375
}377
/// Bounded in-memory caches for save/copy (never persisted, never logged).378
#[derive(Default)]379
pub 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)>,386
}388
/// Owner-scoped progress. Drop clears busy even on input errors or worker panics.389
pub struct GenerationJob {390
owner:String, request:String, epoch:u64, started:Instant,391
owners:Arc<Mutex<HashMap<String,u64>>>,progress:Arc<Mutex<HashMap<String,Value>>>,392
}393
impl 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
}400
}401
impl 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
}408
}409
impl 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 parser420
// 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
state459
}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 final474
// 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
}544
}546
/// Writes private (0600) and public (0644) key files.547
pub 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"] }))570
}572
#[cfg(test)]573
mod 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
}750
}752
/// Generated bundle names are produced by the Core; renderers choose only a token/index.753
pub 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}))758
}759
pub 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()}))773
}775
/// OpenPGP algorithm catalog for the main Keychain view (adding subkeys).776
pub fn openpgp_algorithm_catalog() -> serde_json::Value { super::openpgp_algorithms::catalog() }SHA-256
77da389848a46ab81888c0661ad7e0563d663dbfb6b842d31b5cd2deefa10b54CH-J Proprietary Software License 1.14 · 原始碼依 CH-J Proprietary Software License 1.14 提供。公開原始碼不會改變授權條款,也不會授予額外的權利。
壓縮檔 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f