mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge PR #3561: M9: read .p7b/.p7c certificate bundles for trust import
This commit is contained in:
@@ -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" } }),
|
||||
&[],
|
||||
)),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(¬_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};
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user