diff --git a/Cargo.lock b/Cargo.lock index d5ceaa13c..1d6d2d126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,6 +840,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -853,7 +863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -866,7 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -3573,6 +3583,7 @@ dependencies = [ "printcraft-fonts", "rc2", "rsa", + "security-framework", "sha1", "sha2", "thiserror 2.0.21", @@ -4091,6 +4102,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5650,7 +5684,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", diff --git a/apps/printcraft/src/main.rs b/apps/printcraft/src/main.rs index 8c063a570..298162429 100644 --- a/apps/printcraft/src/main.rs +++ b/apps/printcraft/src/main.rs @@ -51,6 +51,7 @@ fn main() -> eframe::Result { app.restore(&json); } app.integrated_titlebar = integrated; + app.keychain_ids = cfg!(target_os = "macos"); if let Some(file) = &control_file { let client = app.attach_control(&cc.egui_ctx); match printcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7aa2543aa..e2d65c832 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -343,6 +343,17 @@ impl Automation { "accessibility_set_alt" => self.accessibility_set_alt(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, + "sign_keychain_ids" => { + #[cfg(target_os = "macos")] + let ids: Vec = printcraft_engine::sign::keychain::identities(None) + .map_err(failed)? + .iter() + .map(|id| json!({ "id": printcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "macos"))] + let ids: Vec = Vec::new(); + json!({ "count": ids.len(), "ids": ids }) + } "sign_trust" => self.sign_trust(&a)?, "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 3ae096f6a..c9bfebe9f 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -117,6 +117,13 @@ impl Automation { } fn open_id(&self, a: &Args) -> Result { + // A macOS Keychain identity: "keychain:" or "keychain:". + if let Some(r) = a.str("id")?.strip_prefix("keychain:") { + #[cfg(target_os = "macos")] + return sign::keychain::find(&format!("keychain:{r}")).map_err(failed); + #[cfg(not(target_os = "macos"))] + return Err(failed(format!("keychain:{r}: Keychain identities are only available on macOS"))); + } let path = self.resolve(a.str("id")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d7df0155e..636210442 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -539,7 +539,11 @@ pub fn tools() -> Vec { }), &["name", "password", "path"], )), - t("sign_document", "Sign a document", "Sign with a digital ID (.p12/.pfx) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_keychain_ids", "List Keychain digital IDs", "macOS: the signing identities in the user's keychains (certificate details and the keychain: reference sign_document takes). The private keys stay in the Keychain, which may ask the user to allow their use.") + .ro() + .cmd("sign.digital") + .with(schema(json!({}), &[])), + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") .cmd("sign.digital") .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 80dc1d004..938831022 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1131,6 +1131,8 @@ fn digital_ids_signing_and_validation_through_tools() { let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); + // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). + assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!( a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "wrong!", "out": "signed.pdf" })), Err(ToolError::InvalidArgs(_)) diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index eabfc6bfd..259ce0c8a 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -32,6 +32,10 @@ rc2 = "0.9" aws-lc-rs = { version = "1.18", default-features = false, features = ["aws-lc-sys", "prebuilt-nasm"] } getrandom = "0.4" +[target.'cfg(target_os = "macos")'.dependencies] +# Signing with Keychain identities (MIT OR Apache-2.0). +security-framework = "3.7" + [target.'cfg(target_arch = "wasm32")'.dependencies] # crypto-bigint asks for randomness; in the browser it comes from Web Crypto. getrandom = { version = "0.4", features = ["wasm_js"] } diff --git a/crates/sign/src/keychain.rs b/crates/sign/src/keychain.rs new file mode 100644 index 000000000..49392203c --- /dev/null +++ b/crates/sign/src/keychain.rs @@ -0,0 +1,78 @@ +//! Digital IDs from the macOS Keychain: identities (a certificate with its private key) that +//! sign through the Security framework. The private key never leaves the Keychain; macOS may +//! ask the user to allow PrintCraft to use it. + +use std::path::Path; +use std::sync::Arc; + +use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; +use security_framework::key::{Algorithm, SecKey}; +use security_framework::os::macos::keychain::SecKeychain; + +use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey}; +use crate::{Certificate, DigitalId, SignError}; + +struct KeychainKey { + key: SecKey, + rsa: bool, +} + +impl ExternalKey for KeychainKey { + fn sign(&self, alg: DigestAlg, msg: &[u8]) -> Result, SignError> { + let a = match (self.rsa, alg) { + (true, DigestAlg::Sha384) => Algorithm::RSASignatureMessagePKCS1v15SHA384, + (true, DigestAlg::Sha512) => Algorithm::RSASignatureMessagePKCS1v15SHA512, + (true, _) => Algorithm::RSASignatureMessagePKCS1v15SHA256, + (false, DigestAlg::Sha384) => Algorithm::ECDSASignatureMessageX962SHA384, + (false, DigestAlg::Sha512) => Algorithm::ECDSASignatureMessageX962SHA512, + (false, _) => Algorithm::ECDSASignatureMessageX962SHA256, + }; + self.key.create_signature(a, msg).map_err(|e| SignError::Crypto(format!("the Keychain didn't sign: {e}"))) + } +} + +/// The `keychain:` reference PrintCraft keeps for an identity. +pub fn reference(c: &Certificate) -> String { + let d = DigestAlg::Sha256.digest(&[&c.raw]); + format!("keychain:{}", d.iter().map(|b| format!("{b:02x}")).collect::()) +} + +/// The identity a reference (or a certificate common name) names. +pub fn find(reference_or_name: &str) -> Result { + identities(None)? + .into_iter() + .find(|id| { + reference(&id.certificate) == reference_or_name + || id.certificate.subject.common_name() == Some(reference_or_name.trim_start_matches("keychain:")) + }) + .ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}"))) +} + +/// The signing identities in the user's keychains (or only in the keychain file `keychain`). +/// Identities with keys PrintCraft can't use (other curves, Ed25519) are left out. +pub fn identities(keychain: Option<&Path>) -> Result, SignError> { + let mut search = ItemSearchOptions::new(); + search.class(ItemClass::identity()).load_refs(true).limit(Limit::All); + let kc; + if let Some(p) = keychain { + kc = SecKeychain::open(p).map_err(|e| SignError::Crypto(format!("{}: {e}", p.display())))?; + search.keychains(std::slice::from_ref(&kc)); + } + let found = match search.search() { + Ok(f) => f, + // "The specified item could not be found in the keychain." + Err(e) if e.code() == -25300 => return Ok(Vec::new()), + Err(e) => return Err(SignError::Crypto(format!("the Keychain couldn't be searched: {e}"))), + }; + let mut out = Vec::new(); + for r in found { + let SearchResult::Ref(Reference::Identity(id)) = r else { continue }; + let (Ok(cert), Ok(key)) = (id.certificate(), id.private_key()) else { continue }; + let Ok(certificate) = Certificate::parse(&cert.to_der()) else { continue }; + let rsa = matches!(certificate.public_key, PublicKey::Rsa { .. }); + let key = PrivateKey::external(certificate.public_key.clone(), Arc::new(KeychainKey { key, rsa })); + let friendly_name = Some(certificate.display_name()); + out.push(DigitalId { key, certificate, chain: Vec::new(), friendly_name }); + } + Ok(out) +} diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index fc37db803..9b125743f 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -223,6 +223,14 @@ enum Inner { Rsa, P256(p256::ecdsa::SigningKey), P384(p384::ecdsa::SigningKey), + /// A key held elsewhere (the macOS Keychain, a token) that signs on request. + External(std::sync::Arc), +} + +/// A private key PrintCraft can't read, only ask to sign (OS key stores, tokens). +pub trait ExternalKey: Send + Sync { + /// Sign `msg`, hashing it with `alg` (PKCS #1 v1.5 for RSA, DER-encoded ECDSA). + fn sign(&self, alg: DigestAlg, msg: &[u8]) -> Result, SignError>; } /// A private key that can sign. @@ -240,6 +248,17 @@ impl std::fmt::Debug for PrivateKey { } impl PrivateKey { + /// A key held outside PrintCraft's memory (its public half is `public`). It can't be + /// exported to a PKCS #12 file. + pub fn external(public: PublicKey, key: std::sync::Arc) -> PrivateKey { + PrivateKey { inner: Inner::External(key), public, pkcs8: Vec::new() } + } + + /// Whether the key lives outside the app (and so can't be exported). + pub fn is_external(&self) -> bool { + matches!(self.inner, Inner::External(_)) + } + /// From a PKCS#8 `PrivateKeyInfo`. pub fn from_pkcs8(info: &[u8]) -> Result { let t = Tlv::parse_all(info)?.children()?; @@ -387,6 +406,7 @@ impl PrivateKey { let s: p256::ecdsa::Signature = k.sign_prehash(&alg.digest(&[msg])).map_err(|e| SignError::Crypto(e.to_string()))?; Ok(s.to_der().as_bytes().to_vec()) } + Inner::External(k) => k.sign(alg, msg), Inner::P384(k) => { use p384::ecdsa::signature::hazmat::PrehashSigner; let s: p384::ecdsa::Signature = k.sign_prehash(&alg.digest(&[msg])).map_err(|e| SignError::Crypto(e.to_string()))?; diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index 7721cfee0..da35ffeb2 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -7,6 +7,8 @@ pub mod cms; pub mod der; +#[cfg(target_os = "macos")] +pub mod keychain; pub mod keys; pub mod pdf; pub mod pkcs12; diff --git a/crates/sign/tests/data/README.md b/crates/sign/tests/data/README.md index 18524c7ee..a77b448d6 100644 --- a/crates/sign/tests/data/README.md +++ b/crates/sign/tests/data/README.md @@ -8,6 +8,7 @@ The password of every `.p12` is `test`. | `rsa-aes.p12` | RSA 2048 self-signed "Test Signer RSA"; PBES2 AES-256-CBC, SHA-256 MAC (OpenSSL 3 default) | | `rsa-legacy.p12` | the same key and certificate; RC2-40 certificates, 3DES key, SHA-1 MAC (`-legacy`) | | `ec-p256.p12` | P-256 self-signed "Test Signer EC", no friendly name | +| `ec-legacy.p12` | `ec-p256.p12` re-exported with `-legacy` (3DES, SHA-1 MAC): the format macOS `security import` accepts | | `ec-p384.p12` | P-384 self-signed "Test Signer P384"; AES-128-CBC, SHA-1 MAC | | `chain.p12` | RSA 2048 "Ada Lovelace" issued by a P-256 test root, chain included | | `rsa.crt.pem`, `ca.crt.pem` | the RSA certificate and the test root alone | @@ -20,6 +21,7 @@ openssl pkcs12 -export -inkey rsa.key -in rsa.crt -out rsa-aes.p12 -passout pass openssl pkcs12 -export -legacy -inkey rsa.key -in rsa.crt -out rsa-legacy.p12 -passout pass:test -name "Test Signer RSA" openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout ec.key -out ec.crt -days 3650 -subj "/CN=Test Signer EC/O=PrintCraft Tests" -set_serial 2002 openssl pkcs12 -export -inkey ec.key -in ec.crt -out ec-p256.p12 -passout pass:test +openssl pkcs12 -export -legacy -inkey ec.key -in ec.crt -out ec-legacy.p12 -passout pass:test -name "Test Signer EC" openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-384 -nodes -keyout ec3.key -out ec3.crt -days 3650 -subj "/CN=Test Signer P384" -set_serial 3003 openssl pkcs12 -export -inkey ec3.key -in ec3.crt -out ec-p384.p12 -passout pass:test -certpbe AES-128-CBC -keypbe AES-128-CBC -macalg sha1 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout ca.key -out ca.crt -days 3650 -subj "/CN=PrintCraft Test Root CA/O=PrintCraft Tests" -set_serial 1 -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" diff --git a/crates/sign/tests/data/ec-legacy.p12 b/crates/sign/tests/data/ec-legacy.p12 new file mode 100644 index 000000000..caf621189 Binary files /dev/null and b/crates/sign/tests/data/ec-legacy.p12 differ diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index d4bb8497a..4b0eafc8f 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -206,3 +206,49 @@ trailer << /Root 1 0 R >> assert_eq!((s.status, s.certify, s.modification.clone()), (Status::Unknown, certify, Modification::None), "{:?}", s.details); } } + +/// Signing with Keychain identities, in a throwaway keychain file. Ignored by default: creating +/// a keychain touches the user's keychain search list (restored afterwards). +#[cfg(target_os = "macos")] +#[test] +#[ignore = "creates a temporary macOS keychain; run with --ignored"] +fn signing_with_keychain_identities() { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("printcraft-keychain-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let kc = dir.join("test.keychain-db"); + let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap(); + let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap(); + let saved: Vec = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect(); + let k = kc.to_str().unwrap(); + assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success()); + let restore = || { + let mut args = vec!["list-keychains", "-d", "user", "-s"]; + args.extend(saved.iter().map(String::as_str)); + sec(&args); + }; + restore(); + // Everything that can fail runs before the clean-up below. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + sec(&["unlock-keychain", "-p", "pc-test", k]); + // macOS imports only legacy-format PKCS #12 (SHA-1 MAC). + for f in ["rsa-legacy.p12", "ec-legacy.p12"] { + let p = format!("{}/tests/data/{f}", env!("CARGO_MANIFEST_DIR")); + let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]); + assert!(out.status.success(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); + } + let ids = printcraft_sign::keychain::identities(Some(&kc)).unwrap(); + assert_eq!(ids.len(), 2, "{ids:?}"); + for id in &ids { + assert!(id.key.is_external()); + let signed = printcraft_sign::sign(&open(&fixture()), id, &opts()).unwrap(); + let trusted = signatures(&open(&signed), &signed, &TrustStore { certs: vec![id.certificate.clone()] }); + let s = trusted.iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + } + })); + sec(&["delete-keychain", k]); + restore(); + let _ = std::fs::remove_dir_all(&dir); + result.unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19b686a80..69c38707f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -292,6 +292,8 @@ pub struct PrintCraftApp { pub a11y: a11y_ui::A11yState, pub a11y_skipped: std::collections::BTreeSet, pub alt_draft: a11y_ui::AltDraft, + /// List the macOS Keychain's signing identities among the digital IDs (the desktop app). + pub keychain_ids: bool, /// The last space audit. pub space_audit: Vec, /// Combine files: the files staged so far. @@ -422,6 +424,7 @@ impl PrintCraftApp { a11y: a11y_ui::A11yState::default(), a11y_skipped: Default::default(), alt_draft: Default::default(), + keychain_ids: false, space_audit: Vec::new(), combine_draft: Vec::new(), custom_stamps: Vec::new(), @@ -745,7 +748,8 @@ impl PrintCraftApp { "recent": self.recent, "theme": self.theme, "signature": self.signature, - "digital_ids": self.digital_ids, + // Keychain identities are read from macOS each time. + "digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:")).collect::>(), "trusted": trusted, "custom_stamps": stamps_ui::encode(&self.custom_stamps), }) diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 8ac60b4fa..584813247 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -162,6 +162,14 @@ fn id_dir() -> Option { crate::recovery::RecoveryStore::default_dir().and_then(|d| d.parent().map(|p| p.join("Digital IDs"))) } +/// A Keychain identity by its `keychain:` reference. +fn keychain_id(reference: &str) -> Result { + #[cfg(target_os = "macos")] + return sign::keychain::find(reference).map_err(|e| e.to_string()); + #[cfg(not(target_os = "macos"))] + Err(format!("{reference}: Keychain identities are only available on macOS")) +} + pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { DigitalIdEntry { path: path.to_string(), @@ -175,10 +183,27 @@ pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { impl PrintCraftApp { /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { + self.refresh_keychain_ids(); self.sign_draft = Some(SignDraft::new(page, rect, field, certify, self.digital_ids.len())); self.dialog = Some(crate::Dialog::Sign); } + /// List the macOS Keychain's signing identities (after the file-based IDs). + fn refresh_keychain_ids(&mut self) { + self.digital_ids.retain(|e| !e.path.starts_with("keychain:")); + #[cfg(target_os = "macos")] + if self.keychain_ids { + match sign::keychain::identities(None) { + Ok(ids) => { + for id in ids { + self.digital_ids.push(entry_for(&sign::keychain::reference(&id.certificate), &id.certificate)); + } + } + Err(e) => self.notify(format!("The Keychain's digital IDs couldn't be listed: {e}")), + } + } + } + /// Add a digital ID file to the list (or select it if it's there). pub fn add_digital_id(&mut self, path: &str, cert: &Certificate) -> usize { if let Some(i) = self.digital_ids.iter().position(|e| e.path == path) { @@ -246,11 +271,15 @@ impl PrintCraftApp { let Some((_, doc_id)) = self.active_ids() else { return Err("no document".into()) }; let d = self.sign_draft.clone().ok_or("nothing to sign")?; let entry = d.selected.and_then(|i| self.digital_ids.get(i)).cloned().ok_or("Choose a digital ID.")?; - let bytes = std::fs::read(&entry.path).map_err(|e| format!("{}: {e}", entry.path))?; - let id = sign::pkcs12::open(&bytes, &d.password).map_err(|e| match e { - sign::SignError::WrongPassword => "The password is incorrect.".to_string(), - e => e.to_string(), - })?; + let id = if entry.path.starts_with("keychain:") { + keychain_id(&entry.path)? + } else { + let bytes = std::fs::read(&entry.path).map_err(|e| format!("{}: {e}", entry.path))?; + sign::pkcs12::open(&bytes, &d.password).map_err(|e| match e { + sign::SignError::WrongPassword => "The password is incorrect.".to_string(), + e => e.to_string(), + })? + }; let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_string()); let opts = SignOptions { field: d.field.clone(), @@ -370,7 +399,8 @@ fn choose(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, t.accent); ui.painter().text(rect.min + vec2(40.0, 9.0), egui::Align2::LEFT_TOP, &e.name, theme::semibold(13.0), t.text); let sub = format!( - "{}Issued by: {}, Expires: {}", + "{}{}Issued by: {}, Expires: {}", + if e.path.starts_with("keychain:") { "Keychain · " } else { "" }, if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, e.issuer, e.expires @@ -538,6 +568,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { return false; }; title(ui, &format!("{} as \"{}\"", if d.certify.is_some() { "Certify" } else { "Sign" }, entry.name)); + let in_keychain = entry.path.starts_with("keychain:"); let mut close = false; let mut go = false; if d.rect.is_some() || d.field.is_some() { @@ -579,10 +610,17 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { let l = ui.label("Location"); ui.add(egui::TextEdit::singleline(&mut d.location).hint_text("Optional").desired_width(260.0)).labelled_by(l.id); ui.end_row(); - let l = ui.label("Digital ID password"); - let r = ui.add(egui::TextEdit::singleline(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - go = true; + if in_keychain { + ui.label(""); + ui.label( + egui::RichText::new("The key is in the macOS Keychain, which may ask to allow PrintCraft to use it.").small().color(t.text_muted), + ); + } else { + let l = ui.label("Digital ID password"); + let r = ui.add(egui::TextEdit::singleline(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + go = true; + } } ui.end_row(); }); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d20971f7d..18bfcd287 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5759,11 +5759,11 @@ title = "Sign with macOS Keychain identities" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital"] +tools = ["sign_keychain_ids", "sign_document"] +evidence = ["crates/sign/tests/pdf.rs::signing_with_keychain_identities", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] +notes = "Security framework identities (RSA PKCS #1 v1.5, ECDSA P-256/P-384) listed with the digital IDs in Sign with a Digital ID; the key never leaves the Keychain (ExternalKey); keychain: for agents. The test uses a throwaway keychain file (run with --ignored)" [[feature]] id = "sign.windows-cert-store"