mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
* M9: read BER signatures; later changes are classified narrowly
Real-world signatures (DocuSign, Telekom QES, Documenso) are BER: indefinite
lengths and segmented OCTET STRINGs, which the strict DER reader turned away
("DER signature wrong"). Behind that, later revisions that add DSS data or
rewrite the XMP were classified as tampering.
- cms: the CMS structure is read with `Tlv::parse_ber` (the reader #453 added
for PKCS#12); certificates and signed attributes are still verified over
their own exact bytes, signed attributes with an indefinite length are
re-encoded as DER first (RFC 5652 §5.4), and the details say the signature
was BER. High tag numbers are not read.
- pdf: validation data added after signing is a permitted change, but only
objects reached through /DSS /Certs|CRLs|OCSPs|VRI by the keys the standard
defines, with the shape of their role, and new or only grown since signing.
A /DSS entry naming other objects (a page's contents) excuses nothing.
- pdf: only the signed catalog's /Metadata is the document's XMP. A
/Type /Metadata or /Type /DSS label on any other dictionary or stream, even
one it already had when signed (a page's Form XObject), makes nothing
permitted. Negative tests at every DocMDP level.
Six of the new tests fail on the previous code: the BER signature, nested
segments, the store-growth rules, and a dictionary or stream carrying a
/Type /Metadata or /Type /DSS label. The last one let a drawn Form XObject be
rewritten under a Valid signature.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* M9: comparing a grown document security store is linear for references and bounded otherwise
only_grew compared arrays element by element, so a crafted /Certs of ~100k entries could hang
validation. Reference arrays are compared as sets; other arrays only up to 10,000 elements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
pdfcraft-sign
Layer L4: digital signatures (execution plan M9; ISO 32000-2 §12.8; PAdES, ETSI EN 319 142).
let id = pkcs12::open(&std::fs::read("me.p12")?, "password")?; // a digital ID
let signed = sign(&doc, &id, &SignOptions { page: 0, rect: Some(r), date, ..Default::default() })?;
for s in signatures(&doc, &bytes, &trust) { // list + validate
println!("{}: {} — {:?}", s.field, s.summary(), s.details);
}
- Formats, on RustCrypto primitives: a small DER reader and writer (
der), X.509 certificates (x509), CMS SignedData (cms), and PKCS #12 digital ID files (pkcs12): PBES2 (PBKDF2 + AES/3DES), the legacy SHA-1 3DES/RC2 schemes, and MAC checks. Writing uses OpenSSL 3's defaults (AES-256-CBC, HMAC-SHA-256). - Keys: RSA, ECDSA P-256 and P-384. Verification uses RustCrypto everywhere. RSA
private-key operations (signing, key generation) use
aws-lc-rson native targets and are refused in the browser (ADR-0009: thersacrate's Marvin advisory, RUSTSEC-2023-0071, has no fix, sorsais only used to verify). ECDSA signs with deterministic nonces (RFC 6979). - Signing: PAdES B-B (
ETSI.CAdES.detached, signing-certificate-v2, SHA-256/384, no SHA-1). An existing unsigned field or a new one (visible with Acrobat's name-and-details appearance, or invisible); certification with DocMDP P=1/2/3. The document is written incrementally with a zero-filled/Contentsand fixed-width/ByteRange, which are then patched in place. Encrypted documents are refused for now. - Timestamps (PAdES B-T): the
timestampmodule builds and parses RFC 3161 requests, responses and TSTInfo tokens (size-capped, imprint- and signature-checked).sign_with_timestampattaches the TSA's token as an unsigned attribute over the signature value; the transport is the caller's (TimestampAuthority— this crate never opens a socket). Validation verifies embedded tokens; when the TSA chains to the trust store it reports the trusted time inSignatureInfo::timestamp_time, which then anchors certificate-validity and revocation checks. An untrusted TSA's time is reported as an unverified timestamp and never used as the validation time (the signer's claimed time is).timestamp::respondis the TSA-side signer behind the deterministic test authority. - Document timestamps and LTV:
timestamp_documentappends a standalone RFC 3161 document timestamp (/ETSI.RFC3161) covering the whole file; validation discovers and verifies these dictionaries (imprint over the signed bytes, token signature, TSA cert validity).dss::embedmerges revocation evidence into the catalog's/DSSwith/VRIentries keyed per signature (uppercase-hex SHA-1 of/Contents), deduplicating byte-identical blobs — sign → DSS → timestamp makes a B-LTA file, and the change classifier treats the store as a permitted change: only objects reached through/Certs,/CRLs,/OCSPsand/VRIwith the shape of their role, and new or only grown since the signature, count (a/DSSentry naming a page's contents, or a/Type /DSSlabel, does not).revocationparses and verifies RFC 5280 CRLs and RFC 6960 OCSP responses (responder identity, OCSP-signing EKU for delegated responders, CertID hash matching, validity windows); validation checks embedded evidence against the signer's chain and a verified revocation invalidates the signature.
Not yet: revocation fetching (AIA/CRLDP extraction and a fetcher), timestamp-server
configuration, FieldMDP locks, certificate security, smart cards and PKCS #11 tokens.
macOS Keychain and Windows Current User Personal (My) store identities sign through
ExternalKey without exporting private keys. Windows CNG supports RSA PKCS #1 v1.5 and
ECDSA P-256/P-384; the store integration is tested with software-backed keys.
- Validation:
/ByteRangeand the CMS are read from the file's own bytes; the digest, the signature value and the signer's chain (against aTrustStore) are checked. Later revisions are diffed against the signed one, and the changes are classified (signing, form fill, comments, metadata, page content, document structure) under the DocMDP permissions. The verdict follows Acrobat: valid, unknown (intact but the identity isn't trusted) or invalid. - Chains are checked, not just linked.
x509::build_chainonly takes an issuer whose key verifies the certificate below it and that may issue:basicConstraintsCA:TRUE (an old v1 self-signed root without constraints counts),keyCertSignwhen it has a key usage, apathLenConstraintthat allows the CAs below it, and, given the signing time, validity then. Certificates embedded in a document are in the pool too, so an ordinary subscriber certificate can never vouch for another one; when an issuer is refused, the signature's details say why. - BER as well as DER. The CMS is read with
der::Tlv::parse_ber, as Windows CryptoAPI, Adobe PPKMS, DocuSign, Documenso andopenssl cms -streamwrite it: indefinite lengths and an OCTET STRING split into segments. Only the structure is read that way; certificates and signed 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. - Later changes are classified narrowly. Only the catalog's own
/Metadatastream is the document's XMP, and only objects reached through the/DSSkeys above are the security store. A/Type /Metadataor/Type /DSSlabel on any other dictionary or stream (a page's Form XObject, say) makes nothing permitted, at every DocMDP level.
Oracles: poppler's pdfsig reports our signatures valid; OpenSSL reads our .p12 files and
verifies our CMS; tests/data/openssl-signed.pdf is a signature OpenSSL made, which we validate.