From edce549a327c72e6daab3796de7e0a7f2523c817 Mon Sep 17 00:00:00 2001 From: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:02:52 -0400 Subject: [PATCH] M9: sign with the Windows certificate store (#179) (#304) List Current User Personal CNG identities alongside file digital IDs and sign without exporting their private keys through safe rustls-cng wrappers. Expose Windows store references through automation and the desktop dialogs, with RSA and ECDSA validation, certificate cleanup, and UI regression coverage. Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 10 ++-- Cargo.lock | 12 ++++ ROADMAP.md | 6 +- apps/pdfcraft/src/main.rs | 2 +- crates/automation/src/lib.rs | 11 ++++ crates/automation/src/signing.rs | 6 ++ crates/automation/src/tools.rs | 8 ++- crates/automation/tests/automation.rs | 7 +++ crates/sign/Cargo.toml | 4 ++ crates/sign/README.md | 5 +- crates/sign/src/lib.rs | 2 + crates/sign/src/windows.rs | 81 +++++++++++++++++++++++++++ crates/sign/tests/pdf.rs | 72 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 10 ++-- crates/ui-egui/src/sign_ui.rs | 51 ++++++++++++++--- crates/ui-egui/tests/signing.rs | 49 ++++++++++++++++ parity/acrobat-features.toml | 10 ++-- 22 files changed, 326 insertions(+), 30 deletions(-) create mode 100644 crates/sign/src/windows.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 323d05e0b8..b445d5137e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4f002361b308776512acb1e0fad553ba51af7287f60e675846861bd44cc36e46" +sha256 = "fbc6e61d3f602ada7f292a034174070c00c7c789290334714f3fff5c013af2ad" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e31ae39637ae02e9aec4c887ca1c52924ba7161f78219a82b1c49e3502e04222" +sha256 = "39f83ab539f6380df995a855c1c8e8c036bcd060e06b9d1b027771e1e7d6e7ef" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2ec17cb75e1c5936921e55a8a210128383cfd4fb03211d1acd750c1bec1a5d19" +sha256 = "4c409434bd3385b56b561c36ff819e19e574bda8979d16cd53232cb5bea3c65f" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2587fb0e75d5a8c1e2bb9c918cbb2d8adb34835c8ddec77500ba6517555c0c55" +sha256 = "d322ac956218961d22828726a7fe0752b0a68603679c2dcf17c5aa840995e124" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9ce3369dac8963a90633b4191680587dd43003c1cf5edf24fe641d8d0e8c6b3c" +sha256 = "4b14f85bc0890823c30bff37723ecb05bc5ea5a8583334644168b08043ab6bde" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/Cargo.lock b/Cargo.lock index 8e207b71a1..2520b60a4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3764,6 +3764,7 @@ dependencies = [ "pdfcraft-fonts", "rc2", "rsa", + "rustls-cng", "security-framework", "sha1", "sha2", @@ -4738,6 +4739,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-cng" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeed9c3e96253f36d1cfdd593388ecca775b532b6ff0c649a3010f6af60fe70" +dependencies = [ + "bitflags 2.13.2", + "rustls", + "windows-sys 0.61.2", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" diff --git a/ROADMAP.md b/ROADMAP.md index e6478a65c6..b1c29e5f91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ Read this before choosing work. The feature table above counts what exists; this | B View | 55% | Shell, find, panels, tiles, web build. **Rendering is borrowed (`hayro`)**, so the 7 rendering P0s are only partial | | D Organize | 55% | Pages, combine, split, bookmarks, labels. Missing: replace pages, transitions | | G Protect | 54% | Passwords, permissions, redaction, sanitize. Missing: certificate security, redaction codes | -| H Sign | 48% | PAdES B-B signing and validation. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, Windows store, PKCS #11 | +| H Sign | 48% | PAdES B-B signing and validation; macOS Keychain and Windows CNG certificate store signing. Missing: timestamps (B-T), LTV (DSS/OCSP/CRL), FieldMDP, smart cards, PKCS #11 | | C Edit | 46% | Added text and images stay editable; header/footer/watermark. Missing: robust editing of existing text and images (fonts, subsets, reflow) | | L Print | 36% | Acrobat-style sizing, n-up, booklet, CUPS. Missing: Windows and web printing, production options | | J Create | 32% | From images, text, clipboard; Word/HTML/RTF export. Missing: Office import, Excel/PowerPoint export | @@ -98,7 +98,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding | -| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain signing, certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, Windows store, PKCS #11 | +| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | @@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log +- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged. + Newest first. One line per session: the date, what moved, and the new overall percentage. - **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 4ac79a33c2..8fae5b5844 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -156,7 +156,7 @@ fn main() -> eframe::Result { } app.integrated_titlebar = integrated; app.update_source = Some(std::sync::Arc::new(updates::latest_release)); - app.keychain_ids = cfg!(target_os = "macos"); + app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1ca2031a00..e79831b4ea 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -578,6 +578,17 @@ impl Automation { let ids: Vec = Vec::new(); json!({ "count": ids.len(), "ids": ids }) } + "sign_windows_ids" => { + #[cfg(target_os = "windows")] + let ids: Vec = pdfcraft_engine::sign::windows::identities() + .map_err(failed)? + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "windows"))] + 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 e102841afc..f45ee5aa89 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -125,6 +125,12 @@ impl Automation { #[cfg(not(target_os = "macos"))] return Err(failed(format!("keychain:{r}: Keychain identities are only available on macOS"))); } + if let Some(r) = a.str("id")?.strip_prefix("windows:") { + #[cfg(target_os = "windows")] + return sign::windows::find(&format!("windows:{r}")).map_err(failed); + #[cfg(not(target_os = "windows"))] + return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows"))); + } 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 e0b1f5aa5f..641a59176c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -570,16 +570,20 @@ pub fn tools() -> Vec { }), &["name", "password", "path"], )), + t("sign_windows_ids", "List Windows store digital IDs", "Windows: signing identities in the Current User Personal certificate store (certificate details and the windows: reference sign_document takes). Private keys remain in CNG; Windows may ask permission to use them.") + .ro() + .cmd("sign.digital") + .with(schema(json!({}), &[])), 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).") + 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, or on Windows a store identity: \"windows:\" from sign_windows_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!({ "doc": doc(), - "id": { "type": "string", "description": "Path of the digital ID (.p12 / .pfx)." }, + "id": { "type": "string", "description": "Digital ID file (.p12 / .pfx), keychain: reference on macOS, or windows: reference on Windows." }, "password": { "type": "string" }, "field": { "type": "string" }, "page": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index bfa1ec1cb4..dcff8d57e4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1631,6 +1631,13 @@ fn digital_ids_signing_and_validation_through_tools() { 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": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); + let store = ok(&mut a, "sign_windows_ids", json!({})); + let ids = store["ids"].as_array().unwrap(); + assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); + assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); + #[cfg(not(windows))] + assert!(ids.is_empty()); 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 a09a78bde2..ccd75f7bec 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -33,6 +33,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 = "windows")'.dependencies] +# Safe Windows certificate store and CNG signing wrappers (MIT OR Apache-2.0). +rustls-cng = { version = "0.7.1", default-features = false } + [target.'cfg(target_os = "macos")'.dependencies] # Signing with Keychain identities (MIT OR Apache-2.0). security-framework = "3.7" diff --git a/crates/sign/README.md b/crates/sign/README.md index 924cb8fdb6..f77e523250 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -44,7 +44,10 @@ for s in signatures(&doc, &bytes, &trust) { // li signer's chain and a verified revocation invalidates the signature. Not yet: revocation fetching (AIA/CRLDP extraction and a fetcher), timestamp-server -configuration, FieldMDP locks, certificate security, OS key stores and PKCS #11 tokens. +configuration, FieldMDP locks, certificate security, smart cards and PKCS #11 tokens. +macOS Keychain and Windows Current User Personal (My) store identities sign through +`ExternalKey` without exporting private keys. Windows CNG supports RSA PKCS #1 v1.5 and +ECDSA P-256/P-384; the store integration is tested with software-backed keys. - **Validation:** `/ByteRange` and the CMS are read from the file's own bytes; the digest, the signature value and the signer's chain (against a `TrustStore`) are checked. Later revisions are diffed against the signed one, and the changes are classified (signing, form diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index df3c80c6bc..b35f8ae93f 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -17,6 +17,8 @@ pub mod pdf; pub mod pkcs12; pub mod revocation; pub mod timestamp; +#[cfg(target_os = "windows")] +pub mod windows; pub mod x509; pub use der::Time; diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs new file mode 100644 index 0000000000..fa3c53784e --- /dev/null +++ b/crates/sign/src/windows.rs @@ -0,0 +1,81 @@ +//! Signing identities in the Windows Current User Personal ("My") certificate store. +//! Private keys remain in CNG; Windows may ask permission to use a key. + +use std::sync::Arc; + +use rustls_cng::key::{AlgorithmGroup, NCryptKey, SignaturePadding}; +use rustls_cng::store::{CertStore, CertStoreType}; + +use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey}; +use crate::{Certificate, DigitalId, SignError}; + +struct WindowsKey { + key: NCryptKey, + public: PublicKey, +} + +impl ExternalKey for WindowsKey { + fn sign(&self, alg: DigestAlg, msg: &[u8]) -> Result, SignError> { + let digest = alg.digest(&[msg]); + // CNG adds the DigestInfo for PKCS #1 v1.5 when given PKCS1 padding. + let padding = if matches!(self.public, PublicKey::Rsa { .. }) { SignaturePadding::Pkcs1 } else { SignaturePadding::None }; + let raw = self + .key + .sign(&digest, padding) + .map_err(|e| SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {e}")))?; + match &self.public { + PublicKey::Rsa { .. } => Ok(raw), + PublicKey::P256(_) => p256::ecdsa::Signature::from_slice(&raw) + .map(|s| s.to_der().as_bytes().to_vec()) + .map_err(|e| SignError::Crypto(format!("CNG returned an invalid P-256 signature: {e}"))), + PublicKey::P384(_) => p384::ecdsa::Signature::from_slice(&raw) + .map(|s| s.to_der().as_bytes().to_vec()) + .map_err(|e| SignError::Crypto(format!("CNG returned an invalid P-384 signature: {e}"))), + } + } +} + +/// The `windows:` reference kept for an identity. +pub fn reference(certificate: &Certificate) -> String { + let digest = DigestAlg::Sha256.digest(&[&certificate.raw]); + format!("windows:{}", digest.iter().map(|b| format!("{b:02x}")).collect::()) +} + +/// Find an identity by its reference or subject common name. +pub fn find(reference_or_name: &str) -> Result { + identities()? + .into_iter() + .find(|id| { + reference(&id.certificate) == reference_or_name + || id.certificate.subject.common_name() == Some(reference_or_name.strip_prefix("windows:").unwrap_or(reference_or_name)) + }) + .ok_or_else(|| SignError::Crypto(format!("no Windows certificate store identity {reference_or_name}"))) +} + +/// List usable RSA, P-256 and P-384 CNG identities in Current User > Personal. +/// Certificates with unsupported keys or without an accessible private key are skipped. +pub fn identities() -> Result, SignError> { + let store = CertStore::open(CertStoreType::CurrentUser, "My") + .map_err(|e| SignError::Crypto(format!("the Windows Current User Personal store couldn't be opened: {e}")))?; + let contexts = store.find_all().map_err(|e| SignError::Crypto(format!("the Windows certificate store couldn't be searched: {e}")))?; + let mut out = Vec::new(); + for context in contexts { + let Ok(certificate) = Certificate::parse(context.as_der()) else { continue }; + // Enumeration must not open permission or PIN dialogs. Signing may prompt later. + let Ok(mut key) = context.acquire_key(true) else { continue }; + let supported = match &certificate.public_key { + PublicKey::Rsa { .. } => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Rsa), + PublicKey::P256(_) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == 256), + PublicKey::P384(_) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == 384), + }; + if !supported { + continue; + } + key.set_silent(false); + let public = certificate.public_key.clone(); + let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { key, public })); + let friendly_name = Some(certificate.display_name()); + out.push(DigitalId { key, certificate, chain: Vec::new(), friendly_name }); + } + Ok(out) +} diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index dae2d334ab..aa44d0f7ac 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -637,3 +637,75 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() { let token = pdfcraft_sign::timestamp::parse_response(&resp, &q).unwrap(); assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time)); } + +#[cfg(windows)] +#[test] +fn windows_store_enumeration_and_missing_identity() { + assert!(pdfcraft_sign::windows::identities().is_ok()); + assert!(pdfcraft_sign::windows::find("windows:no such signer").is_err()); +} + +/// Uses only newly created software-backed CNG keys, removed even when signing fails. +#[cfg(windows)] +#[test] +#[ignore = "creates temporary certificates in the Windows Current User Personal store"] +fn signing_with_windows_store_identities() { + use std::process::Command; + fn powershell(script: &str) -> std::process::Output { + // Load the certificate provider explicitly in a profile-free Windows PowerShell child. + let script = format!( + r#"$ErrorActionPreference='Stop'; Import-Module "$PSHOME\Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1"; {script}"# + ); + Command::new("powershell.exe").args(["-NoProfile", "-NonInteractive", "-Command", &script]).output().unwrap() + } + struct Certificates(Vec); + impl Drop for Certificates { + fn drop(&mut self) { + for thumbprint in &self.0 { + let out = + powershell(&format!("$ErrorActionPreference='Stop'; Remove-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{thumbprint}' -DeleteKey")); + if !out.status.success() { + eprintln!("test certificate cleanup failed: {}", String::from_utf8_lossy(&out.stderr)); + } + } + } + } + let mut created = Certificates(Vec::new()); + let mut random = [0u8; 16]; + getrandom::fill(&mut random).unwrap(); + let unique: String = random.iter().map(|b| format!("{b:02x}")).collect(); + for algorithm in ["RSA", "ECDSA_nistP256", "ECDSA_nistP384"] { + let name = format!("PdfCraft Test {unique} {algorithm}"); + let rsa = if algorithm == "RSA" { "-KeyLength 2048" } else { "" }; + let out = powershell(&format!( + "$ErrorActionPreference='Stop'; $c=New-SelfSignedCertificate -Subject 'CN={name}' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Provider 'Microsoft Software Key Storage Provider' -KeyAlgorithm {algorithm} {rsa} -KeyUsage DigitalSignature -KeyExportPolicy NonExportable; $c.Thumbprint" + )); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let thumbprint = String::from_utf8(out.stdout).unwrap().trim().to_string(); + assert_eq!(thumbprint.len(), 40); + assert!(thumbprint.bytes().all(|b| b.is_ascii_hexdigit())); + created.0.push(thumbprint); + let listed = pdfcraft_sign::windows::identities().unwrap(); + let listed_id = listed.iter().find(|id| id.certificate.subject.common_name() == Some(name.as_str())).expect("new identity listed"); + let reference = pdfcraft_sign::windows::reference(&listed_id.certificate); + let id = pdfcraft_sign::windows::find(&reference).unwrap(); + assert_eq!(pdfcraft_sign::windows::find(&format!("windows:{name}")).unwrap().certificate.raw, id.certificate.raw); + assert!(id.key.is_external()); + let mut options = opts(); + let now = powershell("(Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss')"); + assert!(now.status.success()); + options.date = format!("D:{}Z", String::from_utf8(now.stdout).unwrap().trim()); + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &options).unwrap(); + let validated = signatures(&open(&signed), &signed, &TrustStore { certs: vec![id.certificate.clone()] }); + let signature = validated.iter().find(|s| s.signed).unwrap(); + assert_eq!(signature.status, Status::Valid, "{:?}", signature.details); + assert_eq!(signature.modification, Modification::None); + assert_eq!(signature.signer.as_deref(), Some(name.as_str())); + } + let thumbprints = created.0.clone(); + drop(created); + for thumbprint in thumbprints { + let out = powershell(&format!("if (Test-Path -LiteralPath 'Cert:\\CurrentUser\\My\\{thumbprint}') {{ exit 1 }}")); + assert!(out.status.success(), "test certificate remains: {thumbprint}"); + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 972d8158c6..dce2b18afd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -788,6 +788,7 @@ Sign with a Digital ID Firmar con un ID digital Choose the digital ID that you want to use for signing: Elige el ID digital que quieres usar para firmar: Keychain · Llavero · + Windows store · Almacén de Windows · Issued by: Emitido por: , Expires: , caduca: No digital IDs yet. Aún no hay ningún ID digital. @@ -817,6 +818,7 @@ Annotations, form fill-in, and digital signatures Anotaciones, rellenado de formularios y firmas digitales Form fill-in and digital signatures Rellenado de formularios y firmas digitales The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. La clave está en el Llavero de macOS, que puede pedirte permiso para que PdfCraft la use. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. La clave está en el almacén de certificados de Windows, que puede pedirte permiso para que PdfCraft la use. Digital ID password Contraseña del ID digital Back Atrás Validate all Validar todas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62d6871283..f3c2c18f50 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -788,6 +788,7 @@ Sign with a Digital ID Signer avec un ID numérique Choose the digital ID that you want to use for signing: Choisissez l'ID numérique à utiliser pour signer : Keychain · Trousseau · + Windows store · Magasin Windows · Issued by: Émis par : , Expires: , expire : No digital IDs yet. Aucun ID numérique pour l'instant. @@ -817,6 +818,7 @@ Annotations, form fill-in, and digital signatures Annotations, remplissage de formulaire et signatures numériques Form fill-in and digital signatures Remplissage de formulaire et signatures numériques The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. La clé est dans le Trousseau de macOS, qui peut demander l'autorisation d'utilisation par PdfCraft. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. La clé est dans le magasin de certificats Windows, qui peut demander l'autorisation d'utilisation par PdfCraft. Digital ID password Mot de passe de l'ID numérique Back Retour Validate all Tout valider diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0c1b41c3a6..a829bd6848 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -794,6 +794,7 @@ Sign with a Digital ID デジタル ID で署名 Choose the digital ID that you want to use for signing: 署名に使用するデジタル ID を選択してください: Keychain · キーチェーン · + Windows store · Windows 証明書ストア · Issued by: 発行者: , Expires: 、有効期限: No digital IDs yet. デジタル ID はまだありません。 @@ -823,6 +824,7 @@ Annotations, form fill-in, and digital signatures 注釈、フォーム入力、デジタル署名 Form fill-in and digital signatures フォーム入力とデジタル署名 The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. 鍵は macOS のキーチェーンに保存されています。PdfCraft に使用を許可するか確認される場合があります。 + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. 鍵は Windows の証明書ストアに保存されています。PdfCraft に使用を許可するか確認される場合があります。 Digital ID password デジタル ID のパスワード Back 戻る Validate all すべて検証 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 60b41a3404..f073b480db 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -444,6 +444,7 @@ Sign with a Digital ID 使用数字 ID 签名 Choose the digital ID that you want to use for signing: 选择要用于签名的数字 ID: Keychain · 钥匙串 · + Windows store · Windows 证书存储 · Issued by: 颁发者: , Expires: ,有效期至: No digital IDs yet. 尚无数字 ID。 @@ -473,6 +474,7 @@ Annotations, form fill-in, and digital signatures 注释、填写表单和数字签名 Form fill-in and digital signatures 填写表单和数字签名 The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. 密钥位于 macOS 钥匙串中,系统可能会询问是否允许 PdfCraft 使用它。 + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. 密钥位于 Windows 证书存储中,系统可能会询问是否允许 PdfCraft 使用它。 Digital ID password 数字 ID 密码 Back 返回 Validate all 全部验证 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8ad3a99a00..8c421725df 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -443,6 +443,7 @@ Sign with a Digital ID 使用數位 ID 簽署 Choose the digital ID that you want to use for signing: 選擇要用於簽名的數位 ID: Keychain · 鑰匙串 · + Windows store · Windows 憑證存放區 · Issued by: 頒發者: , Expires: ,有效期至: No digital IDs yet. 尚無數位 ID。 @@ -472,6 +473,7 @@ Annotations, form fill-in, and digital signatures 註解、填寫表單和數位簽章 Form fill-in and digital signatures 填寫表單和數位簽章 The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. 金鑰位於 macOS 鑰匙串中,系統可能會詢問是否允許 PdfCraft 使用它。 + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. 金鑰位於 Windows 憑證存放區中,系統可能會詢問是否允許 PdfCraft 使用它。 Digital ID password 數位 ID 密碼 Back 返回 Validate all 全部驗證 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9e0b133e9c..84daa9e410 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -420,8 +420,8 @@ pub struct PdfCraftApp { 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, + /// List the OS key store's signing identities among the digital IDs (the desktop app). + pub os_key_store_ids: bool, pub cert_viewer: Option, /// The last space audit. pub space_audit: Vec, @@ -622,7 +622,7 @@ impl PdfCraftApp { a11y: a11y_ui::A11yState::default(), a11y_skipped: Default::default(), alt_draft: Default::default(), - keychain_ids: false, + os_key_store_ids: false, cert_viewer: None, space_audit: Vec::new(), combine_draft: Vec::new(), @@ -1065,8 +1065,8 @@ impl PdfCraftApp { "signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None }, "signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None }, "initials": self.initials, - // Keychain identities are read from macOS each time. - "digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:")).collect::>(), + // macOS Keychain and Windows store identities are read from their OS key stores each time. + "digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")).collect::>(), "trusted": trusted, "custom_stamps": stamps_ui::encode(&self.custom_stamps), "javascript": self.session.javascript(), diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index cce6eb76aa..4a09e40b78 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -170,6 +170,14 @@ fn keychain_id(reference: &str) -> Result { Err(format!("{reference}: Keychain identities are only available on macOS")) } +/// A Windows store identity by its `windows:` reference. +fn windows_id(reference: &str) -> Result { + #[cfg(target_os = "windows")] + return sign::windows::find(reference).map_err(|e| e.to_string()); + #[cfg(not(target_os = "windows"))] + Err(format!("{reference}: Windows certificate store identities are only available on Windows")) +} + pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { DigitalIdEntry { path: path.to_string(), @@ -183,16 +191,16 @@ pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry { impl PdfCraftApp { /// 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.refresh_os_key_store_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:")); + /// List OS key store signing identities (after the file-based IDs). + fn refresh_os_key_store_ids(&mut self) { + self.digital_ids.retain(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")); #[cfg(target_os = "macos")] - if self.keychain_ids { + if self.os_key_store_ids { match sign::keychain::identities(None) { Ok(ids) => { for id in ids { @@ -202,6 +210,17 @@ impl PdfCraftApp { Err(e) => self.notify_fmt("The Keychain's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]), } } + #[cfg(target_os = "windows")] + if self.os_key_store_ids { + match sign::windows::identities() { + Ok(ids) => { + for id in ids { + self.digital_ids.push(entry_for(&sign::windows::reference(&id.certificate), &id.certificate)); + } + } + Err(e) => self.notify_fmt("The Windows store's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]), + } + } } /// Add a digital ID file to the list (or select it if it's there). @@ -278,6 +297,8 @@ impl PdfCraftApp { let entry = d.selected.and_then(|i| self.digital_ids.get(i)).cloned().ok_or("Choose a digital ID.")?; let id = if entry.path.starts_with("keychain:") { keychain_id(&entry.path)? + } else if entry.path.starts_with("windows:") { + windows_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 { @@ -429,7 +450,13 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { 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!( "{keychain}{email}{issued}{issuer}{expires}{date}", - keychain = if e.path.starts_with("keychain:") { tl!("Keychain · ").to_string() } else { String::new() }, + keychain = if e.path.starts_with("keychain:") { + tl!("Keychain · ").to_string() + } else if e.path.starts_with("windows:") { + tl!("Windows store · ").to_string() + } else { + String::new() + }, email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, issued = tl!("Issued by: "), issuer = e.issuer, @@ -622,7 +649,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { &[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)], ), ); - let in_keychain = entry.path.starts_with("keychain:"); + let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:"); let mut close = false; let mut go = false; if d.rect.is_some() || d.field.is_some() { @@ -664,10 +691,16 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let l = ui.label(tl!("Location")); ui.add(egui::TextEdit::singleline(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); ui.end_row(); - if in_keychain { + if in_os_key_store { ui.label(""); ui.label( - egui::RichText::new(tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.")).small().color(t.text_muted), + egui::RichText::new(if entry.path.starts_with("windows:") { + tl!("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.") + } else { + tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.") + }) + .small() + .color(t.text_muted), ); } else { let l = ui.label(tl!("Digital ID password")); diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index 18a387b0d5..22f82c8deb 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -244,3 +244,52 @@ fn clicking_an_empty_signature_field_signs_it() { let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); assert!(sig.signed && sig.visible); } + +#[test] +fn os_store_identities_are_not_persisted() { + let mut app = PdfCraftApp::new(); + for path in ["windows:Store signer", "keychain:Store signer", "file-id.p12"] { + app.digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry { + path: path.into(), + name: "Signer".into(), + issuer: "Test issuer".into(), + email: String::new(), + expires: "2030.01.01".into(), + }); + } + let saved = app.persist(); + let settings: serde_json::Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(settings["digital_ids"].as_array().unwrap().len(), 1); + assert_eq!(settings["digital_ids"][0]["path"], "file-id.p12"); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert_eq!(restored.digital_ids.len(), 1); + assert_eq!(restored.digital_ids[0].path, "file-id.p12"); +} + +#[test] +fn windows_store_identity_does_not_ask_for_a_file_password() { + let mut h = harness(dir()); + h.state_mut().start_signing(0, None, None, None); + // A synthetic picker entry exercises the dialog without a real Windows identity. + h.state_mut().digital_ids.push(pdfcraft_ui_egui::DigitalIdEntry { + path: "windows:No Such Signer".into(), + name: "Store signer".into(), + issuer: "Test issuer".into(), + email: String::new(), + expires: "2030.01.01".into(), + }); + let draft = h.state_mut().sign_draft.as_mut().unwrap(); + draft.step = SignStep::Choose; + draft.selected = Some(0); + h.run_steps(3); + h.get_by_label("Continue").click(); + h.run_steps(3); + h.get_by_label("Sign as \"Store signer\""); + assert_eq!(h.query_all_by_label("Digital ID password").count(), 0); + h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it."); + h.get_by_label("Sign").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Sign)); + assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 660478014c..cc5448bbaa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5807,11 +5807,11 @@ title = "Sign with the Windows certificate store" area = "H" tier = "P0" milestone = "M9" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["sign.digital", "sign.certify"] +tools = ["sign_windows_ids", "sign_document"] +evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password"] +notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows: for agents and Windows store labels in the desktop ID picker. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates (run with --ignored); smart cards and PKCS #11 still planned." [[feature]] id = "sign.pkcs11"