* Sign: decide Windows store keys in code every platform builds #646 fixed the Windows build after #386 with `_` arms in the two matches in windows.rs. That module is cfg(windows), so Linux and macOS CI never compile it, and the next PublicKey variant will either break the Windows build again or, now, fall into `_` without anyone choosing. The store's two decisions move into keys.rs, which every platform builds, with every variant named and no `_` arm: - PublicKey::store_signing_key: RSA, or ECDSA with 256/384 bits, or None for the kinds PdfCraft only verifies with (P-521, brainpool, Ed25519, Unsupported). - PublicKey::store_signature_der: CNG's raw r||s as DER (RSA as is); the verify-only kinds return SignError::Unsupported, as since #646. windows.rs calls both, with no `_` arms left. Behaviour is unchanged. tests/store_keys.rs runs everywhere: every variant's answer (with an exhaustive match, so a new variant must be added to it), the P-256 and P-384 conversion round-tripped through PublicKey::verify (code that used to compile only on Windows), and malformed CNG signatures erroring rather than panicking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sign: smart cards and tokens in the Windows store ask for their PIN (#694) Signing with a Windows store identity whose key lives on a smart card or USB token failed with NTE_SILENT_CONTEXT (0x80090022) and no PIN dialog. `identities()` acquires each key with CRYPT_ACQUIRE_SILENT_FLAG so that listing never prompts, and signing reused that handle after `set_silent(false)`. That only drops NCRYPT_SILENT_FLAG from NCryptSignHash: a provider handle that was opened silently stays silent, so a card that needs its PIN refuses instead of asking. `WindowsKey` now keeps the certificate context and acquires a fresh, non-silent key handle when it signs, so the provider can show its own dialog. Listing is unchanged and still silent. Reproduced and checked by hand with a qualified certificate on a SafeNet token (SafeNet Smart Card Key Storage Provider, RSA-2048): 0.5.0 fails with 80090022, this build shows the SafeNet PIN dialog and signs. The software-key round trip (`signing_with_windows_store_identities`, --ignored) still passes on Windows 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Sign: a Windows store identity signs with its issuers A digital ID file brings its certificate chain and the signature embeds it; a Windows store identity signed with its own certificate only. A certificate issued by an intermediate CA (the usual case on a token) then validates only where the reader already has that CA. `windows::find`, which fetches the identity to sign with, now fills `chain` with the certificates Windows chains it to, without the root. `identities()` still builds no chains, so listing stays fast and local. A chain that can't be built leaves `chain` empty, as before. Test `a_windows_store_identity_signs_with_its_issuers` (--ignored, like the other store test): a temporary root, issuer and signer; the signature validates against the root alone. The cleanup guard now also removes the public copy New-SelfSignedCertificate leaves in Intermediate Certification Authorities, which the existing test left behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mmmaarian03-crypto <253881360+mmmaarian03-crypto@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
c5d886488c
commit
99c7dd9311
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::<String>())
|
||||
}
|
||||
|
||||
/// 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<DigitalId, SignError> {
|
||||
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<Certificate> {
|
||||
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<Vec<DigitalId>, SignError> {
|
||||
Ok(entries()?.into_iter().map(|(_, id)| id).collect())
|
||||
}
|
||||
|
||||
/// Each usable identity with the store's certificate context it came from.
|
||||
fn entries() -> Result<Vec<(CertContext, DigitalId)>, 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<Vec<DigitalId>, 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<Vec<DigitalId>, 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>);
|
||||
|
||||
#[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<String>);
|
||||
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<u8> {
|
||||
|
|
|
|||
|
|
@ -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:<fingerprint|common name> 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:<fingerprint|common name> 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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue