diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eebc..1fb3cf54a6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -750,7 +750,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 cbf40f1962..55f83a4c33 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4675,6 +4675,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/sign/src/cms.rs b/crates/sign/src/cms.rs index f1f17e4d48..bbdea4b145 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -54,6 +54,46 @@ pub struct SignedData { 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, _) = Tlv::parse_ber(bytes)?; + 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, then the optional [0] certificates. + let mut certificates = Vec::new(); + for t in sd.iter().skip(3) { + if 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}")) } @@ -95,16 +135,7 @@ 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), _ => {} } diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 6f4a731e7a..4c4c22a985 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -545,27 +545,40 @@ 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); } } 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/crypto.rs b/crates/sign/tests/crypto.rs index ef3d5976d6..91a0feaaa1 100644 --- a/crates/sign/tests/crypto.rs +++ b/crates/sign/tests/crypto.rs @@ -239,6 +239,65 @@ fn certificates_load_from_pem_and_der_and_export_as_pem() { assert!(pdfcraft_sign::x509::load_certificates(b"hello").is_err()); } +/// A PKCS #7 certificate bundle (degenerate SignedData, RFC 5652 §5.2) holding `certs`, laid out +/// as `openssl crl2pkcs7 -nocrl` writes it. +/// 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") +} + +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 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()); + // 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}; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d7230c0962..cc3e231979 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (XDP data row corrected; BigTIFF in Create PDF) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** patch (`.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation @@ -56,7 +56,7 @@ features total). | Format | Acrobat | PdfCraft | Fidelity | Tests and evidence | |---|---|---|---|---| | PKCS #12 digital IDs (`.p12`, `.pfx`) | R W | R W (self-signed IDs) | high | OpenSSL-made fixtures (`crates/sign/tests/data/README.md`) | -| Certificates (`.cer`, `.der`, `.pem`, `.p7c`, `.p7b`) | R W | R (`.cer/.pem/.der/.p12` trust import); W public certificate | good; `.p7c/.p7b` bundles not read | `sign.trust-import` partial, `sign.export-certificate` | +| Certificates (`.cer`, `.der`, `.pem`, `.p7c`, `.p7b`) | R W | R (`.cer/.pem/.der/.p7b/.p7c/.p12` trust import); W public certificate | good; `.p7c/.p7b` bundles read (DER or PEM), not written | `sign.trust-import` partial, `sign.export-certificate` | | CMS / PKCS #7 signatures, RFC 3161 tokens, OCSP, CRL | R W | R W | high | `pdfsig` and OpenSSL oracles | ## Create PDF from @@ -110,6 +110,7 @@ features total). | Date | Change | Summary | |---|---|---| +| 2026-10-19 | patch | Certificates row: `.p7b`/`.p7c` bundles (DER or PEM `PKCS7`/`CMS`) are read for trust import. | | 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. | | 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. | | 2026-10-11 | minor | Encrypted PDF row: written files open in Acrobat DC (#774, fixed by #836). FDF row: #807, #809 and #819 fixed | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..39f67771b9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6273,7 +6273,7 @@ status = "partial" commands = [] tools = ["sign_trust"] evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"] -notes = "import .cer/.pem/.der/.p12 through the tool; per-certificate trust settings not yet" +notes = "import .cer/.pem/.der/.p7b/.p7c/.p12 through the tool; per-certificate trust settings not yet" [[feature]] id = "sign.eutl"