diff --git a/crates/sign/README.md b/crates/sign/README.md index 1d1f895d..abb95eba 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -47,10 +47,14 @@ 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, smart cards and PKCS #11 tokens. +configuration, FieldMDP locks, certificate security, direct PKCS #11 access to 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. +ECDSA P-256/P-384; the store integration is tested with software-backed keys. A smart card +or token whose driver registers a CNG key storage provider is such an identity too: the key +is opened for signing without the silent flag, so the provider shows its own PIN dialog +(checked by hand with a SafeNet token; CI has no hardware). A store identity signs with the +issuers Windows chains its certificate to, without the root. - **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/windows.rs b/crates/sign/src/windows.rs index 1ea1b7f4..23b56a47 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -3,14 +3,15 @@ use std::sync::Arc; -use rustls_cng::key::{AlgorithmGroup, NCryptKey, SignaturePadding}; +use rustls_cng::cert::CertContext; +use rustls_cng::key::{AlgorithmGroup, SignaturePadding}; use rustls_cng::store::{CertStore, CertStoreType}; use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey, StoreKey}; use crate::{Certificate, DigitalId, SignError}; struct WindowsKey { - key: NCryptKey, + context: CertContext, public: PublicKey, } @@ -19,8 +20,15 @@ impl ExternalKey for WindowsKey { 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 + // A key handle acquired silently can never prompt: smart cards and tokens then refuse + // to sign (NTE_SILENT_CONTEXT, 0x80090022) instead of asking for the PIN. Acquire the + // signing handle without the silent flag so the provider can show its own dialog. + let key = self.context.acquire_key(false).map_err(|e| { + SignError::Crypto(format!( + "the Windows certificate store key couldn't be opened for signing (is the smart card or token connected?): {e}" + )) + })?; + let raw = key .sign(&digest, padding) .map_err(|e| SignError::Crypto(format!("the Windows certificate store didn't sign (key use may have been cancelled): {e}")))?; self.public.store_signature_der(&raw) @@ -33,20 +41,35 @@ pub fn reference(certificate: &Certificate) -> String { format!("windows:{}", digest.iter().map(|b| format!("{b:02x}")).collect::()) } -/// Find an identity by its reference or subject common name. +/// Find an identity by its reference or subject common name. Its `chain` holds the issuers +/// Windows finds for the certificate, so a signature embeds them as it does for a file's. pub fn find(reference_or_name: &str) -> Result { - identities()? + let (context, mut id) = entries()? .into_iter() - .find(|id| { + .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}"))) + .ok_or_else(|| SignError::Crypto(format!("no Windows certificate store identity {reference_or_name}")))?; + id.chain = issuers(&context, &id.certificate); + Ok(id) +} + +/// The certificates Windows chains `certificate` to, without the root. Best effort: an +/// identity signs without them too. +fn issuers(context: &CertContext, certificate: &Certificate) -> Vec { + let Ok(chain) = context.as_chain_der() else { return Vec::new() }; + chain.iter().filter(|der| **der != certificate.raw).filter_map(|der| Certificate::parse(der).ok()).collect() } /// 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> { + Ok(entries()?.into_iter().map(|(_, id)| id).collect()) +} + +/// Each usable identity with the store's certificate context it came from. +fn entries() -> 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}")))?; @@ -54,7 +77,7 @@ pub fn identities() -> Result, SignError> { 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 Ok(key) = context.acquire_key(true) else { continue }; // P-521, brainpool and Ed25519 certificates parse (to verify with) but aren't signed with. let supported = match certificate.public_key.store_signing_key() { Some(StoreKey::Rsa) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Rsa), @@ -66,11 +89,12 @@ pub fn identities() -> Result, SignError> { if !supported { continue; } - key.set_silent(false); + // The silent handle was only for the checks above; `sign` acquires its own. + drop(key); let public = certificate.public_key.clone(); - let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { key, public })); + let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public })); let friendly_name = Some(certificate.display_name()); - out.push(DigitalId { key, certificate, chain: Vec::new(), friendly_name }); + out.push((context, 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 4bb7daf8..5525b773 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -1090,31 +1090,40 @@ fn windows_store_enumeration_and_missing_identity() { assert!(pdfcraft_sign::windows::find("windows:no such signer").is_err()); } +#[cfg(windows)] +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}"# + ); + std::process::Command::new("powershell.exe").args(["-NoProfile", "-NonInteractive", "-Command", &script]).output().unwrap() +} + +/// Thumbprints of temporary Current User Personal certificates, removed with their keys on drop. +#[cfg(windows)] +struct Certificates(Vec); + +#[cfg(windows)] +impl Drop for Certificates { + fn drop(&mut self) { + for thumbprint in &self.0 { + // New-SelfSignedCertificate also leaves a public copy of a self-signed or CA + // certificate in Intermediate Certification Authorities (CA). + let out = powershell(&format!( + "Remove-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{thumbprint}' -DeleteKey; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}" + )); + if !out.status.success() { + eprintln!("test certificate cleanup failed: {}", String::from_utf8_lossy(&out.stderr)); + } + } + } +} + /// 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(); @@ -1150,11 +1159,59 @@ fn signing_with_windows_store_identities() { 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}"); + for store in ["My", "CA"] { + let out = powershell(&format!("if (Test-Path -LiteralPath 'Cert:\\CurrentUser\\{store}\\{thumbprint}') {{ exit 1 }}")); + assert!(out.status.success(), "test certificate remains in {store}: {thumbprint}"); + } } } +/// An identity found for signing brings the issuers Windows chains it to (not the root), so the +/// signature validates against the root alone. Uses only temporary software-backed CNG keys. +#[cfg(windows)] +#[test] +#[ignore = "creates temporary certificates in the Windows Current User Personal store"] +fn a_windows_store_identity_signs_with_its_issuers() { + 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(); + let [root, issuer, signer] = ["Root", "Issuer", "Signer"].map(|role| format!("PdfCraft Test {unique} {role}")); + let mut create = |name: &str, signed_by: Option<&str>, extra: &str| { + let by = signed_by.map(|t| format!("-Signer (Get-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{t}')")).unwrap_or_default(); + let out = powershell(&format!( + "$c=New-SelfSignedCertificate -Subject 'CN={name}' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Provider 'Microsoft Software Key Storage Provider' -KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy NonExportable {by} {extra}; $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); + created.0.push(thumbprint.clone()); + thumbprint + }; + let ca = "-KeyUsage CertSign -TextExtension @('2.5.29.19={text}CA=true')"; + let root_thumbprint = create(&root, None, ca); + let issuer_thumbprint = create(&issuer, Some(&root_thumbprint), ca); + create(&signer, Some(&issuer_thumbprint), "-KeyUsage DigitalSignature"); + + let listed = pdfcraft_sign::windows::identities().unwrap(); + let anchor = listed.iter().find(|id| id.certificate.subject.common_name() == Some(root.as_str())).expect("root listed").certificate.clone(); + // Listing builds no chains; finding the identity to sign with does. + assert!(listed.iter().all(|id| id.chain.is_empty())); + let id = pdfcraft_sign::windows::find(&format!("windows:{signer}")).unwrap(); + let chain: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect(); + assert_eq!(chain, [Some(issuer.as_str())], "the issuer, without the signer's own certificate or the root"); + + 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![anchor] }); + let signature = validated.iter().find(|s| s.signed).unwrap(); + assert_eq!(signature.status, Status::Valid, "{:?}", signature.details); + assert_eq!(signature.signer.as_deref(), Some(signer.as_str())); +} + /// Documenso-style: an AcroForm signature field `Timestamp_1` whose `/V` is the document /// timestamp dictionary (`/Type /DocTimeStamp`, `/SubFilter /ETSI.RFC3161`). fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7a9be21a..c722197b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5824,8 +5824,8 @@ milestone = "M9" 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." +evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "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. The signature embeds the issuers Windows chains the certificate to. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); direct PKCS #11 still planned." [[feature]] id = "sign.pkcs11"