Objavljen vir
Prenos vir ZIP
CH-J Tools
Brskajte po imenikih in datotekah za določeno izdajo programa.
CH-J / Server Manager
1704 datoteke
0.1.2-beta
Dodano: · Zgrajena:
1
//! Local identities. Private material is persisted only inside VaultStore, never returned to UI.2
use crate::core::{3
error::{CoreError, CoreResult},4
vault::VaultStore,5
};6
use pgp::{7
composed::{8
Deserializable, EncryptionCaps, KeyType, SecretKeyParamsBuilder, SignedPublicKey,9
SignedSecretKey, SubkeyParamsBuilder,10
},11
types::KeyDetails,12
};13
use serde_json::{json, Value};14
use zeroize::Zeroizing;16
fn invalid() -> CoreError {17
CoreError::new("KEYCHAIN_INVALID_KEY", "Unsupported or invalid key.")18
}19
pub 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
summary65
}67
pub fn list(vault: &VaultStore) -> CoreResult<Value> {68
list_at_generation(vault, vault.unlocked_generation()?)69
}70
pub 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
))78
}79
/// Do not release a stale signature/plaintext after selection, revocation or vault changes.80
pub 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
})86
}87
pub fn get(vault: &VaultStore, id: &str) -> CoreResult<Value> {88
get_at_generation(vault, id, vault.unlocked_generation()?)89
}90
pub fn get_at_generation(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {91
vault92
.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."))98
}99
pub 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
public124
.to_armored_string(Default::default())125
.map_err(|_| invalid())?,126
Some(127
secret128
.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
public139
.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
public153
.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
public164
.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.details174
.revocation_signatures175
.iter()176
.any(|s| s.verify_key(&key.primary_key).is_ok())177
})178
.unwrap_or(false)179
} else {180
false181
};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
)196
}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.199
pub 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
}208
}210
/// GPG Keychain and `gpg --export-secret-keys --armor` + `--export` write the private block followed by the211
/// public block of the same key into one file. Use the private block (it contains the public key) when every212
/// block describes the same primary key; several different keys must be imported one by one.213
fn 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()))243
}244
pub fn store(vault: &VaultStore, entry: Value) -> CoreResult<Value> {245
store_at_generation(vault, entry, vault.unlocked_generation()?)246
}247
pub 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 = secrets251
.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
})275
}276
/// Commit a private-key operation only against the exact original vault snapshot.277
pub 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
})284
}285
pub fn remove(vault: &VaultStore, id: &str) -> CoreResult<Value> {286
remove_at_generation(vault, id, vault.unlocked_generation()?)287
}288
pub 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) = data292
.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
keep299
});300
}301
Ok(json!({"removed":removed}))302
})303
}304
pub 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
secret343
.to_armored_string(Default::default())344
.map_err(|_| invalid())?,345
);346
parse(text.as_bytes(), label, "")347
}349
pub fn sign(entry: &Value, path: &std::path::Path, passphrase: &str) -> CoreResult<String> {350
sign_with(entry, path, passphrase, None)351
}353
/// OpenPGP components of a stored identity that can make a signature now: the primary key and every subkey354
/// with an effective S capability that is neither revoked nor expired. Compat formats (V2/V3/V5) keep their355
/// own automatic choice and report one `automatic` component.356
pub 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"] != false367
};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)380
}382
/// `component` names the fingerprint of the primary key or of one of its signing subkeys; `None` keeps the383
/// automatic choice (first effective signing subkey, else the primary key).384
pub 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_subkey428
{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 = signature457
.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
None467
} 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
}478
}479
pub 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)489
}490
pub 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
.0504
} 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
&& signature532
.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
&& signature545
.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"]}))571
}573
#[cfg(test)]574
mod 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
generation682
)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
true698
);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
}711
}713
/// Produce a self-revocation and retain the complete certificate for public export.714
pub 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 = config754
.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
signature761
.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!(secret766
.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)773
}SHA-256
594ea704a367e9bb80fcf592a8141d33d33842390c2b771fb725bdc0e900609fCH-J Proprietary Software License 1.14 · Vir je na voljo pod CH-J Proprietary Software License 1.14. Njena razpoložljivost ne spreminja pogojev licence ali dodeljuje dodatnih pravic.
Arhiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f