CH-J Toolsbainisteoireacht freastalaí SSH
Roghchlár
Foinse foilsithe

CH-J Tools

Brabhsáil comhadlanna agus comhaid chun feidhmchlár áirithe a scaoileadh.

Íosluchtaigh foinse ZIP
CH-J / Server Manager 1704 Comhaid
0.1.2-beta Suimigh: · Tógtha:
keychain.rs Suimigh: Dáta gan taifeadadh 37.7 KB · 774 líne
Cód Íosluchtaigh comhad
1//! Local identities. Private material is persisted only inside VaultStore, never returned to UI.
2use crate::core::{
3 error::{CoreError, CoreResult},
4 vault::VaultStore,
5};
6use pgp::{
7 composed::{
8 Deserializable, EncryptionCaps, KeyType, SecretKeyParamsBuilder, SignedPublicKey,
9 SignedSecretKey, SubkeyParamsBuilder,
10 },
11 types::KeyDetails,
12};
13use serde_json::{json, Value};
14use zeroize::Zeroizing;
16fn invalid() -> CoreError {
17 CoreError::new("KEYCHAIN_INVALID_KEY", "Unsupported or invalid key.")
19pub fn describe(entry: &Value) -> Value {
20 let mut summary = json!({"id":entry["id"], "label":entry["label"], "kind":entry["kind"], "fingerprint":entry["fingerprint"], "hasPrivate":entry["private"].is_string(), "createdAt":entry["createdAt"], "revoked":entry["revoked"], "type":entry["type"], "bits":entry["bits"], "privateKeyFormat":entry["privateKeyFormat"], "signSupported":entry["signSupported"] != false});
21 if entry["keyserver"].is_object() {
22 summary["keyserver"] = json!({"origin":entry["keyserver"]["origin"],"found":entry["keyserver"]["found"],"revoked":entry["keyserver"]["revoked"],"checkedAt":entry["keyserver"]["checkedAt"],"publishedAt":entry["keyserver"]["publishedAt"],"revocationPublishedAt":entry["keyserver"]["revocationPublishedAt"]});
23 }
24 if entry["kind"] == "ssh" {
25 if let Ok(public) = russh::keys::ssh_key::PublicKey::from_openssh(entry["public"].as_str().unwrap_or("")) {
26 let comment = public.comment().as_str_lossy().trim().to_string();
27 // Entries imported before 2026-11-05 carry the default label "SSH"; show the key comment instead.
28 if entry["label"] == "SSH" && !comment.is_empty() { summary["label"] = json!(comment); }
29 summary["comment"] = json!(comment);
30 summary["algorithm"] = json!(public.algorithm().as_str());
31 let bits = match public.key_data() {
32 russh::keys::ssh_key::public::KeyData::Rsa(rsa) => Some(rsa.key_size()),
33 russh::keys::ssh_key::public::KeyData::Ed25519(_) => Some(256),
34 russh::keys::ssh_key::public::KeyData::Ecdsa(key) => Some(match key.curve() { russh::keys::ssh_key::EcdsaCurve::NistP256 => 256, russh::keys::ssh_key::EcdsaCurve::NistP384 => 384, _ => 521 }),
35 _ => None,
36 };
37 if let Some(bits) = bits { summary["bits"] = json!(bits); }
38 }
39 }
40 if entry["kind"] == "openpgp" {
41 if let Ok(details) =
42 crate::core::openpgp::describe_public(entry["public"].as_str().unwrap_or(""))
43 {
44 for (key, value) in details.as_object().unwrap() {
45 summary[key] = value.clone();
46 }
47 for (name,value) in super::openpgp_data::capabilities(entry,&details).as_object().unwrap(){summary[name]=value.clone();}
48 summary["signSupported"] = json!(
49 [2, 3, 4, 5, 6].contains(&details["version"].as_u64().unwrap_or(0))
50 && details["certificateCapabilities"]
51 .as_str()
52 .unwrap_or("")
53 .contains('S')
54 );
55 summary["revokeSupported"] =
56 json!([2, 3, 4, 5, 6].contains(&details["version"].as_u64().unwrap_or(0)));
57 }
58 }
59 if entry["kind"]=="ssh" {
60 if let Ok(public)=russh::keys::ssh_key::PublicKey::from_openssh(entry["public"].as_str().unwrap_or("")) {
61 summary["algorithm"]=json!(public.algorithm().as_str());summary["postQuantum"]=json!(public.algorithm().as_str()=="ssh-mldsa44-ed25519");summary["hybrid"]=summary["postQuantum"].clone();
62 }
63 }
64 summary
67pub fn list(vault: &VaultStore) -> CoreResult<Value> {
68 list_at_generation(vault, vault.unlocked_generation()?)
70pub fn list_at_generation(vault: &VaultStore, generation: u64) -> CoreResult<Value> {
71 let data = vault.get_data_at_generation(generation)?;
72 Ok(Value::Array(
73 data.pointer("/secrets/keychain")
74 .and_then(Value::as_array)
75 .map(|items| items.iter().map(describe).collect())
76 .unwrap_or_default(),
77 ))
79/// Do not release a stale signature/plaintext after selection, revocation or vault changes.
80pub fn with_revision<R>(vault:&VaultStore,generation:u64,before:&Value,operation:impl FnOnce()->CoreResult<R>)->CoreResult<R>{
81 vault.with_data_at_generation(generation,|data|{
82 let current=data.pointer("/secrets/keychain").and_then(Value::as_array).and_then(|a|a.iter().find(|e|e["id"]==before["id"]));
83 if current!=Some(before){return Err(CoreError::new("KEYCHAIN_CHANGED","The selected key changed. Repeat the operation."))}
84 operation()
85 })
87pub fn get(vault: &VaultStore, id: &str) -> CoreResult<Value> {
88 get_at_generation(vault, id, vault.unlocked_generation()?)
90pub fn get_at_generation(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {
91 vault
92 .get_data_at_generation(generation)?
93 .pointer("/secrets/keychain")
94 .and_then(Value::as_array)
95 .and_then(|items| items.iter().find(|item| item["id"] == id))
96 .cloned()
97 .ok_or_else(|| CoreError::new("KEYCHAIN_NOT_FOUND", "Key was not found."))
99pub fn parse(bytes: &[u8], label: &str, passphrase: &str) -> CoreResult<Value> {
100 if bytes.len() > 4 * 1024 * 1024 || label.chars().count() > 200 {
101 return Err(invalid());
102 }
103 let selected = single_key_block(bytes)?;
104 let bytes: &[u8] = &selected;
105 if super::openpgp_compat::handles(bytes)? {
106 return super::openpgp_compat::parse(bytes,label);
107 }
108 let text = Zeroizing::new(String::from_utf8_lossy(bytes).to_string());
109 let ssh_secret = russh::keys::decode_secret_key(&text, if passphrase.is_empty() { None } else { Some(passphrase) });
110 let encrypted_pem = text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") || text.contains("Proc-Type: 4,ENCRYPTED");
111 if matches!(ssh_secret, Err(russh::keys::Error::KeyIsEncrypted)) || (passphrase.is_empty() && encrypted_pem && ssh_secret.is_err()) {
112 return Err(CoreError::new("PRIVATE_KEY_PASSPHRASE_REQUIRED", "The private key is encrypted; enter its passphrase."));
113 }
114 // The OpenSSH comment is the natural name of an SSH key.
115 let mut ssh_comment = String::new();
116 let (kind, public, private, fingerprint) = if let Ok((secret, _)) =
117 SignedSecretKey::from_reader_single(std::io::Cursor::new(bytes))
118 {
119 secret.verify_bindings().map_err(|_| invalid())?;
120 let public = SignedPublicKey::from(secret.clone());
121 (
122 "openpgp",
123 public
124 .to_armored_string(Default::default())
125 .map_err(|_| invalid())?,
126 Some(
127 secret
128 .to_armored_string(Default::default())
129 .map_err(|_| invalid())?,
130 ),
131 hex::encode_upper(public.fingerprint().as_ref()),
132 )
133 } else if let Ok((public, _)) = SignedPublicKey::from_reader_single(std::io::Cursor::new(bytes))
134 {
135 public.verify_bindings().map_err(|_| invalid())?;
136 (
137 "openpgp",
138 public
139 .to_armored_string(Default::default())
140 .map_err(|_| invalid())?,
141 None,
142 hex::encode_upper(public.fingerprint().as_ref()),
143 )
144 } else if let Ok(secret) = ssh_secret {
145 let public = secret.public_key();
146 ssh_comment = secret.comment().as_str_lossy().trim().to_string();
147 if public.algorithm().as_str()=="ssh-mldsa44-ed25519@openssh.com" {return Err(CoreError::new("KEYCHAIN_UNSUPPORTED_ALGORITHM","Experimental SSH composite keys must be regenerated in the final OpenSSH format."));}
148 (
149 "ssh",
150 public.to_openssh().map_err(|_| invalid())?,
151 Some(text.to_string()),
152 public
153 .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
154 .to_string(),
155 )
156 } else if let Ok(public) = russh::keys::ssh_key::PublicKey::from_openssh(&text) {
157 ssh_comment = public.comment().as_str_lossy().trim().to_string();
158 if public.algorithm().as_str()=="ssh-mldsa44-ed25519@openssh.com" {return Err(CoreError::new("KEYCHAIN_UNSUPPORTED_ALGORITHM","Experimental SSH composite keys must be regenerated in the final OpenSSH format."));}
159 (
160 "ssh",
161 public.to_openssh().map_err(|_| invalid())?,
162 None,
163 public
164 .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
165 .to_string(),
166 )
167 } else {
168 return Err(invalid());
169 };
170 let revoked = if kind == "openpgp" {
171 SignedPublicKey::from_reader_single(std::io::Cursor::new(public.as_bytes()))
172 .map(|(key, _)| {
173 key.details
174 .revocation_signatures
175 .iter()
176 .any(|s| s.verify_key(&key.primary_key).is_ok())
177 })
178 .unwrap_or(false)
179 } else {
180 false
181 };
182 let label = if label.trim().is_empty() && kind == "openpgp" {
183 crate::core::openpgp::describe_public(&public)
184 .ok()
185 .and_then(|d| d["userIds"][0].as_str().map(str::to_string))
186 .unwrap_or_else(|| "OpenPGP".into())
187 } else if label.trim().is_empty() {
188 let comment: String = ssh_comment.chars().filter(|c| !c.is_control()).take(200).collect();
189 if comment.is_empty() { "SSH".into() } else { comment }
190 } else {
191 label.trim().to_string()
192 };
193 Ok(
194 json!({"revoked":revoked, "id":uuid::Uuid::new_v4().to_string(), "kind":kind, "label":label, "public":public, "private":private, "fingerprint":fingerprint, "createdAt":chrono::Utc::now().to_rfc3339()}),
195 )
197/// Private export in the layout of GPG Keychain: the private block followed by the public block of the same key.
198/// Only for keys rPGP reads back (V4/V6); historical formats keep the private block alone.
199pub fn private_export_text(entry: &Value) -> Option<String> {
200 let private = entry["private"].as_str()?;
201 if entry["kind"] != "openpgp" { return Some(private.to_string()); }
202 let public = entry["publicExport"].as_str().or_else(|| entry["public"].as_str());
203 match public {
204 Some(public) if SignedSecretKey::from_reader_single(std::io::Cursor::new(private.as_bytes())).is_ok() =>
205 Some(format!("{}\n{}\n", private.trim_end(), public.trim_end())),
206 _ => Some(private.to_string()),
207 }
210/// GPG Keychain and `gpg --export-secret-keys --armor` + `--export` write the private block followed by the
211/// public block of the same key into one file. Use the private block (it contains the public key) when every
212/// block describes the same primary key; several different keys must be imported one by one.
213fn single_key_block(bytes: &[u8]) -> CoreResult<Zeroizing<Vec<u8>>> {
214 let Ok(text) = std::str::from_utf8(bytes) else { return Ok(Zeroizing::new(bytes.to_vec())) };
215 if text.matches("-----BEGIN PGP ").count() < 2 {
216 return Ok(Zeroizing::new(bytes.to_vec()));
217 }
218 let mut blocks: Vec<&str> = Vec::new();
219 let mut rest = text;
220 while let Some(start) = rest.find("-----BEGIN PGP ") {
221 let tail = &rest[start..];
222 let kind_end = tail[15..].find("-----").ok_or_else(invalid)? + 15;
223 let end_marker = format!("-----END PGP {}-----", &tail[15..kind_end]);
224 let end = tail.find(&end_marker).ok_or_else(invalid)? + end_marker.len();
225 blocks.push(&tail[..end]);
226 rest = &tail[end..];
227 if blocks.len() > 8 { return Err(invalid()); }
228 }
229 if !rest.trim().is_empty() { return Err(invalid()); }
230 let fingerprint = |block: &str| -> Option<String> {
231 if block.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") {
232 SignedSecretKey::from_reader_single(std::io::Cursor::new(block.as_bytes())).ok().map(|(k, _)| hex::encode_upper(SignedPublicKey::from(k).fingerprint().as_ref()))
233 } else if block.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
234 SignedPublicKey::from_reader_single(std::io::Cursor::new(block.as_bytes())).ok().map(|(k, _)| hex::encode_upper(k.fingerprint().as_ref()))
235 } else { None }
236 };
237 let prints: Vec<Option<String>> = blocks.iter().map(|b| fingerprint(b)).collect();
238 if prints.iter().any(Option::is_none) || prints.windows(2).any(|w| w[0] != w[1]) {
239 return Err(CoreError::new("KEYCHAIN_MULTIPLE_KEYS", "The file contains several different keys. Import them one by one."));
240 }
241 let chosen = blocks.iter().find(|b| b.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----")).unwrap_or(&blocks[0]);
242 Ok(Zeroizing::new(chosen.as_bytes().to_vec()))
244pub fn store(vault: &VaultStore, entry: Value) -> CoreResult<Value> {
245 store_at_generation(vault, entry, vault.unlocked_generation()?)
247pub fn store_at_generation(vault: &VaultStore, entry: Value, generation: u64) -> CoreResult<Value> {
248 vault.update_at_generation(generation, |data| {
249 let secrets = data["secrets"].as_object_mut().ok_or_else(invalid)?;
250 let items = secrets
251 .entry("keychain")
252 .or_insert_with(|| json!([]))
253 .as_array_mut()
254 .ok_or_else(invalid)?;
255 if let Some(existing) = items.iter_mut().find(|item| {
256 item["kind"] == entry["kind"] && item["fingerprint"] == entry["fingerprint"]
257 }) {
258 // A private SSH key completes a stored public-only entry with the same fingerprint.
259 if entry["kind"] == "ssh" && !existing["private"].is_string() && entry["private"].is_string() {
260 existing["private"] = entry["private"].clone();
261 if entry.get("privateKeyFormat").is_some() { existing["privateKeyFormat"] = entry["privateKeyFormat"].clone(); }
262 return Ok(describe(existing));
263 }
264 if existing["private"].is_string() && !entry["private"].is_string() {
265 return Err(CoreError::new("KEYCHAIN_PUBLIC_ALREADY_INCLUDED", "This public key is already part of a stored key pair."));
266 }
267 return Err(CoreError::new(
268 "KEYCHAIN_DUPLICATE",
269 "This key is already stored.",
270 ));
271 }
272 items.push(entry.clone());
273 Ok(describe(&entry))
274 })
276/// Commit a private-key operation only against the exact original vault snapshot.
277pub fn commit_revision(vault:&VaultStore,generation:u64,before:&Value,next:Value)->CoreResult<Value> {
278 if ["id","kind","fingerprint"].iter().any(|field|before[*field]!=next[*field]) {return Err(invalid());}
279 vault.update_at_generation(generation,|data| {
280 let current=data.pointer_mut("/secrets/keychain").and_then(Value::as_array_mut).and_then(|items|items.iter_mut().find(|e|e["id"]==before["id"])).ok_or_else(||CoreError::plain("Key was removed."))?;
281 if *current!=*before {return Err(CoreError::plain("Key changed during editing. Try again."));}
282 *current=next;Ok(describe(current))
283 })
285pub fn remove(vault: &VaultStore, id: &str) -> CoreResult<Value> {
286 remove_at_generation(vault, id, vault.unlocked_generation()?)
288pub fn remove_at_generation(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {
289 vault.update_at_generation(generation, |data| {
290 let mut removed = false;
291 if let Some(items) = data
292 .pointer_mut("/secrets/keychain")
293 .and_then(Value::as_array_mut)
294 {
295 items.retain(|item| {
296 let keep = item["id"] != id;
297 removed |= !keep;
298 keep
299 });
300 }
301 Ok(json!({"removed":removed}))
302 })
304pub fn generate(kind: &str, label: &str) -> CoreResult<Value> {
305 if label.trim().is_empty() || label.chars().count() > 200 || label.chars().any(char::is_control)
306 {
307 return Err(invalid());
308 }
309 if kind == "ssh" {
310 let generated = crate::core::keys::generate(
311 &json!({"type":"ed25519", "privateKeyFormat":"openssh", "comment":label}),
312 )?;
313 return parse(
314 generated["privateKey"].as_str().unwrap_or("").as_bytes(),
315 label,
316 "",
317 );
318 }
319 if kind != "openpgp" {
320 return Err(invalid());
321 }
322 let encryption = SubkeyParamsBuilder::default()
323 .key_type(KeyType::ECDH(
324 pgp::crypto::ecc_curve::ECCCurve::Curve25519Legacy,
325 ))
326 .can_sign(false)
327 .can_encrypt(EncryptionCaps::All)
328 .build()
329 .map_err(|_| invalid())?;
330 let secret = SecretKeyParamsBuilder::default()
331 .key_type(KeyType::Ed25519Legacy)
332 .can_certify(true)
333 .can_sign(true)
334 .can_encrypt(EncryptionCaps::None)
335 .primary_user_id(label.to_string())
336 .subkeys(vec![encryption])
337 .build()
338 .map_err(|_| invalid())?
339 .generate(rand::thread_rng())
340 .map_err(|_| invalid())?;
341 let text = Zeroizing::new(
342 secret
343 .to_armored_string(Default::default())
344 .map_err(|_| invalid())?,
345 );
346 parse(text.as_bytes(), label, "")
349pub fn sign(entry: &Value, path: &std::path::Path, passphrase: &str) -> CoreResult<String> {
350 sign_with(entry, path, passphrase, None)
353/// OpenPGP components of a stored identity that can make a signature now: the primary key and every subkey
354/// with an effective S capability that is neither revoked nor expired. Compat formats (V2/V3/V5) keep their
355/// own automatic choice and report one `automatic` component.
356pub fn signing_components(entry: &Value) -> CoreResult<Vec<Value>> {
357 if entry["kind"] != "openpgp" || !entry["private"].is_string() || entry["revoked"] == true {
358 return Ok(Vec::new());
359 }
360 let public = entry["public"].as_str().ok_or_else(invalid)?;
361 let details = crate::core::openpgp::describe_public(public)?;
362 if details["revoked"] == true {
363 return Ok(Vec::new());
364 }
365 let can_sign = |meta: &Value| {
366 meta["effectiveCapabilities"].as_str().unwrap_or("").contains('S') && meta["revoked"] != true && meta["expired"] != true && meta["usable"] != false
367 };
368 let describe = |meta: &Value, role: &str| json!({"fingerprint":meta["fingerprint"],"keyId":meta["keyId"],"algorithm":meta["algorithm"],"postQuantum":meta["postQuantum"],"expiresAt":meta["expiresAt"],"createdAt":meta["keyCreatedAt"],"role":role});
369 if super::openpgp_compat::handles(public.as_bytes())? {
370 return Ok(if details["certificateCapabilities"].as_str().unwrap_or("").contains('S') { vec![json!({"fingerprint":null,"role":"automatic","algorithm":details["algorithm"]})] } else { Vec::new() });
371 }
372 let mut out = Vec::new();
373 if can_sign(&details) {
374 out.push(describe(&details, "primary"));
375 }
376 for meta in details["subkeys"].as_array().into_iter().flatten().filter(|meta| can_sign(meta)) {
377 out.push(describe(meta, "subkey"));
378 }
379 Ok(out)
382/// `component` names the fingerprint of the primary key or of one of its signing subkeys; `None` keeps the
383/// automatic choice (first effective signing subkey, else the primary key).
384pub fn sign_with(entry: &Value, path: &std::path::Path, passphrase: &str, component: Option<&str>) -> CoreResult<String> {
385 if entry["kind"] == "openpgp" && super::openpgp_compat::handles(entry["public"].as_str().unwrap_or("").as_bytes())? {
386 if component.is_some() {
387 return Err(CoreError::plain("This key format chooses its signing key automatically."));
388 }
389 return super::openpgp_compat::sign(entry,path,passphrase);
390 }
391 if entry["kind"] == "openpgp" {
392 if let Some(wanted) = component {
393 let allowed = signing_components(entry)?;
394 if !allowed.iter().any(|c| c["fingerprint"].as_str().is_some_and(|f| f.eq_ignore_ascii_case(wanted))) {
395 return Err(CoreError::new("KEYCHAIN_COMPONENT_UNUSABLE", "The selected key cannot sign: it is revoked, expired or has no signing capability."));
396 }
397 }
398 } else if component.is_some() {
399 return Err(invalid());
400 }
401 let secret = Zeroizing::new(entry["private"].as_str().ok_or_else(invalid)?.to_string());
402 if entry["kind"] == "openpgp" {
403 let (key, _) = SignedSecretKey::from_reader_single(std::io::Cursor::new(secret.as_bytes()))
404 .map_err(|_| invalid())?;
405 let details =
406 crate::core::openpgp::describe_public(entry["public"].as_str().ok_or_else(invalid)?)?;
407 let signing = |key: &dyn pgp::types::KeyDetails| {
408 details["subkeys"]
409 .as_array()
410 .into_iter()
411 .flatten()
412 .find(|meta| meta["fingerprint"] == hex::encode_upper(key.fingerprint().as_ref()))
413 .is_some_and(|meta| {
414 meta["effectiveCapabilities"]
415 .as_str()
416 .unwrap_or("")
417 .contains('S')
418 })
419 };
420 let wanted = component.map(str::to_ascii_uppercase);
421 let primary_fp = hex::encode_upper(key.primary_key.fingerprint().as_ref());
422 let chosen_subkey = match &wanted {
423 Some(fp) if *fp == primary_fp => None,
424 Some(fp) => Some(key.secret_subkeys.iter().find(|sub| hex::encode_upper(sub.key.fingerprint().as_ref()) == *fp && signing(&sub.key)).ok_or_else(|| CoreError::new("KEYCHAIN_COMPONENT_UNUSABLE", "The selected subkey has no usable secret part."))?),
425 None => key.secret_subkeys.iter().find(|sub| signing(&sub.key)),
426 };
427 let signature = if let Some(subkey) = chosen_subkey
428 {
429 pgp::composed::DetachedSignature::sign_binary_data(
430 rand::thread_rng(),
431 &subkey.key,
432 &pgp::types::Password::from(passphrase),
433 crate::core::openpgp_algorithms::signing_hash(subkey.key.public_params()),
434 std::fs::File::open(path)?,
435 )
436 } else if details["effectiveCapabilities"]
437 .as_str()
438 .unwrap_or("")
439 .contains('S')
440 {
441 pgp::composed::DetachedSignature::sign_binary_data(
442 rand::thread_rng(),
443 &key.primary_key,
444 &pgp::types::Password::from(passphrase),
445 crate::core::openpgp_algorithms::signing_hash(key.primary_key.public_params()),
446 std::fs::File::open(path)?,
447 )
448 } else {
449 return Err(CoreError::plain(
450 "This certificate has no effective signing capability.",
451 ));
452 }
453 .map_err(|_| {
454 CoreError::plain("OpenPGP signing failed. Check the passphrase and signing capability.")
455 })?;
456 let armored = signature
457 .to_armored_string(Default::default())
458 .map_err(|_| invalid())?;
459 // Reuse the strict validity/revocation checks before releasing a newly made signature.
460 verify(entry, path, armored.as_bytes())?;
461 Ok(armored)
462 } else {
463 let key = russh::keys::decode_secret_key(
464 &secret,
465 if passphrase.is_empty() {
466 None
467 } else {
468 Some(passphrase)
469 },
470 )
471 .map_err(|_| invalid())?;
472 let bytes = bounded_file(path, 64 * 1024 * 1024)?;
473 key.sign("file", russh::keys::ssh_key::HashAlg::Sha512, &bytes)
474 .map_err(|_| invalid())?
475 .to_pem(Default::default())
476 .map_err(|_| invalid())
477 }
479pub fn bounded_file(path: &std::path::Path, limit: u64) -> CoreResult<Vec<u8>> {
480 use std::io::Read;
481 let mut bytes = Vec::new();
482 std::fs::File::open(path)?
483 .take(limit + 1)
484 .read_to_end(&mut bytes)?;
485 if bytes.len() as u64 > limit {
486 return Err(CoreError::plain("File is too large for this operation."));
487 }
488 Ok(bytes)
490pub fn verify(entry: &Value, path: &std::path::Path, signature: &[u8]) -> CoreResult<Value> {
491 let public = entry["public"].as_str().ok_or_else(invalid)?;
492 if entry["kind"] == "openpgp" {
493 if super::openpgp_compat::handles(public.as_bytes())? {
494 return super::openpgp_compat::verify(entry,path,signature);
495 }
496 use pgp::{composed::DetachedSignature, packet::SignatureType};
497 if signature.len() > 1024 * 1024 {
498 return Err(invalid());
499 }
500 let signature = if signature.starts_with(b"-----BEGIN PGP") {
501 DetachedSignature::from_armor_single(std::io::Cursor::new(signature))
502 .map_err(|_| invalid())?
503 .0
504 } else {
505 DetachedSignature::from_bytes(std::io::Cursor::new(signature)).map_err(|_| invalid())?
506 }
507 .signature;
508 if signature.version()==pgp::packet::SignatureVersion::V6 && signature.config().is_some_and(|c|c.pub_alg==pgp::crypto::public_key::PublicKeyAlgorithm::DSA) {
509 return Err(invalid());
510 }
511 if !matches!(
512 signature.typ(),
513 Some(SignatureType::Binary | SignatureType::Text)
514 ) || !crate::core::updates::openpgp::signature_current(
515 &signature,
516 std::time::SystemTime::now(),
517 ) {
518 return Err(invalid());
519 }
520 let (key, _) = SignedPublicKey::from_reader_single(std::io::Cursor::new(public))
521 .map_err(|_| invalid())?;
522 let details = crate::core::openpgp::describe_public(public)?;
523 let effective = |meta: &Value| {
524 meta["effectiveCapabilities"]
525 .as_str()
526 .unwrap_or("")
527 .contains('S')
528 && [4, 6].contains(&meta["version"].as_u64().unwrap_or(0))
529 };
530 let mut valid = effective(&details)
531 && signature
532 .verify(&key.primary_key, std::fs::File::open(path)?)
533 .is_ok();
534 if !valid {
535 for subkey in &key.public_subkeys {
536 if details["subkeys"]
537 .as_array()
538 .into_iter()
539 .flatten()
540 .any(|meta| {
541 meta["fingerprint"] == hex::encode_upper(subkey.key.fingerprint().as_ref())
542 && effective(meta)
543 })
544 && signature
545 .verify(&subkey.key, std::fs::File::open(path)?)
546 .is_ok()
547 {
548 valid = true;
549 break;
550 }
551 }
552 }
553 if !valid {
554 return Err(CoreError::new(
555 "KEYCHAIN_SIGNATURE_INVALID",
556 "The signature is invalid, or its key has no effective signing capability.",
557 ));
558 }
559 } else {
560 let key = russh::keys::ssh_key::PublicKey::from_openssh(public).map_err(|_| invalid())?;
561 let signature = russh::keys::ssh_key::SshSig::from_pem(signature).map_err(|_| invalid())?;
562 let bytes = bounded_file(path, 64 * 1024 * 1024)?;
563 key.verify("file", &bytes, &signature).map_err(|_| {
564 CoreError::new(
565 "KEYCHAIN_SIGNATURE_INVALID",
566 "SSH signature verification failed.",
567 )
568 })?;
569 }
570 Ok(json!({"verified":true, "fingerprint":entry["fingerprint"]}))
573#[cfg(test)]
574mod tests {
575 use super::*;
576 #[tokio::test]
577 async fn final_local_effect_rejects_changed_removed_or_locked_identity(){
578 let root=tempfile::tempdir().unwrap();let vault=VaultStore::new(root.path());vault.create("synthetic-revision-password").await.unwrap();
579 let entry=generate("ssh","Synthetic revision").unwrap();store(&vault,entry.clone()).unwrap();let generation=vault.unlocked_generation().unwrap();
580 let called=std::cell::Cell::new(false);
581 with_revision(&vault,generation,&entry,||{called.set(true);Ok(())}).unwrap();assert!(called.replace(false));
582 vault.update(|data|{data["secrets"]["keychain"][0]["revoked"]=json!(true);Ok(())}).unwrap();
583 assert!(with_revision(&vault,generation,&entry,||{called.set(true);Ok(())}).is_err());assert!(!called.get());
584 vault.update(|data|{data["secrets"]["keychain"]=json!([]);Ok(())}).unwrap();assert!(with_revision(&vault,generation,&entry,||{called.set(true);Ok(())}).is_err());assert!(!called.get());
585 vault.lock();assert!(with_revision(&vault,generation,&entry,||{called.set(true);Ok(())}).is_err());assert!(!called.get());
586 }
588 #[test]
589 fn gpg_keychain_file_with_private_and_public_block_imports_once() {
590 let first=generate("openpgp","Combined fixture").unwrap();
591 let combined=private_export_text(&first).unwrap();
592 assert!(combined.contains("PRIVATE KEY BLOCK") && combined.contains("PUBLIC KEY BLOCK"));
593 let parsed=parse(combined.as_bytes(),"","").unwrap();
594 assert!(parsed["private"].is_string());assert_eq!(parsed["fingerprint"],first["fingerprint"]);
595 let other=generate("openpgp","Other fixture").unwrap();
596 let mixed=format!("{}\n{}\n",first["private"].as_str().unwrap(),other["public"].as_str().unwrap());
597 assert_eq!(parse(mixed.as_bytes(),"","").unwrap_err().code,"KEYCHAIN_MULTIPLE_KEYS");
598 }
600 #[tokio::test]
601 async fn ssh_public_import_uses_comment_as_name_and_encrypted_key_asks_for_passphrase() {
602 let generated=generate("ssh","laptop@example").unwrap();
603 let private=parse(generated["private"].as_str().unwrap().as_bytes(),"","").unwrap();
604 assert_eq!(private["label"],"laptop@example");assert!(private["private"].is_string());
605 let public_line=format!("{}\n",generated["public"].as_str().unwrap());
606 let public=parse(public_line.as_bytes(),"","").unwrap();
607 let mut legacy=public.clone();legacy["label"]=json!("SSH");
608 let shown=describe(&legacy);assert_eq!(shown["label"],"laptop@example");assert_eq!(shown["bits"],256);assert_eq!(shown["algorithm"],"ssh-ed25519");
609 assert_eq!(public["kind"],"ssh");assert_eq!(public["label"],"laptop@example");assert!(public["private"].is_null());
610 assert_eq!(public["fingerprint"],private["fingerprint"]);
611 assert_eq!(parse(public_line.as_bytes(),"Own name","").unwrap()["label"],"Own name");
612 let root=tempfile::tempdir().unwrap();let vault=VaultStore::new(root.path());vault.create("fixture-vault-password").await.unwrap();
613 store(&vault,public).unwrap();
614 assert_eq!(list(&vault).unwrap()[0]["hasPrivate"],false);
615 assert_eq!(store(&vault,private.clone()).unwrap()["hasPrivate"],true);
616 assert_eq!(list(&vault).unwrap().as_array().unwrap().len(),1);
617 assert_eq!(store(&vault,parse(public_line.as_bytes(),"","").unwrap()).unwrap_err().code,"KEYCHAIN_PUBLIC_ALREADY_INCLUDED");
618 assert_eq!(store(&vault,private).unwrap_err().code,"KEYCHAIN_DUPLICATE");
619 let encrypted=crate::core::keys::generate(&json!({"type":"ed25519","privateKeyFormat":"openssh","passphrase":"fixture-passphrase"})).unwrap();
620 let text=encrypted["privateKey"].as_str().unwrap().as_bytes();
621 assert_eq!(parse(text,"","").unwrap_err().code,"PRIVATE_KEY_PASSPHRASE_REQUIRED");
622 assert!(parse(text,"","fixture-passphrase").unwrap()["private"].is_string());
623 }
625 #[tokio::test]
626 async fn key_revision_cannot_overwrite_concurrent_changes_removed_keys_or_a_new_unlock() {
627 let root=tempfile::tempdir().unwrap();let vault=VaultStore::new(root.path());vault.create("fixture-vault-password").await.unwrap();
628 let entry=generate("openpgp","Revision fixture").unwrap();store(&vault,entry.clone()).unwrap();
629 let epoch=vault.unlocked_generation().unwrap();
630 let mut next=entry.clone();next["label"]=json!("Updated fixture");
631 assert!(commit_revision(&vault,epoch,&entry,next.clone()).is_ok());
632 assert!(commit_revision(&vault,epoch,&entry,entry.clone()).is_err());
633 let mut wrong_id=next.clone();wrong_id["id"]=json!("foreign-id");assert!(commit_revision(&vault,epoch,&next,wrong_id).is_err());
634 vault.lock();vault.unlock("fixture-vault-password").await.unwrap();
635 assert!(commit_revision(&vault,epoch,&next,entry.clone()).is_err());
636 let epoch=vault.unlocked_generation().unwrap();remove_at_generation(&vault,next["id"].as_str().unwrap(),epoch).unwrap();
637 assert!(commit_revision(&vault,epoch,&next,entry).is_err());
638 }
639 #[tokio::test]
640 async fn keys_are_encrypted_hidden_from_ui_and_locked_on_reopen() {
641 let root = tempfile::tempdir().unwrap();
642 let vault = VaultStore::new(root.path());
643 assert!(list(&vault).is_err());
644 vault.create("fixture-password").await.unwrap();
645 for kind in ["ssh", "openpgp"] {
646 let entry = generate(kind, "Test <test@example.invalid>").unwrap();
647 let private = entry["private"].as_str().unwrap().to_string();
648 let id = entry["id"].as_str().unwrap().to_string();
649 let summary = store(&vault, entry.clone()).unwrap();
650 assert!(summary.get("private").is_none());
651 assert!(summary.get("public").is_none());
652 assert!(store(&vault, entry).is_err());
653 let disk =
654 std::fs::read_to_string(root.path().join("vault/core-v1.data.json")).unwrap();
655 assert!(!disk.contains(&private));
656 vault.lock();
657 assert!(get(&vault, &id).is_err());
658 vault.unlock("fixture-password").await.unwrap();
659 assert_eq!(get(&vault, &id).unwrap()["private"], private);
660 assert!(remove(&vault, &id).unwrap()["removed"].as_bool().unwrap());
661 }
662 assert!(parse(b"invalid key", "bad", "").is_err());
663 }
664 #[tokio::test]
665 async fn keychain_requests_from_an_old_unlock_epoch_are_rejected() {
666 let root = tempfile::tempdir().unwrap();
667 let vault = VaultStore::new(root.path());
668 vault.create("fixture-password").await.unwrap();
669 let generation = vault.unlocked_generation().unwrap();
670 let entry = generate("ssh", "Synthetic identity").unwrap();
671 let id = entry["id"].as_str().unwrap().to_string();
672 store(&vault, entry.clone()).unwrap();
673 vault.lock();
674 vault.unlock("fixture-password").await.unwrap();
675 assert!(list_at_generation(&vault, generation).is_err());
676 assert!(get_at_generation(&vault, &id, generation).is_err());
677 assert!(remove_at_generation(&vault, &id, generation).is_err());
678 assert!(store_at_generation(
679 &vault,
680 generate("ssh", "Other synthetic identity").unwrap(),
681 generation
682 )
683 .is_err());
684 assert_eq!(get(&vault, &id).unwrap(), entry);
685 }
687 #[test]
688 fn detached_signatures_detect_modified_files_for_both_formats() {
689 let root = tempfile::tempdir().unwrap();
690 let path = root.path().join("file");
691 for kind in ["ssh", "openpgp"] {
692 let entry = generate(kind, "Test <test@example.invalid>").unwrap();
693 std::fs::write(&path, b"original").unwrap();
694 let signature = sign(&entry, &path, "").unwrap();
695 assert_eq!(
696 verify(&entry, &path, signature.as_bytes()).unwrap()["verified"],
697 true
698 );
699 if kind == "openpgp" {
700 let revoked = revoke(&entry, "").unwrap();
701 assert_eq!(revoked["revoked"], true);
702 assert!(verify(&revoked, &path, signature.as_bytes()).is_err());
703 assert!(sign(&revoked, &path, "").is_err());
704 }
705 std::fs::write(&path, b"tampered").unwrap();
706 assert!(verify(&entry, &path, signature.as_bytes()).is_err());
707 let other = generate(kind, "Other <other@example.invalid>").unwrap();
708 assert!(verify(&other, &path, signature.as_bytes()).is_err());
709 }
710 }
713/// Produce a self-revocation and retain the complete certificate for public export.
714pub fn revoke(entry: &Value, passphrase: &str) -> CoreResult<Value> {
715 use pgp::{
716 packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData},
717 types::{KeyVersion, Password, Timestamp},
718 };
719 if entry["kind"] != "openpgp" {
720 return Err(CoreError::plain(
721 "Only OpenPGP self-revocation is supported here.",
722 ));
723 }
724 if super::openpgp_compat::handles(entry["public"].as_str().unwrap_or("").as_bytes())? {
725 return super::openpgp_compat::revoke(entry,passphrase);
726 }
727 let private = Zeroizing::new(entry["private"].as_str().ok_or_else(invalid)?.to_string());
728 let (mut secret, _) =
729 SignedSecretKey::from_reader_single(std::io::Cursor::new(private.as_bytes()))
730 .map_err(|_| invalid())?;
731 let public = SignedPublicKey::from(secret.clone());
732 let mut config = SignatureConfig::from_key(
733 &mut rand::thread_rng(),
734 &secret.primary_key,
735 SignatureType::KeyRevocation,
736 )
737 .map_err(|_| invalid())?;
738 config.hash_alg=crate::core::openpgp_algorithms::signing_hash(secret.primary_key.public_params());
739 config.hashed_subpackets = vec![
740 Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now()))
741 .map_err(|_| invalid())?,
742 Subpacket::regular(SubpacketData::IssuerFingerprint(secret.fingerprint()))
743 .map_err(|_| invalid())?,
744 ];
745 // GnuPG's v4 import also needs the legacy issuer ID to locate the certificate;
746 // the full issuer fingerprint remains in the authenticated hashed subpackets.
747 if secret.primary_key.version() <= KeyVersion::V4 {
748 config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::IssuerKeyId(
749 secret.primary_key.legacy_key_id(),
750 ))
751 .map_err(|_| invalid())?];
752 }
753 let signature = config
754 .sign_key(
755 &secret.primary_key,
756 &Password::from(passphrase),
757 &public.primary_key,
758 )
759 .map_err(|_| CoreError::plain("Revocation failed. Check the key passphrase."))?;
760 signature
761 .verify_key(&public.primary_key)
762 .map_err(|_| invalid())?;
763 secret.details.revocation_signatures.push(signature);
764 let mut next = entry.clone();
765 next["private"] = json!(secret
766 .to_armored_string(Default::default())
767 .map_err(|_| invalid())?);
768 next["public"] = json!(SignedPublicKey::from(secret)
769 .to_armored_string(Default::default())
770 .map_err(|_| invalid())?);
771 next["revoked"] = json!(true);
772 Ok(next)
SHA-256594ea704a367e9bb80fcf592a8141d33d33842390c2b771fb725bdc0e900609f

CH-J Proprietary Software License 1.14 · Tá Foinse ar fáil faoin CH-J Proprietary Software License 1.14. Ní chuireann a infhaighteacht a athrú ar na téarmaí ceadúnais nó cearta breise a dheonú.

Cartlann SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Comhaid