From 26af9db66e240c62fee10e4cd5db7b7107767e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Tue, 16 Jun 2026 09:40:46 -0300 Subject: [PATCH 1/2] sync: vendored pdf_signer engine to v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-sync the bundled copy of the standalone `pdf_signer` crate (github.com/StrategicProjects/pdf_signer) to its v0.2.0 release: the verification & trust hardening audit (issues #1–#10) — cryptographic RFC 3161 timestamp validation, authenticated CRL/OCSP revocation, signing-time chain validation, structural signature location, /ByteRange↔/Contents binding, path-building backtracking, and honest CLI/trust reporting. No third-party dependencies changed, so the vendored crate set (vendor.tar.xz) is unchanged; only the path-dependency version in Cargo.lock is bumped. The bundled library checks cleanly with default and `https` features. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/rust/Cargo.lock | 2 +- src/rust/pdf_signer/Cargo.toml | 2 +- src/rust/pdf_signer/src/crypto.rs | 301 +++++++++++++++++++++--- src/rust/pdf_signer/src/dss.rs | 37 ++- src/rust/pdf_signer/src/lib.rs | 30 +-- src/rust/pdf_signer/src/policy.rs | 8 +- src/rust/pdf_signer/src/sign.rs | 9 + src/rust/pdf_signer/src/testkit.rs | 198 ++++++++++++++++ src/rust/pdf_signer/src/trust.rs | 365 ++++++++++++++++++++++------- src/rust/pdf_signer/src/tsa.rs | 14 +- src/rust/pdf_signer/src/verify.rs | 203 +++++++++++++--- 11 files changed, 993 insertions(+), 176 deletions(-) diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index d357b8d..fd97d37 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "pdf_signer" -version = "0.1.7" +version = "0.2.0" dependencies = [ "cms", "const-oid", diff --git a/src/rust/pdf_signer/Cargo.toml b/src/rust/pdf_signer/Cargo.toml index 940c66d..c8b53a0 100644 --- a/src/rust/pdf_signer/Cargo.toml +++ b/src/rust/pdf_signer/Cargo.toml @@ -3,7 +3,7 @@ # Keep in sync with that repo until it is published to crates.io. [package] name = "pdf_signer" -version = "0.1.7" +version = "0.2.0" edition = "2021" rust-version = "1.74" description = "Sign PDF documents with a PKCS#12 keystore and verify their signatures." diff --git a/src/rust/pdf_signer/src/crypto.rs b/src/rust/pdf_signer/src/crypto.rs index 2ff4415..df399af 100644 --- a/src/rust/pdf_signer/src/crypto.rs +++ b/src/rust/pdf_signer/src/crypto.rs @@ -20,8 +20,8 @@ use const_oid::db::rfc5912::{ use const_oid::db::rfc8410::ID_ED_25519; use const_oid::ObjectIdentifier; -use der::asn1::{BitString, OctetString, SetOfVec, UtcTime}; -use der::{Any, DateTime, Decode, Encode, Sequence}; +use der::asn1::{BitString, GeneralizedTime, OctetString, SetOfVec, UtcTime}; +use der::{Any, DateTime, Decode, Encode, Reader, Sequence, SliceReader}; use rsa::pkcs8::{DecodePrivateKey, PrivateKeyInfo}; use sha2::{Sha384, Sha512}; @@ -36,6 +36,18 @@ use x509_cert::time::Time; const ID_AA_TIME_STAMP_TOKEN: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.16.2.14"); +/// id-ct-TSTInfo (RFC 3161) — the eContentType of a timestamp token. +const ID_CT_TST_INFO: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.16.1.4"); + +/// RFC 3161 `MessageImprint` — the hash algorithm and the hash of the stamped +/// data, as echoed back inside `TSTInfo`. +#[derive(Sequence)] +struct MessageImprint { + hash_algorithm: AlgorithmIdentifierOwned, + hashed_message: OctetString, +} + /// `ESSCertIDv2` with the SHA-256 default hash algorithm and `issuerSerial` /// omitted (both optional), leaving just the certificate hash. #[derive(Sequence)] @@ -371,20 +383,181 @@ pub(crate) fn signer_certificate_and_pool( Ok((signer, pool)) } -/// Lightweight check that a `/DocTimeStamp` `/Contents` is a well-formed RFC -/// 3161 token whose message imprint is bound to `data` (the document byte -/// range). Full TSA-signature/chain validation is left for a future B-LT -/// verifier; this confirms structure + binding. +/// Verify a `/DocTimeStamp` RFC 3161 token (`token_der`, a CMS `ContentInfo`) +/// against `data` (the stamped document byte range). +/// +/// This checks the full chain of trust *within* the token: +/// 1. the encapsulated content is an RFC 3161 `TSTInfo`; +/// 2. the TSA's CMS signature over that `TSTInfo` is cryptographically valid +/// (signed attributes + `messageDigest`), using the certificate embedded in +/// the token; +/// 3. the `TSTInfo.messageImprint` binds to `data` under its stated hash. +/// +/// Not yet covered (tracked separately): validating the TSA certificate chain +/// against a trust anchor, and `genTime`/policy/nonce semantics. Signers +/// identified by `SubjectKeyIdentifier` are not supported. pub(crate) fn verify_doc_timestamp(token_der: &[u8], data: &[u8]) -> Result<()> { - ContentInfo::from_der(token_der).map_err(crypto)?; - let imprint = Sha256::digest(data); - if token_der.windows(imprint.len()).any(|w| w == imprint.as_slice()) { - Ok(()) - } else { - Err(Error::Verification( + let ci = ContentInfo::from_der(token_der).map_err(crypto)?; + let sd = ci.content.decode_as::().map_err(crypto)?; + + // The encapsulated content must be a TSTInfo; get its DER octets. + let tst_der = tst_info_der(&sd)?; + + // 1. The TSA's signature must verify over the TSTInfo (the eContent). + let si = sd + .signer_infos + .0 + .iter() + .next() + .ok_or_else(|| Error::Verification("timestamp has no SignerInfo".into()))?; + verify_signed_attrs(&sd, si, &tst_der)?; + + // 2. The TSTInfo's messageImprint must bind to `data` under its own hash. + let imprint = parse_tst_info(&tst_der)?.imprint; + let want = digest_data(imprint.hash_algorithm.oid, data)?; + if imprint.hashed_message.as_bytes() != want.as_slice() { + return Err(Error::Verification( "timestamp imprint does not match the document".into(), - )) + )); } + Ok(()) +} + +/// Return the `messageImprint.hashedMessage` of a timestamp token, used to +/// confirm a freshly fetched TSA response stamped the imprint we asked for. +pub(crate) fn tst_message_imprint(token_der: &[u8]) -> Result> { + let ci = ContentInfo::from_der(token_der).map_err(crypto)?; + let sd = ci.content.decode_as::().map_err(crypto)?; + let tst_der = tst_info_der(&sd)?; + Ok(parse_tst_info(&tst_der)? + .imprint + .hashed_message + .as_bytes() + .to_vec()) +} + +/// A cryptographically verified RFC 3161 signature-timestamp: the asserted +/// time plus the TSA's own certificates, so the caller can anchor the TSA to a +/// trust store before relying on `gen_time`. +pub(crate) struct VerifiedTimestamp { + /// The TSA's asserted `genTime`. + pub gen_time: SystemTime, + /// The TSA's signing certificate. + pub tsa_leaf: Certificate, + /// Certificates embedded in the token (for building the TSA's chain). + pub tsa_pool: Vec, +} + +/// Verify the embedded RFC 3161 **signature-timestamp** of a document CMS. +/// +/// This authenticates the time before it can be used as a validation anchor: +/// it checks the TSA's CMS signature over the `TSTInfo`, and that the token's +/// `messageImprint` equals the hash of the document signer's signature value +/// (RFC 3161 / CAdES signature-timestamp semantics). It returns the `genTime` +/// together with the TSA's certificates so the caller can require the TSA to +/// chain to a trusted root — without that anchor a forged self-issued TSA could +/// assert any time. The unauthenticated `signingTime` attribute is deliberately +/// **not** consulted; it is display-only. +pub(crate) fn verify_embedded_timestamp(cms_der: &[u8]) -> Result { + let ci = ContentInfo::from_der(cms_der).map_err(crypto)?; + let sd = ci.content.decode_as::().map_err(crypto)?; + let si = sd + .signer_infos + .0 + .iter() + .next() + .ok_or_else(|| Error::Verification("no SignerInfo present".into()))?; + let signature_value = si.signature.as_bytes(); + + // Locate the id-aa-timeStampToken unsigned attribute. + let token_der = si + .unsigned_attrs + .as_ref() + .and_then(|attrs| attrs.iter().find(|a| a.oid == ID_AA_TIME_STAMP_TOKEN)) + .and_then(|a| a.values.iter().next()) + .ok_or_else(|| Error::Verification("no signature timestamp present".into()))? + .to_der() + .map_err(crypto)?; + + // Verify the TSA's signature over its TSTInfo (the eContent). + let tci = ContentInfo::from_der(&token_der).map_err(crypto)?; + let tsd = tci.content.decode_as::().map_err(crypto)?; + let tst_der = tst_info_der(&tsd)?; + let tsi = tsd + .signer_infos + .0 + .iter() + .next() + .ok_or_else(|| Error::Verification("timestamp has no SignerInfo".into()))?; + let tsa_leaf = verify_signed_attrs(&tsd, tsi, &tst_der)?.clone(); + + // The timestamp must imprint the document signer's signature value. + let info = parse_tst_info(&tst_der)?; + let want = digest_data(info.imprint.hash_algorithm.oid, signature_value)?; + if info.imprint.hashed_message.as_bytes() != want.as_slice() { + return Err(Error::Verification( + "signature timestamp does not bind to the signature".into(), + )); + } + + let mut tsa_pool = Vec::new(); + if let Some(set) = &tsd.certificates { + for choice in set.0.iter() { + if let CertificateChoices::Certificate(c) = choice { + tsa_pool.push(c.clone()); + } + } + } + + Ok(VerifiedTimestamp { + gen_time: info.gen_time.to_system_time(), + tsa_leaf, + tsa_pool, + }) +} + +/// Extract the DER of the encapsulated `TSTInfo` from a timestamp token's +/// `SignedData`, validating that the eContentType is `id-ct-TSTInfo`. +fn tst_info_der(sd: &SignedData) -> Result> { + let eci = &sd.encap_content_info; + if eci.econtent_type != ID_CT_TST_INFO { + return Err(Error::Verification( + "timestamp token does not encapsulate a TSTInfo".into(), + )); + } + let econtent = eci + .econtent + .as_ref() + .ok_or_else(|| Error::Verification("timestamp token has no eContent".into()))?; + let octets = econtent.decode_as::().map_err(crypto)?; + Ok(octets.as_bytes().to_vec()) +} + +/// The fields of an RFC 3161 `TSTInfo` that we consume. +struct TstInfo { + imprint: MessageImprint, + gen_time: GeneralizedTime, +} + +/// Parse the leading fields of a DER-encoded `TSTInfo` (up to `genTime`), +/// skipping the serialNumber and the optional trailing fields (accuracy, +/// ordering, nonce, tsa, extensions). +fn parse_tst_info(tst_der: &[u8]) -> Result { + let mut reader = SliceReader::new(tst_der).map_err(crypto)?; + reader + .sequence(|r| { + let _version: u8 = r.decode()?; // INTEGER v1 + let _policy: ObjectIdentifier = r.decode()?; // TSAPolicyId + let imprint: MessageImprint = r.decode()?; + r.tlv_bytes()?; // serialNumber (INTEGER), unused + let gen_time: GeneralizedTime = r.decode()?; + // Skip whatever optional fields follow so the SEQUENCE is consumed. + while !r.is_finished() { + r.tlv_bytes()?; + } + Ok(TstInfo { imprint, gen_time }) + }) + .map_err(crypto) } /// Verify a detached CMS `der` (a ContentInfo) against `data`. @@ -403,13 +576,30 @@ pub(crate) fn cms_verify(der: &[u8], data: &[u8]) -> Result { .next() .ok_or_else(|| Error::Verification("no SignerInfo present".into()))?; + let cert = verify_signed_attrs(&sd, si, data)?; + Ok(CmsVerification { + signer_subject: cert.tbs_certificate.subject.to_string(), + }) +} + +/// Verify a `SignerInfo`'s signature over its signed attributes and that its +/// `messageDigest` attribute equals `H(content)`, returning the signer +/// certificate. `content` is whatever the signature commits to: the external +/// byte range for a detached document signature, or the encapsulated `TSTInfo` +/// for a timestamp token. Does **not** validate the certificate chain / trust. +fn verify_signed_attrs<'a>( + sd: &'a SignedData, + si: &SignerInfo, + content: &[u8], +) -> Result<&'a Certificate> { let signed_attrs = si .signed_attrs .as_ref() .ok_or_else(|| Error::Verification("signer has no signed attributes".into()))?; - // 1. messageDigest attribute must equal H(data) for the SignerInfo's digest. - let want = digest_data(si.digest_alg.oid, data)?; + // 1. messageDigest attribute must equal H(content) under the SignerInfo's + // digest algorithm. + let want = digest_data(si.digest_alg.oid, content)?; let mut found_digest = None; for attr in signed_attrs.iter() { if attr.oid == ID_MESSAGE_DIGEST { @@ -429,22 +619,17 @@ pub(crate) fn cms_verify(der: &[u8], data: &[u8]) -> Result { } // 2. Locate the signer certificate by issuer + serial. - let cert = find_signer_cert(&sd, si)?; + let cert = find_signer_cert(sd, si)?; - // 3. Verify the signer's signature over the DER of the signed attributes, - // using RSA or ECDSA according to the certificate's public key. + // 3. Verify the signature over the DER of the signed attributes, using the + // algorithm of the certificate's public key. let spki = &cert.tbs_certificate.subject_public_key_info; let spki_der = spki.to_der().map_err(crypto)?; let signed_attrs_der = signed_attrs.to_der().map_err(crypto)?; let sig_bytes = si.signature.as_bytes(); let ok = if spki.algorithm.oid == RSA_ENCRYPTION { - let pub_key = rsa::RsaPublicKey::from_public_key_der(&spki_der).map_err(crypto)?; - let vk = VerifyingKey::::new(pub_key); - match Signature::try_from(sig_bytes) { - Ok(s) => vk.verify(&signed_attrs_der, &s).is_ok(), - Err(_) => false, - } + rsa_verify(&spki_der, &signed_attrs_der, sig_bytes, si.digest_alg.oid) } else if spki.algorithm.oid == ID_EC_PUBLIC_KEY { verify_ecdsa_sig(&spki_der, &signed_attrs_der, sig_bytes) } else if spki.algorithm.oid == ID_ED_25519 { @@ -455,10 +640,27 @@ pub(crate) fn cms_verify(der: &[u8], data: &[u8]) -> Result { if !ok { return Err(Error::Verification("signature invalid".into())); } + Ok(cert) +} - Ok(CmsVerification { - signer_subject: cert.tbs_certificate.subject.to_string(), - }) +/// Verify an RSA PKCS#1 v1.5 signature over `msg`, choosing the hash from the +/// SignerInfo's digest algorithm (SHA-256/384/512). +fn rsa_verify(spki_der: &[u8], msg: &[u8], sig: &[u8], digest_oid: ObjectIdentifier) -> bool { + let (Ok(pub_key), Ok(s)) = ( + rsa::RsaPublicKey::from_public_key_der(spki_der), + Signature::try_from(sig), + ) else { + return false; + }; + if digest_oid == ID_SHA_256 { + VerifyingKey::::new(pub_key).verify(msg, &s).is_ok() + } else if digest_oid == ID_SHA_384 { + VerifyingKey::::new(pub_key).verify(msg, &s).is_ok() + } else if digest_oid == ID_SHA_512 { + VerifyingKey::::new(pub_key).verify(msg, &s).is_ok() + } else { + false + } } /// Hash `data` with the digest named by `oid` (SHA-256/384/512). @@ -539,3 +741,48 @@ fn find_signer_cert<'a>( "signer certificate not found in CMS".into(), )) } + +#[cfg(test)] +mod tests { + use super::{cms_sign, verify_doc_timestamp}; + use crate::testkit::self_signed_p12; + use sha2::{Digest, Sha256}; + + #[test] + fn doc_timestamp_rejects_a_non_tstinfo_cms() { + // A detached CMS signature over `data` embeds SHA-256(data) as its + // messageDigest attribute. The old window-search verifier found those + // bytes and wrongly accepted it as a document timestamp. It is not an + // RFC 3161 TSTInfo, so the hardened verifier must reject it (issue #4). + let data = b"the document byte range"; + let p12 = self_signed_p12("pw"); + let cms = cms_sign(&p12, "pw", data, None).expect("sign"); + + // Sanity: the imprint really is present in the DER (what fooled the old + // check), yet verification now fails for lack of a real TSTInfo. + let imprint = Sha256::digest(data); + assert!(cms.windows(imprint.len()).any(|w| w == imprint.as_slice())); + assert!(verify_doc_timestamp(&cms, data).is_err()); + } + + #[test] + fn doc_timestamp_rejects_garbage() { + assert!(verify_doc_timestamp(b"not der at all", b"data").is_err()); + } + + #[test] + fn embedded_timestamp_required_for_trusted_time() { + // A B-B signature carries no RFC 3161 signature-timestamp, so there is + // no authenticated time anchor: the chain must be judged at "now", never + // at the signer-asserted signingTime (issue #6 / security review). The + // verifier surfaces this as an error so the caller falls back to now(). + use super::verify_embedded_timestamp; + + let p12 = self_signed_p12("pw"); + let cms = cms_sign(&p12, "pw", b"the byte range", None).expect("sign"); + assert!( + verify_embedded_timestamp(&cms).is_err(), + "no embedded timestamp must not yield a trusted time" + ); + } +} diff --git a/src/rust/pdf_signer/src/dss.rs b/src/rust/pdf_signer/src/dss.rs index 78e10ba..e66d1f8 100644 --- a/src/rust/pdf_signer/src/dss.rs +++ b/src/rust/pdf_signer/src/dss.rs @@ -53,7 +53,7 @@ pub(crate) fn collect_validation_material(signature_cms: &[u8]) -> Result = Vec::new(); for der in &certs { if let Ok(cert) = Certificate::from_der(der) { - for url in crl_http_urls(&cert) { + for url in crl_urls(&cert) { if !urls.contains(&url) { urls.push(url); } @@ -117,14 +117,20 @@ fn fetch_ocsp(cert: &Certificate, pool: &[Certificate]) -> Option> { } } -/// The HTTP OCSP responder URL from a certificate's Authority Information Access. +/// True if we can fetch `url`: plain `http://` always, `https://` only when the +/// `https` feature (and thus a TLS stack) is compiled in. +fn fetchable_url(url: &str) -> bool { + url.starts_with("http://") || (cfg!(feature = "https") && url.starts_with("https://")) +} + +/// The OCSP responder URL from a certificate's Authority Information Access. fn ocsp_url(cert: &Certificate) -> Option { let (_, aia) = cert.tbs_certificate.get::().ok()??; for desc in aia.0.iter() { if desc.access_method == ID_AD_OCSP { if let GeneralName::UniformResourceIdentifier(uri) = &desc.access_location { let s = uri.as_str().to_string(); - if s.starts_with("http://") { + if fetchable_url(&s) { return Some(s); } } @@ -170,7 +176,7 @@ fn extract_timestamp_token(cms_der: &[u8]) -> Result>> { Ok(None) } -fn crl_http_urls(cert: &Certificate) -> Vec { +fn crl_urls(cert: &Certificate) -> Vec { let mut urls = Vec::new(); if let Ok(Some((_, cdp))) = cert.tbs_certificate.get::() { for dp in cdp.0.iter() { @@ -178,7 +184,7 @@ fn crl_http_urls(cert: &Certificate) -> Vec { for name in names { if let GeneralName::UniformResourceIdentifier(uri) = name { let s = uri.as_str().to_string(); - if s.starts_with("http://") { + if fetchable_url(&s) { urls.push(s); } } @@ -303,3 +309,24 @@ fn extract_dss_streams(pdf: &[u8], key: &[u8]) -> Vec> { fn map(e: E) -> Error { Error::Crypto(e.to_string()) } + +#[cfg(test)] +mod tests { + use super::fetchable_url; + + #[test] + fn plain_http_is_always_fetchable() { + assert!(fetchable_url("http://ca.example/crl.der")); + assert!(!fetchable_url("ldap://ca.example/cn")); + assert!(!fetchable_url("ftp://ca.example/crl.der")); + } + + #[test] + fn https_follows_the_feature_flag() { + // HTTPS is only fetchable when a TLS stack is compiled in. + assert_eq!( + fetchable_url("https://ca.example/crl.der"), + cfg!(feature = "https") + ); + } +} diff --git a/src/rust/pdf_signer/src/lib.rs b/src/rust/pdf_signer/src/lib.rs index ff5cb65..9f18a4e 100644 --- a/src/rust/pdf_signer/src/lib.rs +++ b/src/rust/pdf_signer/src/lib.rs @@ -1,24 +1,24 @@ //! # pdf_signer //! -//! Minimal, self-contained library to **digitally sign** PDF documents with a -//! PKCS#12 (`.p12`/`.pfx`) keystore and to **verify** existing signatures. -//! -//! This is a proof of concept intended to replace the bundled -//! `BatchPDFSignPortable.jar` (Java/PDFBox) used by the R package `signer`, -//! removing the Java runtime dependency and the 13 MB binary blob. +//! Pure-Rust, self-contained library to **digitally sign** (PAdES) and +//! **verify** PDF documents with a PKCS#12 (`.p12`/`.pfx`) keystore. It replaces +//! the bundled `BatchPDFSignPortable.jar` (Java/PDFBox) used by the R package +//! `signer`, removing the Java runtime dependency and the binary blob. //! //! ## What it does -//! * [`sign_pdf_file`] / [`sign_pdf_bytes`]: append a signature field and an -//! `adbe.pkcs7.detached` CMS signature over the whole document. +//! * [`sign_pdf_file`] / [`sign_pdf_bytes`]: append a signature field (optionally +//! with a visible appearance) and an `ETSI.CAdES.detached` CMS signature as an +//! **incremental update**, so any prior signature stays valid. Targets PAdES +//! B-B through B-LTA (RFC 3161 signature & document timestamps, `/DSS`). //! * [`verify_pdf_file`] / [`verify_pdf_bytes`]: re-extract the signed byte -//! range, validate the CMS signature cryptographically and report signer info. +//! range, validate the CMS signature cryptographically, report signer info, +//! and optionally validate the signer chain against a [`TrustStore`]. //! -//! ## Scope of the PoC -//! * Single, invisible signature (no visual appearance stream yet). -//! * Full-rewrite save (not an incremental update) — fine for a first -//! signature, revisit before multi-signature support. -//! * CMS is produced via the system OpenSSL (`openssl` crate). A pure-Rust -//! RustCrypto backend is the path for a CRAN-friendly, vendored build. +//! ## Notes +//! * Keys: RSA (PKCS#1 v1.5), ECDSA (P-256/P-384), Ed25519. +//! * 100% pure Rust (RustCrypto) — no OpenSSL, no Java, no system C libraries. +//! An optional `https` feature (ureq/rustls) enables TLS TSA/CRL/OCSP. +//! * Incremental updates support both classic xref tables and xref streams. mod appearance; mod crypto; diff --git a/src/rust/pdf_signer/src/policy.rs b/src/rust/pdf_signer/src/policy.rs index f0e8d3a..3696452 100644 --- a/src/rust/pdf_signer/src/policy.rs +++ b/src/rust/pdf_signer/src/policy.rs @@ -2,10 +2,10 @@ //! policy mapping, and the explicit-policy / policy-mapping / inhibit-anyPolicy //! counters. //! -//! ⚠️ This is a from-scratch implementation of the RFC 5280 §6.1.2–6.1.6 policy -//! algorithm. It is exercised by the crate's own scenario tests but has **not** -//! been validated against the NIST PKITS suite — review before relying on it -//! for high-stakes policy decisions. +//! This is a from-scratch implementation of the RFC 5280 §6.1.2–6.1.6 policy +//! algorithm. It is exercised by the crate's own scenario tests and validated +//! against the NIST PKITS suite (certificate-policy section, see +//! `tests/pkits.rs`). use std::collections::BTreeSet; diff --git a/src/rust/pdf_signer/src/sign.rs b/src/rust/pdf_signer/src/sign.rs index a0113ac..e1dacdf 100644 --- a/src/rust/pdf_signer/src/sign.rs +++ b/src/rust/pdf_signer/src/sign.rs @@ -144,6 +144,15 @@ pub fn sign_pdf_bytes( password: &str, opts: &SignOptions, ) -> Result> { + // PAdES-B-T and above embed an RFC 3161 timestamp, which needs a TSA. Fail + // loudly here rather than silently downgrading the requested level to B-B. + if opts.pades_level >= PadesLevel::Bt && opts.tsa_url.is_none() { + return Err(Error::Crypto(format!( + "PAdES-{:?} requires a tsa_url", + opts.pades_level + ))); + } + // 1. Build an incremental update (keeps the original bytes verbatim, so any // prior signature stays valid). let mut buf = build_incremental_update(pdf, opts)?; diff --git a/src/rust/pdf_signer/src/testkit.rs b/src/rust/pdf_signer/src/testkit.rs index 18a6632..cd82384 100644 --- a/src/rust/pdf_signer/src/testkit.rs +++ b/src/rust/pdf_signer/src/testkit.rs @@ -78,6 +78,18 @@ pub fn sample_pdf() -> Vec { buf } +/// A valid (unsigned) PDF that contains signature-looking syntax +/// (`/ByteRange ... /Contents <...>`) inside a content stream. Structural +/// verification must not mistake it for a signature. +pub fn pdf_with_byterange_decoy() -> Vec { + let mut doc = Document::load_mem(&sample_pdf()).unwrap(); + let decoy = b"/ByteRange [0 10 20 10] /Contents <30820000>".to_vec(); + doc.add_object(Stream::new(dictionary! {}, decoy)); + let mut buf = Vec::new(); + doc.save_to(&mut buf).unwrap(); + buf +} + /// Build a self-signed **ECDSA P-256** certificate and wrap it in a PKCS#12. pub fn self_signed_p256_p12(password: &str) -> Vec { let mut rng = rand::thread_rng(); @@ -610,3 +622,189 @@ pub fn ca_chain3_p12(password: &str) -> (Vec, Vec) { ks.add_entry("poc", KeyStoreEntry::PrivateKeyChain(chain)); (ks.writer(password).write().expect("write p12"), root_der) } + +/// Build a **cross-signing** scenario for path-building tests. +/// +/// One intermediate key with subject `CN=Cross Intermediate` is certified twice: +/// once by an *untrusted* root A and once by a *trusted* root B. A leaf is signed +/// by the intermediate key. Returns `(leaf_der, pool, trusted_root_b_der)` where +/// `pool` is `[I_a, I_b]` — the untrusted-chaining cross-cert first, so a +/// non-backtracking path builder commits to the dead end and fails. +pub fn cross_signed_scenario() -> (Vec, Vec>, Vec) { + let mut rng = rand::thread_rng(); + let validity = Validity::from_now(Duration::from_secs(365 * 24 * 3600)).unwrap(); + + // Two independent roots: A (untrusted) and B (trusted). + let make_root = |name: &str, rng: &mut rand::rngs::ThreadRng| { + let key = RsaPrivateKey::new(rng, 2048).unwrap(); + let signing = SigningKey::::new(key); + let name = Name::from_str(name).unwrap(); + let cert = CertificateBuilder::new( + Profile::Root, + SerialNumber::from(1u32), + validity, + name.clone(), + SubjectPublicKeyInfoOwned::from_key(signing.verifying_key()).unwrap(), + &signing, + ) + .unwrap() + .build::() + .unwrap(); + (signing, name, cert.to_der().unwrap()) + }; + let (root_a_signing, root_a_name, _root_a_der) = make_root("CN=Cross Root A,C=BR", &mut rng); + let (root_b_signing, root_b_name, root_b_der) = make_root("CN=Cross Root B,C=BR", &mut rng); + + // One intermediate key, certified by each root (same subject + key). + let inter_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let inter_signing = SigningKey::::new(inter_key.clone()); + let inter_name = Name::from_str("CN=Cross Intermediate,C=BR").unwrap(); + let inter_spki = SubjectPublicKeyInfoOwned::from_key(inter_signing.verifying_key()).unwrap(); + + let make_inter = |issuer: Name, signer: &SigningKey, spki: &SubjectPublicKeyInfoOwned| { + CertificateBuilder::new( + Profile::SubCA { + issuer, + path_len_constraint: Some(0), + }, + SerialNumber::from(2u32), + validity, + inter_name.clone(), + spki.clone(), + signer, + ) + .unwrap() + .build::() + .unwrap() + .to_der() + .unwrap() + }; + let i_a = make_inter(root_a_name, &root_a_signing, &inter_spki); + let i_b = make_inter(root_b_name, &root_b_signing, &inter_spki); + + // Leaf signed by the intermediate key. + let leaf_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let leaf_signing = SigningKey::::new(leaf_key); + let leaf_der = CertificateBuilder::new( + leaf_profile(inter_name), + SerialNumber::from(3u32), + validity, + Name::from_str("CN=Cross Leaf,C=BR").unwrap(), + SubjectPublicKeyInfoOwned::from_key(leaf_signing.verifying_key()).unwrap(), + &inter_signing, + ) + .unwrap() + .build::() + .unwrap() + .to_der() + .unwrap(); + + (leaf_der, vec![i_a, i_b], root_b_der) +} + +/// Material for exercising authenticated CRL revocation checks. +pub struct RevocationScenario { + /// Leaf certificate (serial 2), signed by `root`. + pub leaf_der: Vec, + /// Trusted root CA (serial 1). + pub root_der: Vec, + /// A current CRL, signed by the root, listing the leaf as revoked. + pub good_crl: Vec, + /// Same contents, but signed by a different key (signature must not verify). + pub wrong_key_crl: Vec, + /// Signed by the root and listing the leaf, but already past `nextUpdate`. + pub expired_crl: Vec, +} + +/// Build a root CA, a leaf, and three CRLs (valid / bad-signature / stale) so +/// tests can assert that only an authenticated, current CRL revokes the leaf. +pub fn revocation_scenario() -> RevocationScenario { + use der::asn1::{BitString, GeneralizedTime}; + use der::DateTime; + use signature::{SignatureEncoding, Signer}; + use std::time::{SystemTime, UNIX_EPOCH}; + use x509_cert::certificate::Version; + use x509_cert::crl::{CertificateList, RevokedCert, TbsCertList}; + use x509_cert::spki::AlgorithmIdentifierOwned; + + let mut rng = rand::thread_rng(); + let validity = Validity::from_now(Duration::from_secs(365 * 24 * 3600)).unwrap(); + + let root_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let root_signing = SigningKey::::new(root_key); + let root_name = Name::from_str("CN=Revocation Root,C=BR").unwrap(); + let root_cert = CertificateBuilder::new( + Profile::Root, + SerialNumber::from(1u32), + validity, + root_name.clone(), + SubjectPublicKeyInfoOwned::from_key(root_signing.verifying_key()).unwrap(), + &root_signing, + ) + .unwrap() + .build::() + .unwrap(); + let root_der = root_cert.to_der().unwrap(); + + let leaf_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let leaf_signing = SigningKey::::new(leaf_key); + let leaf_serial = SerialNumber::from(2u32); + let leaf_cert = CertificateBuilder::new( + leaf_profile(root_name.clone()), + leaf_serial.clone(), + validity, + Name::from_str("CN=Revocation Leaf,C=BR").unwrap(), + SubjectPublicKeyInfoOwned::from_key(leaf_signing.verifying_key()).unwrap(), + &root_signing, + ) + .unwrap() + .build::() + .unwrap(); + let leaf_der = leaf_cert.to_der().unwrap(); + + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let time = |secs: u64| { + x509_cert::time::Time::GeneralTime(GeneralizedTime::from_date_time( + DateTime::from_unix_duration(Duration::from_secs(secs)).unwrap(), + )) + }; + let rsa_sha256 = || AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::SHA_256_WITH_RSA_ENCRYPTION, + parameters: None, + }; + + // Build a CRL revoking the leaf, signed by `signer`, valid in [this, next]. + let build_crl = |signer: &SigningKey, this: u64, next: u64| -> Vec { + let tbs = TbsCertList { + version: Version::V2, + signature: rsa_sha256(), + issuer: root_name.clone(), + this_update: time(this), + next_update: Some(time(next)), + revoked_certificates: Some(vec![RevokedCert { + serial_number: leaf_serial.clone(), + revocation_date: time(this), + crl_entry_extensions: None, + }]), + crl_extensions: None, + }; + let tbs_der = tbs.to_der().unwrap(); + let sig = signer.sign(&tbs_der); + let crl = CertificateList { + tbs_cert_list: tbs, + signature_algorithm: rsa_sha256(), + signature: BitString::from_bytes(&sig.to_vec()).unwrap(), + }; + crl.to_der().unwrap() + }; + + let wrong_key = SigningKey::::new(RsaPrivateKey::new(&mut rng, 2048).unwrap()); + + RevocationScenario { + leaf_der, + root_der, + good_crl: build_crl(&root_signing, now - 3600, now + 3600), + wrong_key_crl: build_crl(&wrong_key, now - 3600, now + 3600), + expired_crl: build_crl(&root_signing, now - 7200, now - 3600), + } +} diff --git a/src/rust/pdf_signer/src/trust.rs b/src/rust/pdf_signer/src/trust.rs index cf50a2c..54e26a4 100644 --- a/src/rust/pdf_signer/src/trust.rs +++ b/src/rust/pdf_signer/src/trust.rs @@ -5,15 +5,24 @@ //! certificate against the **ICP-Brasil** roots (load them with //! [`TrustStore::from_pem`]), but works with any root set. //! -//! Scope: RSA PKCS#1 v1.5 with SHA-256/384/512 (the ICP-Brasil norm). ECDSA and -//! SHA-1 links are treated as unverifiable. No name-constraint / policy -//! processing and no revocation checking here (CRLs live in the DSS). +//! Supported signature algorithms: RSA PKCS#1 v1.5 (SHA-256/384/512), ECDSA +//! (P-256/P-384) and Ed25519; SHA-1 links are treated as unverifiable. The path +//! checks basicConstraints/`keyCertSign`, the validity window, RFC 5280 name +//! constraints (§4.2.1.10) and an optional required-policy OID via the +//! [`policy`](crate::policy) engine. +//! +//! Revocation: CRL and OCSP material (collected into the `/DSS`) is +//! authenticated before it is acted on — a CRL must be in scope and signed by +//! the issuing CA and current; an OCSP response must be signed by the issuer or +//! a delegated `id-kp-OCSPSigning` responder and current. Revocation is +//! soft-fail (no usable evidence ⇒ not treated as revoked). Not yet covered: +//! IDP / partitioned CRLs and a hard-fail mode. use std::time::SystemTime; use const_oid::db::rfc5912::{ - ECDSA_WITH_SHA_256, ECDSA_WITH_SHA_384, ECDSA_WITH_SHA_512, SHA_256_WITH_RSA_ENCRYPTION, - SHA_384_WITH_RSA_ENCRYPTION, SHA_512_WITH_RSA_ENCRYPTION, + ECDSA_WITH_SHA_256, ECDSA_WITH_SHA_384, ECDSA_WITH_SHA_512, ID_KP_OCSP_SIGNING, + SHA_256_WITH_RSA_ENCRYPTION, SHA_384_WITH_RSA_ENCRYPTION, SHA_512_WITH_RSA_ENCRYPTION, }; use const_oid::db::rfc8410::ID_ED_25519; use der::{Decode, Encode}; @@ -26,13 +35,13 @@ use std::collections::BTreeSet; use const_oid::db::rfc5280::ANY_POLICY; use const_oid::ObjectIdentifier; -use sha1::Sha1; +use sha1::{Digest as _, Sha1}; use x509_cert::crl::CertificateList; use x509_cert::ext::pkix::name::GeneralName; -use x509_cert::ext::pkix::{BasicConstraints, KeyUsage, NameConstraints, SubjectAltName}; +use x509_cert::ext::pkix::{BasicConstraints, ExtendedKeyUsage, KeyUsage, NameConstraints, SubjectAltName}; use x509_cert::name::{Name, RelativeDistinguishedName}; use x509_cert::Certificate; -use x509_ocsp::{BasicOcspResponse, CertId, CertStatus}; +use x509_ocsp::{BasicOcspResponse, CertId, CertStatus, ResponderId}; use crate::error::Error; use crate::policy::{process_policies, PolicyInput}; @@ -111,6 +120,12 @@ pub(crate) struct ChainResult { /// `keyCertSign` key usage, CRL + OCSP revocation, **name constraints**, and an /// optional **required policy** OID. Not enforced: the full policy /// `valid_policy_tree` / policy mapping. +/// +/// Revocation is **soft-fail**: a CRL or OCSP response is only acted on once it +/// is authenticated (signed by the issuing CA / an authorized responder), in +/// scope, and current; when no such evidence is available a certificate is not +/// treated as revoked. This avoids a forged or stale list silently flipping the +/// verdict, while not requiring online revocation material to be present. pub(crate) fn verify_chain( leaf: &Certificate, pool: &[Certificate], @@ -124,63 +139,84 @@ pub(crate) fn verify_chain( .map(|d| d.as_secs() as i64) .unwrap_or(0); - // 1. Build the ordered path [leaf, intermediate..., root] with per-link - // checks (signature, validity, revocation, CA constraints). + // Build [leaf, intermediate..., root] depth-first, backtracking past any + // candidate issuer that fails its checks so a valid alternative chain — e.g. + // under cross-signing, duplicate intermediates, or a candidate that trips a + // constraint — can still be found rather than abandoned. let mut path: Vec = vec![leaf.clone()]; - let mut intermediates = 0usize; - loop { - let current = path.last().unwrap().clone(); - if !valid_at(¤t, at) { - return fail("a certificate in the path is expired or not yet valid"); + extend_path(&mut path, store, pool, crls, ocsps, at, 0) +} + +/// Depth-first certificate-path construction with backtracking. `path` ends at +/// the certificate we are trying to chain upward to a trusted root. Returns the +/// first fully validated [`ChainResult`], else a failure. Each candidate issuer +/// is evaluated independently: a rejected candidate is skipped, not fatal, so a +/// later valid issuer still gets its turn. +#[allow(clippy::too_many_arguments)] +fn extend_path( + path: &mut Vec, + store: &TrustStore, + pool: &[Certificate], + crls: &[CertificateList], + ocsps: &[BasicOcspResponse], + at: i64, + intermediates: usize, +) -> ChainResult { + let current = path.last().unwrap().clone(); + if !valid_at(¤t, at) { + return fail("a certificate in the path is expired or not yet valid"); + } + // The current certificate is itself a trusted anchor. + if store.roots.iter().any(|r| same_cert(r, ¤t)) { + return finalize(path, store, at); + } + // The most informative rejection seen so far (a path that reached a root but + // failed a path-wide check beats the generic "no path" message). + let mut pending: Option = None; + + // Try every trusted root that could have issued `current`. + for root in store.roots.iter().filter(|r| issued_by(¤t, r)) { + if !valid_at(root, at) || revoked(¤t, root, crls, ocsps, at) { + continue; } - if is_revoked(¤t, crls) { - return fail("a certificate in the path has been revoked (CRL)"); + path.push(root.clone()); + let result = finalize(path, store, at); + if result.trusted { + return result; } - // Signer certificate is itself a trusted root. - if store.roots.iter().any(|r| same_cert(r, ¤t)) { - return finalize(&path, store, at); + pending.get_or_insert(result); + path.pop(); + } + if intermediates >= MAX_DEPTH { + return pending.unwrap_or_else(|| fail("certificate path too long")); + } + // Try every candidate intermediate that could have issued `current`. + for next in pool.iter() { + // Skip self and anything already on the path (avoid cycles). + if same_cert(next, ¤t) || path.iter().any(|c| same_cert(c, next)) { + continue; } - // Directly issued by a trusted root. - if let Some(root) = store.roots.iter().find(|r| issued_by(¤t, r)) { - if ocsp_revoked(¤t, root, ocsps) { - return fail("a certificate in the path has been revoked (OCSP)"); - } - if !valid_at(root, at) { - return fail("trusted root is expired"); - } - path.push(root.clone()); - return finalize(&path, store, at); + if !issued_by(¤t, next) { + continue; } - if path.len() > MAX_DEPTH { - return fail("certificate path too long"); + // Must be a CA whose pathLenConstraint still permits the certificates + // below it, assert keyCertSign, and not be revoked by its issuer. + match ca_constraints(next) { + Some((true, path_len)) if !path_len.is_some_and(|n| (n as usize) < intermediates) => {} + _ => continue, } - // Climb one intermediate; it must be a CA whose pathLenConstraint still - // permits the certificates below it, and assert keyCertSign. - match pool - .iter() - .find(|c| !same_cert(c, ¤t) && issued_by(¤t, c)) - { - Some(next) => { - match ca_constraints(next) { - Some((true, path_len)) => { - if path_len.is_some_and(|n| (n as usize) < intermediates) { - return fail("intermediate CA pathLenConstraint exceeded"); - } - } - _ => return fail("intermediate is not a CA (basicConstraints)"), - } - if !permits_cert_sign(next) { - return fail("intermediate CA lacks keyCertSign key usage"); - } - if ocsp_revoked(¤t, next, ocsps) { - return fail("a certificate in the path has been revoked (OCSP)"); - } - intermediates += 1; - path.push(next.clone()); - } - None => return fail("could not build a path to a trusted root"), + if !permits_cert_sign(next) || revoked(¤t, next, crls, ocsps, at) { + continue; + } + path.push(next.clone()); + let result = extend_path(path, store, pool, crls, ocsps, at, intermediates + 1); + if result.trusted { + return result; } + pending.get_or_insert(result); + path.pop(); } + pending.unwrap_or_else(|| fail("could not build a path to a trusted root")) } /// Run the path-wide checks (name constraints, required policy) once a trusted @@ -400,17 +436,61 @@ fn uri_host(uri: &str) -> &str { } -/// True if an OCSP response marks `cert` (under `issuer`) as revoked. -fn ocsp_revoked(cert: &Certificate, issuer: &Certificate, ocsps: &[BasicOcspResponse]) -> bool { - let Ok(want) = - CertId::from_issuer::(issuer, cert.tbs_certificate.serial_number.clone()) +/// True if an **authenticated** OCSP response marks `cert` (under `issuer`) as +/// revoked. The response must be signed either by the issuer itself or by a +/// delegated responder it certified (with the `id-kp-OCSPSigning` EKU), and the +/// matching single response must be current. Unauthenticated or stale responses +/// are ignored (soft-fail, see [`revoked`]). +fn ocsp_revoked( + cert: &Certificate, + issuer: &Certificate, + ocsps: &[BasicOcspResponse], + at: i64, +) -> bool { + let Ok(want) = CertId::from_issuer::(issuer, cert.tbs_certificate.serial_number.clone()) else { return false; }; for basic in ocsps { + if !ocsp_authentic(basic, issuer) { + continue; + } for single in basic.tbs_response_data.responses.iter() { if cert_id_eq(&single.cert_id, &want) && matches!(single.cert_status, CertStatus::Revoked(_)) + && ocsp_single_current(single, at) + { + return true; + } + } + } + false +} + +/// Verify that a `BasicOcspResponse` is signed by an authorized responder for +/// `issuer`: either `issuer` directly, or a delegated responder certificate +/// embedded in the response, issued by `issuer` and bearing the OCSP-signing EKU. +fn ocsp_authentic(basic: &BasicOcspResponse, issuer: &Certificate) -> bool { + let Ok(tbs) = basic.tbs_response_data.to_der() else { + return false; + }; + let Some(sig) = basic.signature.as_bytes() else { + return false; + }; + let oid = basic.signature_algorithm.oid; + let rid = &basic.tbs_response_data.responder_id; + + // The issuer signs its own OCSP responses. + if responder_is(rid, issuer) && verify_with_cert(issuer, &tbs, oid, sig) { + return true; + } + // A delegated responder certified by the issuer. + if let Some(certs) = &basic.certs { + for c in certs { + if responder_is(rid, c) + && issued_by(c, issuer) + && has_ocsp_signing_eku(c) + && verify_with_cert(c, &tbs, oid, sig) { return true; } @@ -419,6 +499,53 @@ fn ocsp_revoked(cert: &Certificate, issuer: &Certificate, ocsps: &[BasicOcspResp false } +/// Verify `sig`/`oid` over `tbs` using `cert`'s public key. +fn verify_with_cert(cert: &Certificate, tbs: &[u8], oid: ObjectIdentifier, sig: &[u8]) -> bool { + match cert.tbs_certificate.subject_public_key_info.to_der() { + Ok(spki) => verify_signature(tbs, oid, sig, &spki), + Err(_) => false, + } +} + +/// True if `rid` identifies `cert` (by subject name or by SHA-1 key hash). +fn responder_is(rid: &ResponderId, cert: &Certificate) -> bool { + match rid { + ResponderId::ByName(name) => { + name.to_der().ok() == cert.tbs_certificate.subject.to_der().ok() + } + ResponderId::ByKey(key_hash) => { + match cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .as_bytes() + { + Some(pk) => Sha1::digest(pk).as_slice() == key_hash.as_bytes(), + None => false, + } + } + } +} + +/// True if `cert` asserts the `id-kp-OCSPSigning` extended key usage. +fn has_ocsp_signing_eku(cert: &Certificate) -> bool { + matches!( + cert.tbs_certificate.get::(), + Ok(Some((_, eku))) if eku.0.contains(&ID_KP_OCSP_SIGNING) + ) +} + +/// True if `at` falls within the single response's `thisUpdate..nextUpdate`. +fn ocsp_single_current(single: &x509_ocsp::SingleResponse, at: i64) -> bool { + if at < single.this_update.0.to_unix_duration().as_secs() as i64 { + return false; + } + match &single.next_update { + Some(nu) => at <= nu.0.to_unix_duration().as_secs() as i64, + None => true, + } +} + /// Compare two `CertID`s by name hash, key hash and serial (ignoring the hash /// algorithm's encoding nuances). fn cert_id_eq(a: &CertId, b: &CertId) -> bool { @@ -457,25 +584,82 @@ fn permits_cert_sign(cert: &Certificate) -> bool { } } -/// True if `cert` appears (by serial, under its own issuer) in any CRL. -fn is_revoked(cert: &Certificate, crls: &[CertificateList]) -> bool { - let issuer = cert.tbs_certificate.issuer.to_der().ok(); +/// True if `cert` is revoked by `issuer` according to any authenticated CRL or +/// OCSP response. Revocation is soft-fail: when no usable (authenticated, fresh, +/// in-scope) evidence is available the certificate is *not* treated as revoked. +fn revoked( + cert: &Certificate, + issuer: &Certificate, + crls: &[CertificateList], + ocsps: &[BasicOcspResponse], + at: i64, +) -> bool { + crl_revoked(cert, issuer, crls, at) || ocsp_revoked(cert, issuer, ocsps, at) +} + +/// True if an **authenticated** CRL from `issuer` lists `cert` as revoked. +/// +/// A CRL is only consulted when it is in scope (issued by this CA), its +/// signature verifies under the CA's key, and it is currently within its +/// `thisUpdate..nextUpdate` window. Unauthenticated, out-of-scope or stale CRLs +/// are ignored rather than trusted (revocation is otherwise soft-fail: absence +/// of usable revocation data does not by itself make a certificate untrusted — +/// see [`verify_chain`]). +fn crl_revoked(cert: &Certificate, issuer: &Certificate, crls: &[CertificateList], at: i64) -> bool { let serial = cert.tbs_certificate.serial_number.to_der().ok(); + let ca_subject = issuer.tbs_certificate.subject.to_der().ok(); for crl in crls { - if crl.tbs_cert_list.issuer.to_der().ok() != issuer { + // Scope: the CRL must be issued by this CA. + if crl.tbs_cert_list.issuer.to_der().ok() != ca_subject { + continue; + } + // Authenticity: the CRL must be signed by this CA. + if !verify_crl_signature(crl, issuer) { + continue; + } + // Freshness: thisUpdate <= at <= nextUpdate (when present). + if !crl_current(crl, at) { continue; } if let Some(revoked) = &crl.tbs_cert_list.revoked_certificates { - for entry in revoked { - if entry.serial_number.to_der().ok() == serial { - return true; - } + if revoked + .iter() + .any(|entry| entry.serial_number.to_der().ok() == serial) + { + return true; } } } false } +/// Verify a CRL's signature under the issuing CA's public key. +fn verify_crl_signature(crl: &CertificateList, issuer: &Certificate) -> bool { + let Ok(tbs) = crl.tbs_cert_list.to_der() else { + return false; + }; + let Some(sig) = crl.signature.as_bytes() else { + return false; + }; + let Ok(spki) = issuer.tbs_certificate.subject_public_key_info.to_der() else { + return false; + }; + verify_signature(&tbs, crl.signature_algorithm.oid, sig, &spki) +} + +/// True if `at` falls within the CRL's `thisUpdate..nextUpdate` validity window. +/// A CRL without `nextUpdate` is treated as not-yet-stale (only `thisUpdate` is +/// enforced). +fn crl_current(crl: &CertificateList, at: i64) -> bool { + if at < time_secs(&crl.tbs_cert_list.this_update) { + return false; + } + match &crl.tbs_cert_list.next_update { + Some(nu) => at <= time_secs(nu), + None => true, + } +} + /// `child` is issued by `issuer`: issuer/subject names match and the issuer's /// public key verifies the child's signature. fn issued_by(child: &Certificate, issuer: &Certificate) -> bool { @@ -497,28 +681,34 @@ fn verify_cert_signature(child: &Certificate, issuer: &Certificate) -> bool { let Ok(spki) = issuer.tbs_certificate.subject_public_key_info.to_der() else { return false; }; - let oid = child.signature_algorithm.oid; + verify_signature(&tbs, child.signature_algorithm.oid, sig, &spki) +} +/// Verify `sig` over `tbs` under the algorithm `oid`, using the signer's +/// SubjectPublicKeyInfo DER. Shared by certificate, CRL and OCSP verification. +/// SHA-1-based algorithms are treated as unverifiable. +fn verify_signature(tbs: &[u8], oid: ObjectIdentifier, sig: &[u8], signer_spki_der: &[u8]) -> bool { if oid == SHA_256_WITH_RSA_ENCRYPTION || oid == SHA_384_WITH_RSA_ENCRYPTION || oid == SHA_512_WITH_RSA_ENCRYPTION { - let (Ok(pubkey), Ok(signature)) = - (RsaPublicKey::from_public_key_der(&spki), Signature::try_from(sig)) - else { + let (Ok(pubkey), Ok(signature)) = ( + RsaPublicKey::from_public_key_der(signer_spki_der), + Signature::try_from(sig), + ) else { return false; }; if oid == SHA_256_WITH_RSA_ENCRYPTION { - VerifyingKey::::new(pubkey).verify(&tbs, &signature).is_ok() + VerifyingKey::::new(pubkey).verify(tbs, &signature).is_ok() } else if oid == SHA_384_WITH_RSA_ENCRYPTION { - VerifyingKey::::new(pubkey).verify(&tbs, &signature).is_ok() + VerifyingKey::::new(pubkey).verify(tbs, &signature).is_ok() } else { - VerifyingKey::::new(pubkey).verify(&tbs, &signature).is_ok() + VerifyingKey::::new(pubkey).verify(tbs, &signature).is_ok() } } else if oid == ECDSA_WITH_SHA_256 || oid == ECDSA_WITH_SHA_384 || oid == ECDSA_WITH_SHA_512 { - verify_ecdsa(&spki, &tbs, sig) + verify_ecdsa(signer_spki_der, tbs, sig) } else if oid == ID_ED_25519 { - verify_ed25519(&spki, &tbs, sig) + verify_ed25519(signer_spki_der, tbs, sig) } else { false // unsupported (e.g. SHA-1) } @@ -557,21 +747,16 @@ fn verify_ecdsa(spki_der: &[u8], tbs: &[u8], sig: &[u8]) -> bool { } fn valid_at(cert: &Certificate, at: i64) -> bool { - let nb = cert - .tbs_certificate - .validity - .not_before - .to_unix_duration() - .as_secs() as i64; - let na = cert - .tbs_certificate - .validity - .not_after - .to_unix_duration() - .as_secs() as i64; + let nb = time_secs(&cert.tbs_certificate.validity.not_before); + let na = time_secs(&cert.tbs_certificate.validity.not_after); at >= nb && at <= na } +/// An X.509 `Time` (UTCTime / GeneralizedTime) as seconds since the Unix epoch. +fn time_secs(t: &x509_cert::time::Time) -> i64 { + t.to_unix_duration().as_secs() as i64 +} + fn same_cert(a: &Certificate, b: &Certificate) -> bool { match (a.to_der(), b.to_der()) { (Ok(x), Ok(y)) => x == y, diff --git a/src/rust/pdf_signer/src/tsa.rs b/src/rust/pdf_signer/src/tsa.rs index a58d14e..4584e4a 100644 --- a/src/rust/pdf_signer/src/tsa.rs +++ b/src/rust/pdf_signer/src/tsa.rs @@ -86,9 +86,19 @@ pub(crate) fn request_timestamp(tsa_url: &str, signature: &[u8]) -> Result bool { !self.signatures.is_empty() && self.signatures.iter().all(|s| s.valid) } + + /// True if every signature is valid ([`all_valid`](Self::all_valid)) **and** + /// none chains to an untrusted root. Use this when a trust store was + /// supplied; entries with no trust result (`chain_trusted == None`, e.g. + /// document timestamps) are not treated as failures. + pub fn all_trusted(&self) -> bool { + self.all_valid() + && self + .signatures + .iter() + .all(|s| s.chain_trusted != Some(false)) + } } /// Verify the signatures of a PDF file. @@ -108,38 +126,131 @@ pub fn verify_pdf_file_with_roots( verify_pdf_bytes_with_roots(&pdf, roots) } +/// A signature located in the document: its `/ByteRange`, the raw `/Contents` +/// bytes (hex-decoded, including any zero padding), and whether it is a document +/// timestamp (`/SubFilter /ETSI.RFC3161`). +struct SigLoc { + byte_range: [i64; 4], + contents: Vec, + is_timestamp: bool, +} + /// Verify an in-memory PDF, validating signer chains against `roots`. pub fn verify_pdf_bytes_with_roots(pdf: &[u8], roots: &TrustStore) -> Result { let mut signatures = Vec::new(); + for sig in collect_signatures(pdf) { + signatures.push(verify_one(pdf, &sig, roots)?); + } + Ok(SignatureReport { signatures }) +} + +/// Locate every signature by document **structure** — the signature dictionaries +/// (`/ByteRange` + `/Contents`) reachable in the parsed object set — rather than +/// by scanning the raw bytes for `/ByteRange`, which could match a string, +/// stream or comment. Reads `/ByteRange` and `/SubFilter` from the dictionary, +/// so it does not depend on key order. Falls back to a byte scan only if the +/// document cannot be parsed at all. +fn collect_signatures(pdf: &[u8]) -> Vec { + // Only fall back to scanning when the document cannot be parsed at all. A + // document that parses but has no signature dictionaries genuinely has no + // signatures — a `/ByteRange` found loose in a stream or string is not one. + let Ok(doc) = Document::load_mem(pdf) else { + return collect_signatures_by_scan(pdf); + }; + let mut sigs: Vec = doc + .objects + .values() + .filter_map(|obj| obj.as_dict().ok()) + .filter_map(signature_from_dict) + .collect(); + // Report in file order: an earlier signature's `/Contents` hex string (which + // begins at `s1 + l1`) sits at a lower offset than a later one. + sigs.sort_by_key(|s| s.byte_range[0] + s.byte_range[1]); + sigs +} + +/// Recognize a signature dictionary and read the fields we need, regardless of +/// key order. A signature dictionary carries both a `/ByteRange` array and a +/// `/Contents` string. +fn signature_from_dict(dict: &Dictionary) -> Option { + let contents = dict.get(b"Contents").ok()?.as_str().ok()?.to_vec(); + let arr = dict.get(b"ByteRange").ok()?.as_array().ok()?; + if arr.len() != 4 { + return None; + } + let mut byte_range = [0i64; 4]; + for (slot, v) in byte_range.iter_mut().zip(arr) { + *slot = v.as_i64().ok()?; + } + let is_timestamp = dict.get(b"SubFilter").ok().and_then(|o| o.as_name().ok()) + == Some(b"ETSI.RFC3161".as_ref()); + Some(SigLoc { + byte_range, + contents, + is_timestamp, + }) +} + +/// Fallback enumeration for documents that fail to parse: scan the raw bytes for +/// each `/ByteRange` (the historical behavior). +fn collect_signatures_by_scan(pdf: &[u8]) -> Vec { + let mut out = Vec::new(); let mut from = 0; while let Some(rel) = find_sub(&pdf[from..], b"/ByteRange") { let br = from + rel; from = br + b"/ByteRange".len(); - signatures.push(verify_one(pdf, br, roots)?); + if let (Ok(byte_range), Some(contents)) = + (parse_byte_range(&pdf[br..]), scan_contents(pdf, br)) + { + let is_timestamp = subfilter_before(pdf, br).as_deref() == Some(b"ETSI.RFC3161"); + out.push(SigLoc { + byte_range, + contents, + is_timestamp, + }); + } } - Ok(SignatureReport { signatures }) + out } -/// Verify the single signature whose `/ByteRange` begins at `br`. -fn verify_one(pdf: &[u8], br: usize, roots: &TrustStore) -> Result { - let byte_range = parse_byte_range(&pdf[br..])?; - let der = extract_cms(pdf, br)?; +/// Scan for the `/Contents <...>` hex string following `/ByteRange` at `br` and +/// decode it (fallback path only). +fn scan_contents(pdf: &[u8], br: usize) -> Option> { + let from = br + find_sub(&pdf[br..], b"/Contents")?; + let lt = from + find_sub(&pdf[from..], b"<")?; + let gt = lt + find_sub(&pdf[lt..], b">")?; + hex_decode(&pdf[lt + 1..gt]) +} - // Reassemble the signed content from the two byte-range segments. +/// Verify a single located signature. +fn verify_one(pdf: &[u8], sig: &SigLoc, roots: &TrustStore) -> Result { + let byte_range = sig.byte_range; + if byte_range.iter().any(|&v| v < 0) { + return Err(Error::Malformed("negative ByteRange value".into())); + } let [s1, l1, s2, l2] = byte_range.map(|v| v as usize); if s1 + l1 > pdf.len() || s2 + l2 > pdf.len() { return Err(Error::Malformed("ByteRange out of bounds".into())); } + + // The CMS comes from the structurally-parsed `/Contents`, not from the bytes + // the ByteRange happens to point at. + let der = cms_from_contents(&sig.contents)?; + + // Reassemble the signed content from the two byte-range segments. let mut signed = Vec::with_capacity(l1 + l2); signed.extend_from_slice(&pdf[s1..s1 + l1]); signed.extend_from_slice(&pdf[s2..s2 + l2]); - let covers_whole_document = s1 == 0 && (s2 + l2) == pdf.len(); - - // A `/DocTimeStamp` (SubFilter ETSI.RFC3161) holds a bare RFC 3161 token, - // not a detached document signature — verify it differently. - let is_timestamp = subfilter_before(pdf, br).as_deref() == Some(b"ETSI.RFC3161"); + // "Covers the whole document" requires spanning byte 0 to EOF *and* that the + // only excluded bytes — the ByteRange gap `[s1+l1, s2)` — are exactly the + // `/Contents <...>` hex string. Otherwise a ByteRange could leave arbitrary + // unsigned bytes in the gap and still claim full coverage. + let covers_whole_document = s1 == 0 + && (s2 + l2) == pdf.len() + && gap_is_contents(pdf, s1 + l1, s2, &sig.contents); + let is_timestamp = sig.is_timestamp; let mut chain_trusted = None; let (valid, signer, mut detail) = if is_timestamp { match verify_doc_timestamp(&der, &signed) { @@ -166,7 +277,15 @@ fn verify_one(pdf: &[u8], br: usize, roots: &TrustStore) -> Result Result Option { + let ts = verify_embedded_timestamp(der).ok()?; + // The TSA must itself be trusted (validated at the present time), else a + // self-issued TSA could assert any genTime to dodge expiry/revocation. + verify_chain(&ts.tsa_leaf, &ts.tsa_pool, roots, crls, ocsps, SystemTime::now()) + .trusted + .then_some(ts.gen_time) +} + /// Read the `/SubFilter` name that precedes the `/ByteRange` at `br` (each /// signature dictionary writes SubFilter before ByteRange). fn subfilter_before(pdf: &[u8], br: usize) -> Option> { @@ -232,25 +369,29 @@ fn parse_byte_range(s: &[u8]) -> Result<[i64; 4]> { Ok([nums[0], nums[1], nums[2], nums[3]]) } -/// Pull the CMS DER out of the `/Contents <...>` hex string after `/ByteRange`. -fn extract_cms(pdf: &[u8], byte_range_pos: usize) -> Result> { - let rel = find_sub(&pdf[byte_range_pos..], b"/Contents") - .ok_or_else(|| Error::Malformed("/Contents not found".into()))?; - let from = byte_range_pos + rel; - let lt = from - + find_sub(&pdf[from..], b"<").ok_or_else(|| Error::Malformed("Contents '<' missing".into()))?; - let gt = lt - + find_sub(&pdf[lt..], b">").ok_or_else(|| Error::Malformed("Contents '>' missing".into()))?; - let raw = hex_decode(&pdf[lt + 1..gt]) - .ok_or_else(|| Error::Malformed("Contents not valid hex".into()))?; - // Slice off the zero padding using the ASN.1 length header. - if raw.first() != Some(&0x30) { +/// Slice the real CMS out of the raw `/Contents` bytes, dropping the zero +/// padding using the ASN.1 length header. +fn cms_from_contents(contents: &[u8]) -> Result> { + if contents.first() != Some(&0x30) { return Err(Error::Malformed("CMS does not start with SEQUENCE".into())); } - let len = der_total_len(&raw) + let len = der_total_len(contents) .ok_or_else(|| Error::Malformed("cannot read CMS DER length".into()))?; - if len > raw.len() { + if len > contents.len() { return Err(Error::Malformed("CMS DER length exceeds placeholder".into())); } - Ok(raw[..len].to_vec()) + Ok(contents[..len].to_vec()) +} + +/// True if the ByteRange gap `[gap_start, gap_end)` is exactly the `/Contents` +/// hex string `<...>` whose decoded value is `contents` — i.e. the signature +/// excludes nothing but its own Contents. +fn gap_is_contents(pdf: &[u8], gap_start: usize, gap_end: usize, contents: &[u8]) -> bool { + if gap_start >= gap_end || gap_end > pdf.len() { + return false; + } + let gt = gap_end - 1; + pdf.get(gap_start) == Some(&b'<') + && pdf.get(gt) == Some(&b'>') + && hex_decode(&pdf[gap_start + 1..gt]).as_deref() == Some(contents) } From 91ac5c2792a65985e1d8fcbc5dd3f00abfbfc631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Tue, 16 Jun 2026 12:55:36 -0300 Subject: [PATCH 2/2] release: pdfsigner 0.2.3 (pdf_signer engine v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the v0.2.0 engine (verification & trust hardening, audit #1–#10). Co-Authored-By: Claude Opus 4.8 (1M context) --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 01766c5..9c8d7aa 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: pdfsigner Title: Digitally Sign and Verify PDF Documents Type: Package -Version: 0.2.2 +Version: 0.2.3 Authors@R: c( person("Andre", "Leite", email = "leite@castlab.org",