Foinse foilsithe
Íosluchtaigh foinse ZIP
CH-J Tools
Brabhsáil comhadlanna agus comhaid chun feidhmchlár áirithe a scaoileadh.
CH-J / Server Manager
1704 Comhaid
0.1.2-beta
Suimigh: · Tógtha:
1
//! Quick actions: verifying or signing one local file from the system file manager (Finder, Explorer,2
//! Nautilus/Dolphin) without the main window. The request arrives as command-line arguments3
//! `--chj-quick <verify|sign> <absolute path>`, either at start or forwarded by the single-instance plugin.4
//!5
//! Security: a request only opens the small quick window. Nothing is signed without the user choosing the6
//! key in that window and typing its passphrase; the window always shows the full path of the file.7
use crate::core::error::{CoreError, CoreResult};8
use parking_lot::Mutex;9
use serde_json::{json, Value};10
use std::path::{Path, PathBuf};12
pub const QUICK_FLAG: &str = "--chj-quick";13
/// Detached signature extensions, in the order a verification looks for them next to the file.14
pub const SIGNATURE_EXTENSIONS: &[&str] = &["asc", "sig", "gpg"];15
const MAX_SIGNATURE: u64 = 1024 * 1024;17
#[derive(Clone, Copy, Debug, PartialEq, Eq)]18
pub enum QuickAction {19
Verify,20
Sign,21
}23
impl QuickAction {24
pub fn name(self) -> &'static str {25
match self {26
QuickAction::Verify => "verify",27
QuickAction::Sign => "sign",28
}29
}30
}32
#[derive(Clone, Debug, PartialEq, Eq)]33
pub struct QuickRequest {34
pub action: QuickAction,35
pub path: PathBuf,36
}38
/// Finds `--chj-quick <action> <path>` anywhere in `args` (the program name may or may not be included).39
pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Option<QuickRequest> {40
let position = args.iter().position(|a| a.as_ref() == QUICK_FLAG)?;41
let action = match args.get(position + 1)?.as_ref() {42
"verify" => QuickAction::Verify,43
"sign" => QuickAction::Sign,44
_ => return None,45
};46
let path = PathBuf::from(args.get(position + 2)?.as_ref());47
if !path.is_absolute() {48
return None;49
}50
Some(QuickRequest { action, path })51
}53
/// Pending and current requests of this process. Only one quick window exists at a time; a new request54
/// replaces the shown one (the window reloads its state).55
#[derive(Default)]56
pub struct QuickSlot {57
current: Mutex<Option<QuickRequest>>,58
/// The process was started only for a quick action; closing the quick window then ends it.59
pub only_quick: Mutex<bool>,60
}62
impl QuickSlot {63
pub fn set(&self, request: QuickRequest) {64
*self.current.lock() = Some(request);65
}66
pub fn get(&self) -> Option<QuickRequest> {67
self.current.lock().clone()68
}69
pub fn clear(&self) {70
*self.current.lock() = None;71
}72
}74
fn regular_file(path: &Path) -> CoreResult<std::fs::Metadata> {75
let meta = std::fs::metadata(path).map_err(|_| CoreError::new("QUICK_FILE_MISSING", "The file does not exist or cannot be read."))?;76
if !meta.is_file() {77
return Err(CoreError::new("QUICK_NOT_A_FILE", "Only regular files can be signed or verified."));78
}79
Ok(meta)80
}82
fn with_extension(path: &Path, ext: &str) -> PathBuf {83
let mut name = path.as_os_str().to_os_string();84
name.push(".");85
name.push(ext);86
PathBuf::from(name)87
}89
/// The data file and its detached signature. The selected file may be either: `doc.pdf` (signature90
/// `doc.pdf.asc|.sig|.gpg` next to it) or the signature itself (`doc.pdf.asc` → data `doc.pdf`).91
pub fn locate_pair(selected: &Path) -> CoreResult<(PathBuf, Option<PathBuf>)> {92
regular_file(selected)?;93
let ext = selected.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);94
if ext.is_some_and(|e| SIGNATURE_EXTENSIONS.contains(&e.as_str())) {95
let data = selected.with_extension("");96
if data != selected && data.is_file() {97
return Ok((data, Some(selected.to_path_buf())));98
}99
}100
let signature = SIGNATURE_EXTENSIONS.iter().map(|ext| with_extension(selected, ext)).find(|p| p.is_file());101
Ok((selected.to_path_buf(), signature))102
}104
/// Where a new signature is written: `<file>.asc` (ASCII armored, as GnuPG `--armor --detach-sign`).105
pub fn signature_target(file: &Path) -> PathBuf {106
with_extension(file, "asc")107
}109
pub fn file_summary(path: &Path) -> CoreResult<Value> {110
let meta = regular_file(path)?;111
Ok(json!({112
"path": path.to_string_lossy(),113
"name": path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(),114
"directory": path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),115
"size": meta.len(),116
}))117
}119
pub fn read_signature(path: &Path) -> CoreResult<Vec<u8>> {120
let meta = regular_file(path)?;121
if meta.len() > MAX_SIGNATURE {122
return Err(CoreError::new("QUICK_SIGNATURE_TOO_LARGE", "The signature file is too large."));123
}124
Ok(std::fs::read(path)?)125
}127
/// Verifies `file` against `signature` with the stored Keychain identities first (when the vault is open),128
/// then with the public keys saved by the Hash & Checksum module. Returns the first valid match; when no key129
/// matches, the error of the saved-key path (with the issuer) explains what is missing.130
pub fn verify(file: &Path, signature: &[u8], keychain: &[Value], keyring: &Path) -> CoreResult<Value> {131
for entry in keychain.iter().filter(|e| e["kind"] == "openpgp") {132
if crate::core::keychain::verify(entry, file, signature).is_ok() {133
let details = crate::core::keychain::describe(entry);134
// Name the component that signed: an issuer that is this identity's primary key or one of its subkeys.135
let issuers = crate::core::hashing::pgp::signature_issuers(signature);136
let primary = entry["fingerprint"].as_str().unwrap_or("").to_ascii_uppercase();137
let mut components = vec![(primary.clone(), details["keyId"].as_str().unwrap_or("").to_ascii_uppercase())];138
components.extend(details["subkeys"].as_array().into_iter().flatten().map(|s| (s["fingerprint"].as_str().unwrap_or("").to_ascii_uppercase(), s["keyId"].as_str().unwrap_or("").to_ascii_uppercase())));139
let signer = issuers140
.iter()141
.find_map(|issuer| components.iter().find(|(fp, id)| !issuer.is_empty() && (fp == issuer || id == issuer)).map(|(fp, _)| fp.clone()))142
.unwrap_or(primary);143
return Ok(json!({144
"valid": true,145
"keySource": "keychain",146
"label": entry["label"],147
"primaryFingerprint": entry["fingerprint"],148
"signerFingerprint": signer,149
"userIds": details["userIds"],150
"revoked": details["revoked"],151
"expired": details["expired"],152
}));153
}154
}155
// The signature names a Keychain identity (primary key or subkey) that did not verify it: the file or the156
// signature changed after signing. Say so instead of asking for a key that is already there.157
let issuers = crate::core::hashing::pgp::signature_issuers(signature);158
let known = keychain.iter().filter(|e| e["kind"] == "openpgp").any(|entry| {159
let details = crate::core::keychain::describe(entry);160
let mut ids = vec![entry["fingerprint"].as_str().unwrap_or("").to_ascii_uppercase(), details["keyId"].as_str().unwrap_or("").to_ascii_uppercase()];161
for sub in details["subkeys"].as_array().into_iter().flatten() {162
ids.push(sub["fingerprint"].as_str().unwrap_or("").to_ascii_uppercase());163
ids.push(sub["keyId"].as_str().unwrap_or("").to_ascii_uppercase());164
}165
issuers.iter().any(|issuer| !issuer.is_empty() && ids.contains(issuer))166
});167
if known {168
return Err(CoreError::new("QUICK_SIGNATURE_INVALID", "The signature does not match this file: the file or the signature was changed after signing."));169
}170
let mut result = crate::core::hashing::pgp::verify_file(file, signature, None, keyring, "", false)?;171
result["keySource"] = json!("saved");172
Ok(result)173
}175
/// Largest public key file accepted for a one-off verification.176
pub const MAX_PUBLIC_KEY: u64 = 1024 * 1024;178
/// Normalizes a fingerprint typed by the user (spaces, lower case); empty input means "no expectation".179
pub fn expected_fingerprint(input: Option<&str>) -> CoreResult<Option<String>> {180
let text: String = input.unwrap_or("").chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_uppercase();181
if text.is_empty() {182
return Ok(None);183
}184
if ![40, 64].contains(&text.len()) || !text.chars().all(|c| c.is_ascii_hexdigit()) {185
return Err(CoreError::new("QUICK_FINGERPRINT_INVALID", "Enter the full fingerprint (40 or 64 hexadecimal characters)."));186
}187
Ok(Some(text))188
}190
/// Verifies with a public key given for this verification only (picked file or keys.ch-j.de; nothing is stored).191
/// With `expected`, the key that verified must be that fingerprint (primary key or the signing subkey).192
pub fn verify_with_key(file: &Path, signature: &[u8], key: &[u8], source: &str, expected: Option<&str>) -> CoreResult<Value> {193
let empty = std::env::temp_dir().join("chj-no-saved-keys");194
let mut result = crate::core::hashing::pgp::verify_file(file, signature, Some(key), &empty, expected.unwrap_or(""), false)?;195
result["keySource"] = json!(source);196
result["expectedMatched"] = json!(expected.is_some());197
Ok(result)198
}200
/// Fingerprints (or key IDs) the signature names as its issuer, for showing which key is needed.201
pub fn signature_issuers(signature: &[u8]) -> Vec<String> {202
crate::core::hashing::pgp::signature_issuers(signature)203
}205
#[cfg(test)]206
mod tests {207
use super::*;209
#[test]210
fn parses_only_complete_absolute_requests() {211
let abs = if cfg!(windows) { "C:\\data\\doc.pdf" } else { "/data/doc.pdf" };212
let request = parse_args(&["app", QUICK_FLAG, "sign", abs]).unwrap();213
assert_eq!(request.action, QuickAction::Sign);214
assert_eq!(request.path, PathBuf::from(abs));215
assert_eq!(parse_args(&[QUICK_FLAG, "verify", abs]).unwrap().action, QuickAction::Verify);216
assert!(parse_args(&["app", QUICK_FLAG, "sign", "relative.pdf"]).is_none());217
assert!(parse_args(&["app", QUICK_FLAG, "delete", abs]).is_none());218
assert!(parse_args(&["app", QUICK_FLAG, "sign"]).is_none());219
assert!(parse_args(&["app", abs]).is_none());220
}222
#[test]223
fn finds_signature_from_either_side() {224
let dir = tempfile::tempdir().unwrap();225
let data = dir.path().join("doc.pdf");226
std::fs::write(&data, b"data").unwrap();227
assert_eq!(locate_pair(&data).unwrap(), (data.clone(), None));228
let sig = dir.path().join("doc.pdf.sig");229
std::fs::write(&sig, b"sig").unwrap();230
assert_eq!(locate_pair(&data).unwrap(), (data.clone(), Some(sig.clone())));231
let asc = dir.path().join("doc.pdf.asc");232
std::fs::write(&asc, b"asc").unwrap();233
assert_eq!(locate_pair(&data).unwrap().1, Some(asc.clone()), ".asc wins over .sig");234
assert_eq!(locate_pair(&sig).unwrap(), (data.clone(), Some(sig.clone())), "signature selected");235
assert_eq!(signature_target(&data), asc);236
assert!(locate_pair(dir.path()).is_err(), "directory");237
assert!(locate_pair(&dir.path().join("missing")).is_err());238
}240
#[test]241
fn verifies_with_a_picked_public_key_and_names_wrong_keys_and_changed_files() {242
use crate::core::keychain;243
let dir = tempfile::tempdir().unwrap();244
let file = dir.path().join("payload.bin");245
std::fs::write(&file, b"payload checked with an external public key").unwrap();246
let (_, entry) = crate::core::openpgp::generate(&json!({"name":"External signer","version":4,"primaryCapabilities":"CS","passphrase":"fixture-password"})).unwrap();247
let (_, other) = crate::core::openpgp::generate(&json!({"name":"Somebody else","version":4,"primaryCapabilities":"CS","passphrase":"fixture-password"})).unwrap();248
let armored = keychain::sign_with(&entry, &file, "fixture-password", None).unwrap();249
let public = entry["public"].as_str().unwrap().as_bytes().to_vec();250
let result = verify_with_key(&file, armored.as_bytes(), &public, "file", None).unwrap();251
assert_eq!(result["keySource"], "file");252
assert_eq!(result["valid"], true);253
let wrong = verify_with_key(&file, armored.as_bytes(), other["public"].as_str().unwrap().as_bytes(), "file", None).unwrap_err();254
assert_eq!(wrong.code, "HASH_PGP_SIGNER_UNKNOWN");255
assert_eq!(verify(&file, armored.as_bytes(), &[], &dir.path().join("keyring")).unwrap_err().code, "HASH_PGP_KEY_REQUIRED", "unknown key: ask for it");256
let primary = entry["fingerprint"].as_str().unwrap().to_string();257
let spaced: String = primary.to_ascii_lowercase().chars().collect::<Vec<_>>().chunks(4).map(|c| c.iter().collect::<String>()).collect::<Vec<_>>().join(" ");258
let expected = expected_fingerprint(Some(&spaced)).unwrap().unwrap();259
assert_eq!(verify_with_key(&file, armored.as_bytes(), &public, "keyserver", Some(&expected)).unwrap()["expectedMatched"], true);260
let other_fp = expected_fingerprint(other["fingerprint"].as_str()).unwrap().unwrap();261
assert_eq!(verify_with_key(&file, armored.as_bytes(), &public, "file", Some(&other_fp)).unwrap_err().code, "HASH_PGP_FINGERPRINT_MISMATCH");262
assert!(expected_fingerprint(Some("ABCD")).is_err());263
assert_eq!(expected_fingerprint(Some(" ")).unwrap(), None);264
assert_eq!(signature_issuers(armored.as_bytes()).first().map(String::as_str), Some(primary.as_str()));265
std::fs::write(&file, b"changed").unwrap();266
assert!(verify_with_key(&file, armored.as_bytes(), &public, "file", None).is_err(), "changed file with the right key");267
}269
#[test]270
fn signature_selected_without_its_data_is_treated_as_data() {271
let dir = tempfile::tempdir().unwrap();272
let lone = dir.path().join("orphan.asc");273
std::fs::write(&lone, b"x").unwrap();274
assert_eq!(locate_pair(&lone).unwrap(), (lone.clone(), None));275
}277
#[test]278
fn signs_with_the_chosen_primary_or_subkey_and_verifies_from_the_keychain() {279
use crate::core::keychain;280
use pgp::composed::Deserializable;281
let dir = tempfile::tempdir().unwrap();282
let file = dir.path().join("payload.bin");283
std::fs::write(&file, b"synthetic quick-action payload").unwrap();284
let (_, entry) = crate::core::openpgp::generate(&json!({"name":"Quick fixture","version":6,"primaryCapabilities":"CS","passphrase":"fixture-password","subkeys":[{"role":"sign","algorithm":"ed25519"},{"role":"sign","algorithm":"ed25519"},{"role":"encrypt","algorithm":"curve25519"}]})).unwrap();285
let components = keychain::signing_components(&entry).unwrap();286
assert_eq!(components.len(), 3, "primary and two signing subkeys, no encryption subkey: {components:?}");287
assert_eq!(components[0]["role"], "primary");288
let encrypt = keychain::describe(&entry)["subkeys"].as_array().unwrap().iter().find(|s| s["effectiveCapabilities"] == "E").unwrap()["fingerprint"].as_str().unwrap().to_string();289
assert!(keychain::sign_with(&entry, &file, "fixture-password", Some(&encrypt)).is_err(), "encryption subkey must not sign");290
for component in &components {291
let fp = component["fingerprint"].as_str().unwrap();292
assert!(keychain::sign_with(&entry, &file, "wrong", Some(fp)).is_err());293
let armored = keychain::sign_with(&entry, &file, "fixture-password", Some(&fp.to_ascii_lowercase())).unwrap();294
let (signature, _) = pgp::composed::DetachedSignature::from_armor_single(std::io::Cursor::new(armored.as_bytes())).unwrap();295
let issuers: Vec<String> = signature.signature.config().map(|c| c.issuer_fingerprint()).unwrap_or_default().iter().map(|f| hex::encode_upper(f.as_ref())).collect();296
assert_eq!(issuers, vec![fp.to_string()], "signature made by the chosen component");297
let result = verify(&file, armored.as_bytes(), &[entry.clone()], &dir.path().join("keyring")).unwrap();298
assert_eq!(result["keySource"], "keychain");299
assert_eq!(result["primaryFingerprint"], entry["fingerprint"]);300
assert_eq!(result["signerFingerprint"], json!(fp), "reported signer is the chosen component");301
assert!(verify(&file, armored.as_bytes(), &[], &dir.path().join("keyring")).is_err(), "unknown key without the Keychain");302
std::fs::write(&file, b"changed").unwrap();303
assert_eq!(verify(&file, armored.as_bytes(), &[entry.clone()], &dir.path().join("keyring")).unwrap_err().code, "QUICK_SIGNATURE_INVALID", "changed file, known key");304
std::fs::write(&file, b"synthetic quick-action payload").unwrap();305
}306
}307
}SHA-256
3031cb1af0133810ebe472e867384df7a7a44186a3891208df0a198c183121b9CH-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-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f