diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 55ed5d353..4668881a6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option { c.push_str("h\n"); } } - c.push_str("f*\n"); + c.push_str("f\n"); return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 9e84cb62b..6edfbf9cb 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -394,7 +394,7 @@ pub enum Shape { by: Option, }, /// A typed signature or initials: filled outlines (each contour as points normalised to - /// `rect`, 0–1, y up), drawn with the even-odd rule. + /// `rect`, 0–1, y up), drawn with the non-zero winding rule. TypedSignature { rect: [f64; 4], contours: Vec>, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 91617a0dc..a65996681 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1491,3 +1491,16 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc).unwrap(), [first, second]); } + +/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same +/// winding direction where strokes cross, so typed signature appearances must fill with the +/// non-zero winding operator (`f`), not even-odd (`f*`). +#[test] +fn typed_signature_uses_nonzero_winding_fill() { + let mut doc = fixture(); + let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]]; + let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap(); + let ap = ap_content(&doc, &list(&doc, 0)[idx]); + assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); + assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 551510461..550238b4e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -769,7 +769,7 @@ pub fn tools() -> Vec { }), &["doc", "id", "out"], )), - t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema( + t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema( json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }), &[], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 066c6feed..5c55733cb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5124,6 +5124,13 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. + use pdfcraft_engine::sign::{cms, der, pkcs12}; + let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; + let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); + std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); + assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 21c4fdd66..5433e4257 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -51,6 +51,18 @@ pub fn cjk_width(s: &str, size: f64) -> f64 { .sum() } +/// Approximate advance of `s` in Inter Regular at `size` points. +pub fn inter_width(s: &str, size: f64) -> f64 { + use skrifa::MetadataProvider as _; + let Ok(font) = skrifa::FontRef::new(INTER_REGULAR) else { return helvetica_width(s, size) }; + let loc = skrifa::instance::LocationRef::default(); + let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em.max(1) as f64; + let scale = size / upem; + let charmap = font.charmap(); + let advances = font.glyph_metrics(skrifa::instance::Size::unscaled(), loc); + s.chars().map(|c| charmap.map(c).and_then(|gid| advances.advance_width(gid)).unwrap_or(0.0) as f64 * scale).sum() +} + /// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block). fn is_wide(c: char) -> bool { matches!(c, @@ -323,6 +335,14 @@ mod tests { assert_eq!(undrawn_mark(&d), None); } + #[test] + fn inter_width_measures_latin_extended_and_ascii() { + assert!(inter_width("Luka Petravić", 12.0) > 0.0); + assert_eq!(inter_width("", 12.0), 0.0); + let w = inter_width("čžšć", 10.0); + assert!(w > 10.0 && w < 100.0); + } + #[test] fn widths_wrap_and_encode() { assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); diff --git a/crates/sign/README.md b/crates/sign/README.md index c3462fa93..72f72dd8a 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -83,6 +83,13 @@ needs a prompt to open is listed and asks at signing (issue #179). attributes are still verified over their own exact bytes (signed attributes written with an indefinite length are re-encoded as DER first, as RFC 5652 §5.4 has them signed). Nesting is bounded, and the details say that the signature was BER. High tag numbers are not read. +- **One content type, one message digest.** A CMS whose signed attributes repeat the content type + or the message digest, or give either of them more or fewer than one value, is not read (RFC + 5652 §11.1, §11.2). Neither is one whose content-type attribute differs from `eContentType`: + the signature covers the attribute but not `eContentType`. Both content types must be object + identifiers, and a PDF signature's must be data. A signature with signed attributes but no + content-type attribute is read anyway, with a note, although §11.1 requires one: then + `eContentType` alone says that the content is data. - **Later changes are classified narrowly.** Only the catalog's own `/Metadata` stream is the document's XMP, and only objects reached through the `/DSS` keys above are the security store. A `/Type /Metadata` or `/Type /DSS` *label* on any other dictionary or stream (a page's Form diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index f1f17e4d4..3568cbf45 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -50,10 +50,67 @@ pub struct SignedData { pub signer: SignerInfo, /// Encapsulated content (`adbe.pkcs7.sha1` carries the document digest here). pub content: Option>, + /// `eContentType`, the type of the encapsulated content: data for a PDF signature, TSTInfo + /// for an RFC 3161 token. The signature does not cover it. + pub econtent_type: String, /// Encoding irregularities that were tolerated (BER instead of DER, …), for the details. pub quirks: Vec<&'static str>, } +/// The X.509 certificates in a SignedData `certificates` field (`[0] IMPLICIT CertificateSet`). +/// Other certificate formats (attribute certificates) and certificates that don't parse are +/// skipped. +fn carried_certificates(set: &Tlv) -> Result, SignError> { + let mut out = Vec::new(); + for c in set.children()? { + if c.tag == tag::SEQUENCE + && let Ok(cert) = Certificate::parse(c.raw) + { + out.push(cert); + } + } + Ok(out) +} + +/// The certificates of a PKCS #7 certificate bundle (`.p7b`, `.p7c`): a SignedData with no +/// signers ("degenerate", RFC 5652 §5.2), which is how Windows, Acrobat and certificate +/// authorities hand out a chain or a set of CA certificates. Signers and CRLs, if present, are +/// ignored; only the certificates are read. DER or BER. +pub fn certificate_bundle(bytes: &[u8]) -> Result, SignError> { + let (ci, rest) = Tlv::parse_ber(bytes)?; + // A trust file is the bundle alone: bytes after it mean a damaged or spliced file. + if !rest.is_empty() { + return Err(bad("data after the bundle")); + } + let ci = ci.expect(tag::SEQUENCE, "ContentInfo")?.children()?; + let [ct, content] = ci.as_slice() else { return Err(bad("ContentInfo")) }; + if ct.oid()? != SIGNED_DATA { + return Err(bad("not SignedData")); + } + let sd = content.expect(tag::ctx(0), "content")?.inner()?.children()?; + // version, digestAlgorithms, encapContentInfo, the optional [0] certificates and [1] CRLs, + // then signerInfos (RFC 5652 §5.1). + let [version, digests, encap, middle @ .., signers] = sd.as_slice() else { return Err(bad("SignedData")) }; + let shape = [(version, tag::INTEGER), (digests, tag::SET), (encap, tag::SEQUENCE), (signers, tag::SET)]; + // At most one [0] certificates and one [1] CRLs, in that order. + const CERTS: u8 = tag::ctx(0); + const CRLS: u8 = tag::ctx(1); + let middle_ok = matches!(middle.iter().map(|t| t.tag).collect::>().as_slice(), [] | [CERTS] | [CRLS] | [CERTS, CRLS]); + // encapContentInfo starts with its eContentType OID. + let encap_ok = encap.children()?.first().is_some_and(|t| t.tag == tag::OID); + if shape.iter().any(|(t, want)| t.tag != *want) || !middle_ok || !encap_ok { + return Err(bad("SignedData")); + } + let mut certificates = Vec::new(); + for t in middle.iter().filter(|t| t.tag == tag::ctx(0)) { + certificates.extend(carried_certificates(t)?); + } + if certificates.is_empty() { + return Err(bad("the bundle holds no certificate")); + } + Ok(certificates) +} + fn bad(what: &str) -> SignError { SignError::Malformed(format!("CMS: {what}")) } @@ -80,6 +137,7 @@ impl SignedData { let _version = it.next(); let _digest_algs = it.next(); let encap = it.next().ok_or_else(|| bad("encapContentInfo"))?.children()?; + let econtent_type = encap.first().ok_or_else(|| bad("eContentType"))?.oid()?; let content = match encap.get(1) { Some(c) if c.tag == tag::ctx(0) => { // BER signers may split the content into a constructed OCTET STRING. @@ -95,23 +153,23 @@ impl SignedData { let mut signer_infos = None; for t in it { match t.tag { - t2 if t2 == tag::ctx(0) => { - for c in t.children()? { - // Other certificate formats (attribute certificates) are skipped. - if c.tag == tag::SEQUENCE - && let Ok(cert) = Certificate::parse(c.raw) - { - certificates.push(cert); - } - } - } + t2 if t2 == tag::ctx(0) => certificates.extend(carried_certificates(&t)?), tag::SET => signer_infos = Some(t), _ => {} } } let infos = signer_infos.ok_or_else(|| bad("no signerInfos"))?.children()?; let si = infos.first().ok_or_else(|| bad("no signer"))?; - Ok(SignedData { certificates, signer: parse_signer(si)?, content, quirks }) + let signer = parse_signer(si)?; + // RFC 5652 §11.1: the content-type attribute names the type of the encapsulated content. + // The signature covers the attribute but not `eContentType`, so when they differ, one of + // them was written wrong or changed after signing. + if let Some(signed) = &signer.content_type + && *signed != econtent_type + { + return Err(bad("the signed content type is not the encapsulated content type")); + } + Ok(SignedData { certificates, signer, content, econtent_type, quirks }) } /// The signer's certificate among those carried. @@ -204,13 +262,28 @@ fn parse_signer(si: &Tlv<'_>) -> Result { if let Some(t) = set.first_mut() { *t = tag::SET; } + let (mut seen_type, mut seen_digest) = (false, false); for a in next.children()? { let p = a.children()?; let Some(o) = p.first().and_then(|o| o.oid().ok()) else { continue }; - let value = p.get(1).and_then(|v| v.children().ok()).and_then(|v| v.into_iter().next()); + let values = p.get(1).and_then(|v| v.children().ok()).unwrap_or_default(); + // RFC 5652 §11.1 and §11.2: one content-type and one message-digest attribute, each + // with one value. With a second one, two readers could each take a different value. + let (seen, what) = match o.as_str() { + CONTENT_TYPE => (Some(&mut seen_type), "content type"), + MESSAGE_DIGEST => (Some(&mut seen_digest), "message digest"), + _ => (None, ""), + }; + if let Some(seen) = seen + && (std::mem::replace(seen, true) || values.len() != 1) + { + return Err(bad(&format!("the signed attributes must hold one {what} with one value"))); + } + let value = values.into_iter().next(); match (o.as_str(), value) { (MESSAGE_DIGEST, Some(v)) => message_digest = Some(v.value.to_vec()), - (CONTENT_TYPE, Some(v)) => content_type = v.oid().ok(), + // A value that is not an OID would skip every content-type check after this. + (CONTENT_TYPE, Some(v)) => content_type = Some(v.oid()?), (SIGNING_TIME, Some(v)) => signing_time = v.time().ok(), (SIGNING_CERT_V2 | SIGNING_CERT, _) => signing_certificate = true, _ => {} diff --git a/crates/sign/src/graphic.rs b/crates/sign/src/graphic.rs index 5d50c6f76..7b1535043 100644 --- a/crates/sign/src/graphic.rs +++ b/crates/sign/src/graphic.rs @@ -1,7 +1,7 @@ //! A picture in a visible signature, where the signer's name would be large (Acrobat: Configure //! Signature Appearance ▸ Draw or Image, and typed signatures). Drawn as Fill & Sign draws its -//! signatures: ink smoothed and stroked with round ends, typed outlines filled even-odd, images -//! with their alpha as a soft mask. +//! signatures: ink smoothed and stroked with round ends, typed outlines filled with the non-zero +//! winding rule, images with their alpha as a soft mask. use std::sync::Arc; @@ -21,7 +21,7 @@ const MAX_PIXELS: u64 = 1 << 24; pub enum Graphic { /// Drawn strokes, y up, in units where the drawing pad is 1 wide (as Fill & Sign saves them). Strokes(Vec>), - /// Closed outlines, y up, filled even-odd: a typed signature in the script font. + /// Closed outlines, y up, filled with the non-zero winding rule: a typed signature in the script font. Outlines(Vec>), /// Straight (not premultiplied) RGBA pixels, rows from the top. Image { width: u32, height: u32, rgba: Arc> }, @@ -141,7 +141,7 @@ impl Graphic { } c.push_str("h\n"); } - c.push_str("f*\nQ\n"); + c.push_str("f\nQ\n"); Ok((c, None)) } Graphic::Image { width, height, rgba } => { diff --git a/crates/sign/src/keychain.rs b/crates/sign/src/keychain.rs index 601f1b97b..90bb7408f 100644 --- a/crates/sign/src/keychain.rs +++ b/crates/sign/src/keychain.rs @@ -5,9 +5,12 @@ use std::path::Path; use std::sync::Arc; +use security_framework::certificate::SecCertificate; use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; use security_framework::key::{Algorithm, SecKey}; use security_framework::os::macos::keychain::SecKeychain; +use security_framework::policy::SecPolicy; +use security_framework::trust::SecTrust; use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey}; use crate::{Certificate, DigitalId, SignError}; @@ -37,15 +40,36 @@ pub fn reference(c: &Certificate) -> String { format!("keychain:{}", d.iter().map(|b| format!("{b:02x}")).collect::()) } -/// The identity a reference (or a certificate common name) names. +/// The identity a reference (or a certificate common name) names. Its `chain` holds the +/// issuers macOS 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(None)? + let mut id = identities(None)? .into_iter() .find(|id| { reference(&id.certificate) == reference_or_name || id.certificate.subject.common_name() == Some(reference_or_name.trim_start_matches("keychain:")) }) - .ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}"))) + .ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}")))?; + id.chain = issuers(&id.certificate); + Ok(id) +} + +/// The certificates macOS chains `certificate` to through the user's keychains (and the +/// system roots), without `certificate` itself. Best effort: an identity signs without them +/// too. Nothing is fetched from the network, so signing stays offline. +fn issuers(certificate: &Certificate) -> Vec { + let Ok(leaf) = SecCertificate::from_der(&certificate.raw) else { return Vec::new() }; + let Ok(mut trust) = SecTrust::create_with_certificates(&[leaf], &[SecPolicy::create_x509()]) else { return Vec::new() }; + if trust.set_network_fetch_allowed(false).is_err() { + return Vec::new(); + } + // The chain is built even when it doesn't end at a trusted root (a private CA, an expired + // certificate); whether the signer is trusted is decided when the signature is validated. + let _ = trust.evaluate_with_error(); + // `SecTrust::chain` needs macOS 12; PdfCraft runs on macOS 11 (packaging/macos/package.sh). + #[allow(deprecated)] + let chain: Vec = (0..trust.certificate_count()).filter_map(|i| trust.certificate_at_index(i)).collect(); + chain.iter().map(SecCertificate::to_der).filter(|der| *der != certificate.raw).filter_map(|der| Certificate::parse(&der).ok()).collect() } /// The signing identities in the user's keychains (or only in the keychain file `keychain`). diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index a32190fb9..0521c5d91 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -278,6 +278,12 @@ fn pss_params(params: Option<&Tlv<'_>>) -> Result<(DigestAlg, PssParams), SignEr Ok((hash, PssParams { mgf, salt_len: Some(salt_len) })) } +/// The length in bits of the RSA modulus `n` (big-endian; leading zero bytes don't count). +fn rsa_bits(n: &[u8]) -> usize { + let n = n.iter().position(|b| *b != 0).and_then(|i| n.get(i..)).unwrap_or_default(); + n.first().map_or(0, |b| n.len() * 8 - b.leading_zeros() as usize) +} + /// A public key from a `SubjectPublicKeyInfo`. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PublicKey { @@ -380,7 +386,7 @@ impl PublicKey { /// "RSA 2048-bit", "ECDSA P-256"… pub fn describe(&self) -> String { match self { - PublicKey::Rsa { n, .. } => format!("RSA {}-bit", n.len() * 8), + PublicKey::Rsa { n, .. } => format!("RSA {}-bit", rsa_bits(n)), PublicKey::P256(_) => "ECDSA P-256".into(), PublicKey::P384(_) => "ECDSA P-384".into(), PublicKey::P521(_) => "ECDSA P-521".into(), @@ -441,10 +447,31 @@ impl PublicKey { self.verify_noting(scheme, alg, digest, sig, &mut Vec::new()) } - /// [`PublicKey::verify`], noting in `notes` each encoding irregularity it tolerated. Only - /// meaningful when it returns `Ok(true)`: a signature that doesn't verify may leave notes - /// about how far it got. + /// [`PublicKey::verify`], noting in `notes` each encoding irregularity it tolerated, and a key + /// too small to rely on ([`PublicKey::weakness`]). Only meaningful when it returns `Ok(true)`: + /// a signature that doesn't verify may leave notes about how far it got. pub fn verify_noting(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result { + let verified = self.verify_scheme(scheme, alg, digest, sig, notes); + if let Ok(true) = verified + && let Some(weak) = self.weakness() + { + notes.push(weak); + } + verified + } + + /// What to tell the user when a signature that still validates was made with this key, or + /// `None` for one that is fine, as [`DigestAlg::weakness`] does for hashes. + pub fn weakness(&self) -> Option<&'static str> { + match self { + PublicKey::Rsa { n, .. } if rsa_bits(n) < 2048 => Some( + "This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.", + ), + _ => None, + } + } + + fn verify_scheme(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result { match (self, scheme) { (PublicKey::Unsupported { what, .. }, _) => Err(SignError::Unsupported(what.clone())), (PublicKey::Rsa { n, e }, Scheme::RsaPkcs1) => crate::rsa_pad::pkcs1_verify(n, e, alg, digest, sig, notes), diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 78f2ad55d..57c9f95fd 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -354,10 +354,14 @@ pub const WORDINGS: &[&str] = &[ "The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway.", "The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway.", "The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's.", - // What a signature that validates with an old hash still does not prove. + "The signature's signed attributes have no content type, which CMS requires; it was read anyway.", + // What a signature that validates with an old hash or a small key still does not prove. "This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on.", "This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on.", + "This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.", // Whether the signed bytes and the signature still agree. + "The signature's signed attributes have no message digest, so the signature does not cover the document.", + "The signature's signed attributes name a content type other than data, so it is not a signature of the document.", "The document has been altered or corrupted since the signature was applied.", "The signer's certificate is not in the signature.", "The signature value does not match the signer's certificate: the signature is corrupt.", @@ -680,6 +684,19 @@ fn is_doc_timestamp(d: &Dict) -> bool { d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig")) } +/// The DocMDP permission level (`/P`, 1–3) of signature value `v`, or `None` for an approval +/// signature. +fn docmdp(doc: &Document, v: &Dict) -> Option { + v.get(b"Reference") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_array().cloned()) + .into_iter() + .flatten() + .filter_map(|r| doc.resolve(&r).as_dict().cloned()) + .find(|r| r.name(b"TransformMethod") == Some(b"DocMDP")) + .map(|r| r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8) +} + fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) { // A document timestamp can also be the value of a signature field (Documenso writes // `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the @@ -697,17 +714,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf info.contact = text(doc, v, b"ContactInfo"); info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned()); info.signer = text(doc, v, b"Name"); - info.certify = v - .get(b"Reference") - .map(|r| doc.resolve(r)) - .and_then(|r| r.as_array().cloned()) - .into_iter() - .flatten() - .filter_map(|r| doc.resolve(&r).as_dict().cloned()) - .find(|r| r.name(b"TransformMethod") == Some(b"DocMDP")) - .map(|r| { - r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8 - }); + info.certify = docmdp(doc, v); let invalid = |info: &mut SignatureInfo, why: Detail| { info.status = Status::Invalid; info.details.push(why); @@ -787,6 +794,36 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf } None => cache.digest(s.digest, bytes, l0, o1, covered), }; + // With signed attributes the signature is made over them, not over the document: their + // message digest is what ties it to the signed bytes, and their content type says what those + // bytes are (data, for a document). RFC 5652 §5.3 requires both. + if s.signed_attrs.is_some() { + match s.content_type.as_deref() { + Some(crate::cms::DATA) => {} + Some(_) => { + return invalid( + info, + Detail::plain("The signature's signed attributes name a content type other than data, so it is not a signature of the document."), + ); + } + None => { + info.details.push(Detail::plain("The signature's signed attributes have no content type, which CMS requires; it was read anyway.")) + } + } + if s.message_digest.is_none() { + return invalid( + info, + Detail::plain("The signature's signed attributes have no message digest, so the signature does not cover the document."), + ); + } + } + // A PDF signature signs data: the document, or (adbe.pkcs7.sha1) its digest. A content-type + // attribute was checked above, and `SignedData::parse` refuses one that differs from + // `eContentType`; without that attribute, `eContentType` is the only place that says so. + if sd.econtent_type != crate::cms::DATA { + let why = SignError::Malformed("CMS: the encapsulated content is not data".into()); + return invalid(info, Detail::of("The signature could not be read ({}).", Fill::Reason(why.to_string()))); + } if let Some(md) = &s.message_digest && *md != content_digest { @@ -855,7 +892,18 @@ fn finish_validation( info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None } else { - classify_changes(doc, cache.revision(doc, bytes, covered), info.certify) + // The permissions are the signed revision's: a later one can rewrite `/P` (#1788). This + // signature is the one whose byte range ends that revision. + let old = cache.revision(doc, bytes, covered); + if let Some(old) = &old + && let Some(v) = sig_fields(old) + .iter() + .filter_map(|f| f.signature(old)) + .find(|v| nums(old, v, b"ByteRange").is_some_and(|b| b.len() == 4 && b[2] + b[3] == covered as f64)) + { + info.certify = docmdp(old, &v); + } + classify_changes(doc, old, info.certify) }; let mut problems = false; // The changes are handed over as the words they are, not stitched into the sentence, so an @@ -1200,6 +1248,8 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod .collect(); // The signed revision's document-level XMP stream. let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref))); + // The signed revision's XFA data packets: filling the form rewrites them. + let old_xfa_data = xfa_data_packets(&old); // What each object was in the signed revision: its pages, and its trailer /Info. A changed // object is judged by the role it held when signed, never by the labels or keys its new, // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its @@ -1295,6 +1345,12 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // else, even one it already had when signed (a page's Form XObject: the // renderer ignores /Type), does not make rewriting it a metadata change. kind::METADATA + } else if old_xfa_data.contains(&r) { + // The form's XFA data (`datasets`) or state (`form`) as the signed revision + // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) + // save of a certified form does (#2759). The role comes from the signed + // revision, so a later one can't name page content as XFA data. + kind::FORM_FILL } else { kind::OTHER } @@ -1318,6 +1374,21 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` +/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one +/// XFA stream holds its template too, and is not listed. +fn xfa_data_packets(doc: &Document) -> HashSet { + let Some(form) = acroform(doc) else { return HashSet::new() }; + let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() }; + let Object::Array(xfa) = &*xfa else { return HashSet::new() }; + xfa.as_chunks::<2>() + .0 + .iter() + .filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form"))) + .filter_map(|[_, packet]| packet.as_ref()) + .collect() +} + /// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3). #[derive(Clone, Copy, PartialEq, Eq)] enum DssRole { @@ -1605,7 +1676,8 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) // change is only a brand-new object, or one that was already a signature object when signed. let signature_ok = before.is_none_or(is_signature_dict); match ty { - Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if signature_ok => return kind::SIGNATURE, + // Only a new signature value or policy: rewriting a signed one is not a signature (#1788). + Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE, Some(b"Catalog") => { let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"]; return match before { @@ -2063,17 +2135,19 @@ pub fn display_date(pdf: &str) -> String { } /// The visible signature: the signer's name large on the left, or the graphic in its place, and -/// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details -/// beside it fills the box. An error, before anything is drawn, when a detail it writes in -/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a -/// character Helvetica can't draw: signing invisibly, or leaving that part out, still works. A -/// graphic doesn't change this: the name it replaces on the left is still among the details. -/// A label that is drawn ([`AppearanceLabels`], the caller's words) is refused the same way. +/// the details on the right (Acrobat's standard layout), in Helvetica, or in the bundled Inter +/// font when a detail needs characters beyond Western European. A graphic with no details beside +/// it fills the box. An error, before anything is drawn, when a detail it writes (the signer's +/// name, the certificate's subject, the reason or the location) has a character neither font can +/// draw: signing invisibly, or leaving that part out, still works. A graphic doesn't change this: +/// the name it replaces on the left is still among the details. A label that is drawn +/// ([`AppearanceLabels`], the caller's words) must be drawable in Helvetica, and is refused otherwise. fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result { - use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap}; + use pdfcraft_fonts::{first_undrawable, helvetica_width, inter_width, literal, undrawable_message, win_ansi, wrap, wrap_with}; let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0)); let a = &opts.appearance; let dn = cert.subject.display(); + let mut needs_unicode = false; if w > 1.0 && h > 1.0 { let shown = [ (a.name, "the signer's name", Some(name)), @@ -2082,11 +2156,22 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate (a.location, "the location", opts.location.as_deref()), ]; for (on, what, text) in shown { - if on && let Some(c) = text.and_then(first_undrawable) { - return Err(SignError::Unsupported(format!( - "{}; sign without a visible appearance, or leave {what} out of it", - undrawable_message(&format!("{what} of the visible signature"), c) - ))); + if on + && let Some(s) = text + && first_undrawable(s).is_some() + { + for c in s.chars() { + if matches!(c, '\n' | '\r') { + continue; + } + if !pdfcraft_fonts::embedding::covers(pdfcraft_fonts::INTER_REGULAR, &c.to_string()) { + return Err(SignError::Unsupported(format!( + "{}; sign without a visible appearance, or leave {what} out of it", + undrawable_message(&format!("{what} of the visible signature"), c) + ))); + } + } + needs_unicode = true; } } // The labels are the caller's words (a translation), checked as the details are: only @@ -2128,6 +2213,31 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate lines.retain(|l| !l.is_empty()); let mut out = Vec::new(); let mut image = None; + let mut fonts = Dict::new(); + let (font_tag, metrics) = if needs_unicode { + let tag = pdfcraft_fonts::embedding::embed(doc, &mut fonts, pdfcraft_fonts::INTER_REGULAR, "Inter") + .map_err(|e| SignError::Pdf(format!("failed to embed signature font: {e}")))?; + let font_dict = fonts + .get(tag.as_bytes()) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_dict().cloned()) + .ok_or_else(|| SignError::Pdf("embedded signature font dictionary not found".into()))?; + let m = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font_dict); + (tag, Some(m)) + } else { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + ("Helv".to_string(), None) + }; + let measure = |s: &str, sz: f64| -> f64 { if needs_unicode { inter_width(s, sz) } else { helvetica_width(s, sz) } }; + let wrap_text = + |s: &str, sz: f64, max_w: f64| -> Vec { if needs_unicode { wrap_with(s, sz, max_w, inter_width) } else { wrap(s, sz, max_w) } }; + let encode_line = + |l: &str| -> Vec { if let Some(m) = &metrics { literal(&m.encode(l).unwrap_or_else(|| win_ansi(l))) } else { literal(&win_ansi(l)) } }; if w > 1.0 && h > 1.0 { let pad = (h * 0.06).clamp(1.0, 6.0); let (left_w, right_x) = match (&opts.graphic, a.name) { @@ -2146,14 +2256,14 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate // The name fills the left half. let mut size = (h * 0.4).min(36.0); let fit = (left_w - 2.0 * pad).max(1.0); - let longest = helvetica_width(name, 1.0).max(0.01); + let longest = measure(name, 1.0).max(0.01); size = size.min(fit / longest).max(4.0); - let words = wrap(name, size, fit); + let words = wrap_text(name, size, fit); let total = words.len() as f64 * size * 1.1; let mut y = (h + total) / 2.0 - size * 0.85; for l in &words { - out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes()); - out.extend(literal(&win_ansi(l))); + out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes()); + out.extend(encode_line(l)); out.extend_from_slice(b" Tj\n"); y -= size * 1.1; } @@ -2161,7 +2271,7 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate // The details, shrunk to fit the right side. let mut size = (h / (lines.len().max(1) as f64 * 1.15)).min(12.0); let wrapped = loop { - let all: Vec = lines.iter().flat_map(|l| wrap(l, size, right_w)).collect(); + let all: Vec = lines.iter().flat_map(|l| wrap_text(l, size, right_w)).collect(); if all.len() as f64 * size * 1.15 <= h - 2.0 * pad || size <= 3.0 { break all; } @@ -2169,8 +2279,8 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate }; let mut y = h - pad - size * 0.9; for l in &wrapped { - out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes()); - out.extend(literal(&win_ansi(l))); + out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes()); + out.extend(encode_line(l)); out.extend_from_slice(b" Tj\n"); y -= size * 1.15; } @@ -2180,13 +2290,6 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|x| Object::Real(*x)).collect())); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); if let Some(im) = image { diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs index 275bdb52c..120fea3da 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -207,6 +207,7 @@ const NTE_NOT_FOUND: u32 = 0x8009_0011; const NTE_BAD_PROVIDER: u32 = 0x8009_0013; const NTE_BAD_PROV_TYPE: u32 = 0x8009_0014; const NTE_BAD_KEYSET: u32 = 0x8009_0016; +const NTE_PROVIDER_DLL_FAIL: u32 = 0x8009_001D; const NTE_PROV_DLL_NOT_FOUND: u32 = 0x8009_001E; const NTE_SILENT_CONTEXT: u32 = 0x8009_0022; const NTE_NOT_SUPPORTED: u32 = 0x8009_0029; @@ -215,6 +216,8 @@ const NTE_DEVICE_NOT_READY: u32 = 0x8009_0030; const NTE_DEVICE_NOT_FOUND: u32 = 0x8009_0035; const NTE_USER_CANCELLED: u32 = 0x8009_0036; const CRYPT_E_NO_KEY_PROPERTY: u32 = 0x8009_200B; +// An NTSTATUS (ntstatus.h) that some key providers pass through unchanged. +const STATUS_NOT_FOUND: u32 = 0xC000_0225; /// A CNG error with the Windows code and, for the usual ones, what it means. fn describe(e: &CngError) -> String { @@ -225,6 +228,15 @@ fn describe(e: &CngError) -> String { NTE_BAD_PROVIDER | NTE_BAD_PROV_TYPE | NTE_PROV_DLL_NOT_FOUND => { "its key provider isn't available to CNG (a legacy CryptoAPI-only provider, or its driver isn't installed)" } + // #2207: a smart card or token's middleware that is missing or broken, or a provider + // that exists only as 32-bit code, which a 64-bit process can't load. + NTE_PROVIDER_DLL_FAIL => { + "its key provider's software failed to load (a smart card or token driver that is missing or broken, or a 32-bit-only provider)" + } + // #1535: an A3 certificate (key on a token) whose provider can't find the key. + STATUS_NOT_FOUND => { + "its key provider can't find the key (the smart card or token holding it isn't connected, or its driver doesn't see the key)" + } NTE_NOT_SUPPORTED => "its key provider doesn't support the operation", NTE_DEVICE_NOT_READY | NTE_DEVICE_NOT_FOUND => "the smart card or token isn't available", NTE_USER_CANCELLED => "the request was cancelled", @@ -247,6 +259,16 @@ mod tests { "the key is missing from its key store (deleted, or imported under another Windows account) (Windows error 0x80090016)" ); assert_eq!(describe(&CngError::WindowsError(0x8010_000C)), "the smart card isn't available (Windows error 0x8010000C)"); + assert_eq!( + describe(&CngError::WindowsError(0x8009_001D)), + "its key provider's software failed to load (a smart card or token driver that is missing or broken, or a 32-bit-only provider) (Windows error 0x8009001D)", + "#2207" + ); + assert_eq!( + describe(&CngError::WindowsError(0xC000_0225)), + "its key provider can't find the key (the smart card or token holding it isn't connected, or its driver doesn't see the key) (Windows error 0xC0000225)", + "#1535" + ); assert_eq!(describe(&CngError::WindowsError(0x57)), "Windows error 0x00000057"); assert_eq!(describe(&CngError::InvalidHashLength), "Invalid hash length"); } diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 6f4a731e7..53c80e269 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -545,27 +545,44 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: } /// Certificates from a file: DER, or PEM with one or more `CERTIFICATE` blocks (`.cer`, `.crt`, -/// `.pem`, Acrobat's `.fdf`-free exports). +/// `.pem`, Acrobat's `.fdf`-free exports), or a PKCS #7 bundle (`.p7b`, `.p7c`) in DER or as a +/// PEM `PKCS7`/`CMS` block, as Windows, Acrobat and certificate authorities export chains. pub fn load_certificates(bytes: &[u8]) -> Result, SignError> { if bytes.first() == Some(&0x30) { - return Ok(vec![Certificate::parse(bytes)?]); + // One DER certificate, else a DER bundle; a file that is neither reports the + // certificate's error, the common case. + return match Certificate::parse(bytes) { + Ok(cert) => Ok(vec![cert]), + Err(e) => crate::cms::certificate_bundle(bytes).map_err(|_| e), + }; } let text = String::from_utf8_lossy(bytes); let mut out = Vec::new(); - let mut block: Option = None; + // The block being read: its label and its base64 so far. + let mut block: Option<(&str, String)> = None; for line in text.lines().map(str::trim) { - if line == "-----BEGIN CERTIFICATE-----" { - block = Some(String::new()); - } else if line == "-----END CERTIFICATE-----" { - if let Some(b) = block.take() { - out.push(Certificate::parse(&base64(&b).ok_or_else(|| SignError::Malformed("PEM base64".into()))?)?); + if let Some(label) = line.strip_prefix("-----BEGIN ").and_then(|l| l.strip_suffix("-----")) { + block = matches!(label, "CERTIFICATE" | "PKCS7" | "CMS").then(|| (label, String::new())); + } else if let Some(label) = line.strip_prefix("-----END ").and_then(|l| l.strip_suffix("-----")) { + // An END that doesn't close the open block ends nothing. + if let Some((open, b)) = block.take_if(|(open, _)| *open == label) { + let der = base64(&b).ok_or_else(|| SignError::Malformed("PEM base64".into()))?; + if open == "CERTIFICATE" { + out.push(Certificate::parse(&der)?); + } else { + out.extend(crate::cms::certificate_bundle(&der)?); + } } - } else if let Some(b) = block.as_mut() { + } else if let Some((_, b)) = block.as_mut() { b.push_str(line); } } + // Certificates before a block that never ends are not a smaller set: the file is damaged. + if block.is_some() { + return Err(SignError::Malformed("a PEM block is not closed".into())); + } if out.is_empty() { - return Err(SignError::Malformed("no certificate found (expected DER or PEM)".into())); + return Err(SignError::Malformed("no certificate found (expected DER, PEM or a PKCS #7 bundle)".into())); } Ok(out) } diff --git a/crates/sign/tests/cms.rs b/crates/sign/tests/cms.rs new file mode 100644 index 000000000..dec7faced --- /dev/null +++ b/crates/sign/tests/cms.rs @@ -0,0 +1,130 @@ +//! What a CMS's signed attributes may hold. RFC 5652 allows one content-type and one +//! message-digest attribute, each with one value, and the content type must be the type of the +//! encapsulated content. Both content types are object identifiers. +//! +//! These go through `SignedData::parse`, the way a PDF's signature reaches it. Each CMS is built +//! here as the signer builds it, so a case differs from a valid one only where it says. + +use pdfcraft_sign::cms::SignedData; +use pdfcraft_sign::der::{self, tag}; +use pdfcraft_sign::keys::DigestAlg; +use pdfcraft_sign::{SignError, pkcs12}; + +const SIGNED_DATA: &str = "1.2.840.113549.1.7.2"; +const DATA: &str = "1.2.840.113549.1.7.1"; +const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4"; +const CONTENT_TYPE: &str = "1.2.840.113549.1.9.3"; +const MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4"; + +fn identity() -> pkcs12::DigitalId { + let p12 = std::fs::read(format!("{}/tests/data/rsa-aes.p12", env!("CARGO_MANIFEST_DIR"))).unwrap(); + pkcs12::open(&p12, "test").unwrap() +} + +/// A signed attribute: its type and the SET of its values. +fn attribute(oid: &str, values: &[&[u8]]) -> Vec { + der::seq(&[&der::oid(oid), &der::set_of(values)]) +} + +/// A SignedData whose encapsulated content has the type `encap_type` and whose one signer +/// signed the attributes `attrs`. +fn signed_data(id: &pkcs12::DigitalId, encap_type: &str, attrs: &[Vec]) -> Vec { + signed_data_with_encap(id, &der::seq(&[&der::oid(encap_type)]), attrs) +} + +/// A SignedData with the encapContentInfo `encap`, as it stands, and whose one signer signed the +/// attributes `attrs`. +fn signed_data_with_encap(id: &pkcs12::DigitalId, encap: &[u8], attrs: &[Vec]) -> Vec { + let alg = DigestAlg::Sha256; + let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect(); + let set = der::set_of(&refs); + let signature = id.key.sign(alg, &set).unwrap(); + let mut signed_attrs = set; + signed_attrs[0] = tag::ctx(0); + let cert = &id.certificate; + let sid = der::seq(&[&cert.issuer.raw, &der::uint(&cert.serial)]); + let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &id.key.signature_algorithm(alg), &der::octets(&signature)]); + let signed_data = + der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), encap, &der::tlv(tag::ctx(0), &cert.raw), &der::set_of(&[&signer])]); + der::seq(&[&der::oid(SIGNED_DATA), &der::explicit(0, &signed_data)]) +} + +/// Why `SignedData::parse` refuses `cms`. +fn refused(cms: &[u8]) -> String { + match SignedData::parse(cms) { + Err(SignError::Malformed(why)) => why, + other => panic!("expected the CMS to be refused, got {other:?}"), + } +} + +#[test] +fn signed_attributes_hold_one_content_type_and_one_message_digest() { + let id = identity(); + let digest = DigestAlg::Sha256.digest(&[b"document"]); + let (data, tst, md, other_md) = (der::oid(DATA), der::oid(TST_INFO), der::octets(&digest), der::octets(&[0; 32])); + let content_type = attribute(CONTENT_TYPE, &[&data]); + let message_digest = attribute(MESSAGE_DIGEST, &[&md]); + + // As a signer writes them: read, and the signature over them verifies. + let sd = SignedData::parse(&signed_data(&id, DATA, &[content_type.clone(), message_digest.clone()])).unwrap(); + assert_eq!(sd.signer.content_type.as_deref(), Some(DATA)); + assert_eq!(sd.signer.message_digest.as_deref(), Some(digest.as_slice())); + assert!(sd.verify_signature(&id.certificate, &digest).unwrap()); + + // A second attribute of either kind, with the same value or another one, and an attribute + // with two values or none. + for (attrs, what) in [ + (vec![content_type.clone(), content_type.clone(), message_digest.clone()], "content type"), + (vec![content_type.clone(), attribute(CONTENT_TYPE, &[&tst]), message_digest.clone()], "content type"), + (vec![attribute(CONTENT_TYPE, &[&data, &tst]), message_digest.clone()], "content type"), + (vec![attribute(CONTENT_TYPE, &[]), message_digest.clone()], "content type"), + (vec![content_type.clone(), message_digest.clone(), message_digest.clone()], "message digest"), + (vec![content_type.clone(), message_digest.clone(), attribute(MESSAGE_DIGEST, &[&other_md])], "message digest"), + (vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[&md, &other_md])], "message digest"), + (vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[])], "message digest"), + ] { + let why = refused(&signed_data(&id, DATA, &attrs)); + assert!(why.contains(&format!("one {what} with one value")), "{why}"); + } +} + +#[test] +fn the_signed_content_type_is_the_type_of_the_encapsulated_content() { + let id = identity(); + let digest = DigestAlg::Sha256.digest(&[b"document"]); + let message_digest = attribute(MESSAGE_DIGEST, &[&der::octets(&digest)]); + let signed_as = |oid: &str| attribute(CONTENT_TYPE, &[&der::oid(oid)]); + + // A PDF signature names data in both places, and an RFC 3161 token names TSTInfo in both. + SignedData::parse(&signed_data(&id, DATA, &[signed_as(DATA), message_digest.clone()])).unwrap(); + SignedData::parse(&signed_data(&id, TST_INFO, &[signed_as(TST_INFO), message_digest.clone()])).unwrap(); + + // The signature does not cover eContentType: here it was changed from data to signedData + // (the last byte of the OID), and the signed attributes still say data. + let why = refused(&signed_data(&id, SIGNED_DATA, &[signed_as(DATA), message_digest.clone()])); + assert!(why.contains("not the encapsulated content type"), "{why}"); + + // Without a content-type attribute there is nothing to compare here. The type is kept, and a + // PDF signature's validation refuses one that is not data (tests/pdf.rs). + let sd = SignedData::parse(&signed_data(&id, SIGNED_DATA, std::slice::from_ref(&message_digest))).unwrap(); + assert_eq!(sd.econtent_type, SIGNED_DATA); +} + +#[test] +fn content_types_are_object_identifiers() { + let id = identity(); + let digest = DigestAlg::Sha256.digest(&[b"document"]); + let message_digest = attribute(MESSAGE_DIGEST, &[&der::octets(&digest)]); + let content_type = attribute(CONTENT_TYPE, &[&der::oid(DATA)]); + let not_an_oid = der::octets(DATA.as_bytes()); + + // A content-type value that is not an OID would leave nothing to compare. + let why = refused(&signed_data(&id, DATA, &[attribute(CONTENT_TYPE, &[¬_an_oid]), message_digest.clone()])); + assert!(why.contains("expected an OID"), "{why}"); + + // eContentType is required (RFC 5652 §5.2) and is an OID. + let why = refused(&signed_data_with_encap(&id, &der::seq(&[¬_an_oid]), &[content_type.clone(), message_digest.clone()])); + assert!(why.contains("expected an OID"), "{why}"); + let why = refused(&signed_data_with_encap(&id, &der::seq(&[]), &[content_type, message_digest])); + assert!(why.contains("eContentType"), "{why}"); +} diff --git a/crates/sign/tests/crypto.rs b/crates/sign/tests/crypto.rs index ef3d5976d..cd36c5277 100644 --- a/crates/sign/tests/crypto.rs +++ b/crates/sign/tests/crypto.rs @@ -239,6 +239,87 @@ fn certificates_load_from_pem_and_der_and_export_as_pem() { assert!(pdfcraft_sign::x509::load_certificates(b"hello").is_err()); } +/// Base64 in 64-column lines, for PEM armour. +fn encode_base64(der: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in der.chunks(3) { + let n = u32::from(c[0]) << 16 | u32::from(*c.get(1).unwrap_or(&0)) << 8 | u32::from(*c.get(2).unwrap_or(&0)); + for i in 0..4 { + s.push(if i <= c.len() { T[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + let lines: Vec<&str> = s.as_bytes().chunks(64).map(|l| std::str::from_utf8(l).unwrap()).collect(); + lines.join("\n") +} + +/// A PKCS #7 certificate bundle (degenerate SignedData, RFC 5652 §5.2) holding `certs`, laid out +/// as `openssl crl2pkcs7 -nocrl` writes it. +fn p7b(certs: &[&[u8]]) -> Vec { + use pdfcraft_sign::der::{explicit, int, oid, seq, set_of, tlv}; + let encap = seq(&[&oid(cms::DATA)]); + let mut sd = vec![int(1), set_of(&[]), encap]; + if !certs.is_empty() { + sd.push(tlv(0xA0, &certs.concat())); + } + sd.push(set_of(&[])); + let refs: Vec<&[u8]> = sd.iter().map(Vec::as_slice).collect(); + seq(&[&oid(cms::SIGNED_DATA), &explicit(0, &seq(&refs))]) +} + +#[test] +fn certificates_load_from_pkcs7_bundles() { + use pdfcraft_sign::x509::{load_certificates, to_pem}; + let leaf = load_certificates(&data("rsa.crt.pem")).unwrap().remove(0); + let root = load_certificates(&data("ca.crt.pem")).unwrap().remove(0); + let bundle = p7b(&[&leaf.raw, &root.raw]); + // .p7b / .p7c in DER, the way Windows and Acrobat export a chain. + assert_eq!(load_certificates(&bundle).unwrap(), vec![leaf.clone(), root.clone()]); + // The same bundle as PEM `PKCS7` and `CMS` blocks, after a key and a plain certificate. + let b64 = encode_base64(&bundle); + for label in ["PKCS7", "CMS"] { + let text = format!( + "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n{}-----BEGIN {label}-----\n{}\n-----END {label}-----\n", + to_pem(&leaf), + b64 + ); + assert_eq!(load_certificates(text.as_bytes()).unwrap(), vec![leaf.clone(), leaf.clone(), root.clone()], "{label}"); + } + // An END that closes a different block ends nothing. + let mismatched = format!("-----BEGIN PKCS7-----\n{b64}\n-----END CERTIFICATE-----\n"); + assert!(load_certificates(mismatched.as_bytes()).is_err()); + // A block that never ends is a damaged file, not a shorter list. + let unclosed = format!("{}-----BEGIN PKCS7-----\n{b64}\n", to_pem(&leaf)); + assert!(load_certificates(unclosed.as_bytes()).is_err()); + // A bundle with no certificate, and a SignedData that isn't one, are errors, not empty lists. + assert!(load_certificates(&p7b(&[])).is_err()); + let not_signed_data = pdfcraft_sign::der::seq(&[&pdfcraft_sign::der::oid(cms::DATA), &pdfcraft_sign::der::explicit(0, &[])]); + assert!(load_certificates(¬_signed_data).is_err()); + // A SignedData out of shape: no signerInfos SET, or a version that isn't an INTEGER. + { + use pdfcraft_sign::der::{explicit, int, oid, seq, set_of, tlv}; + let wrap = |sd: &[&[u8]]| seq(&[&oid(cms::SIGNED_DATA), &explicit(0, &seq(sd))]); + let (certs, encap) = (tlv(0xA0, &[leaf.raw.as_slice(), &root.raw].concat()), seq(&[&oid(cms::DATA)])); + assert_eq!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &set_of(&[])])).unwrap().len(), 2); + assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs])).is_err()); + assert!(load_certificates(&wrap(&[&set_of(&[]), &set_of(&[]), &encap, &certs, &set_of(&[])])).is_err()); + // An empty encapContentInfo, a repeated [0], and [1] CRLs before [0] certificates. + let crls = tlv(0xA1, &[]); + assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &seq(&[]), &certs, &set_of(&[])])).is_err()); + assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &certs, &set_of(&[])])).is_err()); + assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &crls, &certs, &set_of(&[])])).is_err()); + assert_eq!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &crls, &set_of(&[])])).unwrap().len(), 2); + } + let mut trailing = bundle.clone(); + trailing.extend_from_slice(&[0x05, 0x00]); + assert!(load_certificates(&trailing).is_err()); + assert!(cms::certificate_bundle(&bundle).is_ok()); + // Truncated anywhere: an error, never a panic. + for n in 0..bundle.len() { + let _ = load_certificates(&bundle[..n]); + } +} + // ── CRL and OCSP verification (RFC 5280, RFC 6960) ───────────────────────────────────────── use pdfcraft_sign::der::{self, tag}; @@ -546,6 +627,51 @@ fn weak_digests_say_so_in_words_that_are_pinned() { } } +#[test] +fn small_rsa_keys_say_so_and_others_do_not() { + let rsa = |n: Vec| PublicKey::Rsa { n, e: vec![1, 0, 1] }; + let note = Some( + "This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.", + ); + // 1024 bits, with and without the leading zero byte of a DER INTEGER. + assert_eq!(rsa(vec![0xC0; 128]).weakness(), note); + assert_eq!(rsa([vec![0], vec![0xC0; 128]].concat()).weakness(), note); + // 2047 bits is still short; 2048 bits and more are not. + assert_eq!(rsa([vec![0x7F], vec![0xFF; 255]].concat()).weakness(), note); + assert_eq!(rsa([vec![0, 0x80], vec![0; 255]].concat()).weakness(), None); + assert_eq!(rsa(vec![0xFF; 512]).weakness(), None); + assert_eq!(rsa(Vec::new()).weakness(), note, "an empty modulus is not a strong key"); + // The key description counts the same bits, so it never says 2048 next to the warning. + assert_eq!(rsa([vec![0], vec![0xC0; 128]].concat()).describe(), "RSA 1024-bit"); + assert_eq!(rsa([vec![0x7F], vec![0xFF; 255]].concat()).describe(), "RSA 2047-bit"); + assert_eq!(rsa([vec![0, 0x80], vec![0; 255]].concat()).describe(), "RSA 2048-bit"); + assert_eq!(PublicKey::P256(vec![4; 65]).weakness(), None); + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + assert_eq!(id.certificate.public_key.weakness(), None, "the 2048-bit test identity"); +} + +/// A synthetic 1024-bit key made for this test (`openssl genrsa 1024`, e = 65537) and its +/// PKCS #1 v1.5 SHA-256 signature of `hello`. +const RSA_1024_N: &str = "ae561814194f0a06475c002fc7633b81e83a738ad3f35ba1ded959be6f6b62302028d58707403f44b7bda959e446f13849896711985ac4815e58d7d323aee664771c35bf2dc3db1bcdc49ac61ae7282a24ec7a5af9f6ebe98945e6a71c4998ed31c41bbb997c4873fb4b8271fd3cf76648efcbff76972ab7bcaf831b9e203d47"; +const RSA_1024_SIG: &str = "0de5110137e768a6b00201cf8340688c81a901fe309489f2c8186b7dc56e67f7e64188970a9bea926b9bd99b19985adfb0489956ffd5e0264468b678c4fd49a155a895fcb9ab127d0c3742b7a07866b3deec20a5b2f61af4cbfd2901517dc268e4d6edf4e905e0f4e05cdc740c3180971c1ec1470c1476f90ea988e2ab4cbb18"; + +#[test] +fn a_signature_made_with_a_small_rsa_key_verifies_with_the_note() { + use pdfcraft_sign::keys::Scheme; + let key = PublicKey::Rsa { n: unhex(RSA_1024_N), e: vec![1, 0, 1] }; + let weak = key.weakness().expect("a 1024-bit key is short"); + let sig = unhex(RSA_1024_SIG); + let mut notes = Vec::new(); + let digest = DigestAlg::Sha256.digest(&[b"hello"]); + assert!(key.verify_noting(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &sig, &mut notes).unwrap()); + assert_eq!(notes, [weak]); + // A signature that doesn't verify says nothing about the key. + let mut notes = Vec::new(); + let other = DigestAlg::Sha256.digest(&[b"hullo"]); + assert!(!key.verify_noting(Scheme::RsaPkcs1, DigestAlg::Sha256, &other, &sig, &mut notes).unwrap()); + assert!(!notes.contains(&weak), "{notes:?}"); +} + #[test] fn an_rsa_signature_value_not_below_the_modulus_is_refused() { use pdfcraft_sign::keys::{DigestAlg, PssParams, Scheme}; diff --git a/crates/sign/tests/data/README.md b/crates/sign/tests/data/README.md index b33f327e1..5685d0e35 100644 --- a/crates/sign/tests/data/README.md +++ b/crates/sign/tests/data/README.md @@ -11,6 +11,7 @@ The password of every `.p12` is `test`. | `ec-legacy.p12` | `ec-p256.p12` re-exported with `-legacy` (3DES, SHA-1 MAC): the format macOS `security import` accepts | | `ec-p384.p12` | P-384 self-signed "Test Signer P384"; AES-128-CBC, SHA-1 MAC | | `chain.p12` | RSA 2048 "Ada Lovelace" issued by a P-256 test root, chain included | +| `chain-legacy.p12` | `chain.p12` re-exported with `-legacy`, for the Keychain tests | | `rsa.crt.pem`, `ca.crt.pem` | the RSA certificate and the test root alone | Regenerate with: @@ -29,6 +30,7 @@ openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj "/CN=Ad printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,nonRepudiation\n" > leaf.ext openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -set_serial 77 -days 3650 -extfile leaf.ext -out leaf.crt openssl pkcs12 -export -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain.p12 -passout pass:test -name "Ada Lovelace" +openssl pkcs12 -export -legacy -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain-legacy.p12 -passout pass:test -name "Ada Lovelace" ``` `openssl-signed.pdf` is a hand-written one-page PDF with a `/Contents` placeholder, signed with diff --git a/crates/sign/tests/data/chain-legacy.p12 b/crates/sign/tests/data/chain-legacy.p12 new file mode 100644 index 000000000..6a11065ca Binary files /dev/null and b/crates/sign/tests/data/chain-legacy.p12 differ diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 908a5c0fe..be7ccf4b6 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ - Detail, Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures, + Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, + in_english, kind, pkcs12, signatures, }; fn data(name: &str) -> Vec { @@ -13,14 +14,20 @@ fn data(name: &str) -> Vec { /// One page with text and an unsigned signature field "Approval". fn fixture() -> Vec { - let objs: Vec<&[u8]> = vec![ - b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", ]; + objs.extend_from_slice(more); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -237,6 +244,23 @@ fn later_changes_are_classified_under_the_signature_permissions() { // Certified allowing comments: fine. let certified3 = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap(); assert_eq!(check(&edit_after(&certified3, add_comment)).status, Status::Valid); + // #1788: a later revision relaxing the signed P 1 to P 3 does not permit the comment. + let relaxed = edit_after(&certified, |doc| { + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let sig = doc.resolve(catalog.get(b"Perms").unwrap()).as_dict().and_then(|p| p.get(b"DocMDP").and_then(Object::as_ref)).unwrap(); + doc.update_dict(sig, |v| { + if let Some(Object::Array(r)) = v.get_mut(b"Reference") + && let Some(Object::Dict(sr)) = r.first_mut() + && let Some(Object::Dict(tp)) = sr.get_mut(b"TransformParams") + { + tp.set(b"P".to_vec(), Object::Int(3)); + } + }) + .unwrap(); + add_comment(doc); + }); + let s = check(&relaxed); + assert_eq!((s.certify, s.status), (Some(1), Status::Invalid), "{:?}", s.modification); } #[test] @@ -290,7 +314,7 @@ fn visible_signatures_show_a_drawn_typed_or_image_graphic() { let typed = Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]], vec![[0.2, 0.1], [0.8, 0.1], [0.5, 0.4]]]); for (graphic, drawn) in [ (strokes, " c\n"), - (typed, "f*\n"), + (typed, "f\n"), // 40 x 10 in the left half (94 x 44 inside the padding): 94 wide, centred vertically. (picture(true), "q 94 0 0 23.5 3 13.25 cm /Im1 Do Q"), ] { @@ -667,6 +691,49 @@ fn signing_with_keychain_identities() { result.unwrap(); } +/// A Keychain identity carries the certificates macOS chains it to, as a `.p12`'s does, so the +/// signature embeds its issuers. Ignored by default: the throwaway keychain joins the user's +/// keychain search list while the test runs (restored afterwards). +#[cfg(target_os = "macos")] +#[test] +#[ignore = "creates a temporary macOS keychain; run with --ignored"] +fn keychain_identities_carry_their_issuers() { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-chain-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let kc = dir.join("test.keychain-db"); + let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap(); + let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap(); + let saved: Vec = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect(); + let k = kc.to_str().unwrap(); + assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success()); + let set_list = |extra: Option<&str>| { + let mut args = vec!["list-keychains", "-d", "user", "-s"]; + args.extend(saved.iter().map(String::as_str)); + args.extend(extra); + sec(&args); + }; + // macOS finds an identity's issuers in the keychains on the search list. + set_list(Some(k)); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + sec(&["unlock-keychain", "-p", "pc-test", k]); + let p = format!("{}/tests/data/chain-legacy.p12", env!("CARGO_MANIFEST_DIR")); + let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]); + assert!(out.status.success(), "chain-legacy.p12: {}", String::from_utf8_lossy(&out.stderr)); + let listed = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap(); + assert_eq!(listed.len(), 1, "{listed:?}"); + let id = pdfcraft_sign::keychain::find(&pdfcraft_sign::keychain::reference(&listed[0].certificate)).unwrap(); + let issuers: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect(); + assert_eq!(issuers, [Some("PrintCraft Test Root CA")]); + let file = pkcs12::open(&data("chain.p12"), "test").unwrap(); + assert_eq!(id.chain.iter().map(|c| &c.raw).collect::>(), file.chain.iter().map(|c| &c.raw).collect::>()); + })); + sec(&["delete-keychain", k]); + set_list(None); + let _ = std::fs::remove_dir_all(&dir); + result.unwrap(); +} + /// `fixture()` plus a Form XObject (object 7) that the page draws. fn fixture_with_xobject() -> Vec { let objs: Vec<&[u8]> = vec![ @@ -749,6 +816,59 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() { } } +/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9). +fn xfa_fixture() -> Vec { + fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>", + &[ + b"<< /Length 11 >>\nstream\n