Merge PR #3561: M9: read .p7b/.p7c certificate bundles for trust import

This commit is contained in:
aiongg
2026-10-22 01:46:12 +00:00
7 changed files with 175 additions and 24 deletions
+1 -1
View File
@@ -756,7 +756,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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" } }),
&[],
)),
+7
View File
@@ -4788,6 +4788,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.
+55 -10
View File
@@ -54,6 +54,60 @@ 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<Vec<Certificate>, 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<Vec<Certificate>, 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::<Vec<_>>().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}"))
}
@@ -95,16 +149,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),
_ => {}
}
+27 -10
View File
@@ -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<Vec<Certificate>, 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<String> = 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)
}
+81
View File
@@ -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<u8> {
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(&not_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};
+3 -2
View File
@@ -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 |
+1 -1
View File
@@ -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"