diff --git a/ROADMAP.md b/ROADMAP.md index 3a71d948..d9d99326 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-09 (M9):** A document timestamp stored as a signature field (Documenso writes `Timestamp_1` that way, its `/V` a `/Type /DocTimeStamp` dictionary) was read as a CMS signature and reported "altered or corrupted since the signature was applied" on an untouched file. A field value with `/SubFilter /ETSI.RFC3161` is validated as a timestamp now, and standalone timestamps are found by `/DocTimeStamp` (ISO 32000-2 §12.8.5) as well as the `/Type /Sig` older writers used; `timestamp_document` writes `/DocTimeStamp`. Regression tests build the same structure with a test TSA. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M9, signatures as DocuSign and Documenso write them, #144):** A real DocuSign + Telekom qualified-signature PDF showed "DER signature wrong" although Acrobat validates it: both CMS blobs use BER indefinite lengths. The CMS is read with the BER reader that #453 added (`Tlv::parse_ber`: indefinite lengths, segmented OCTET STRINGs, depth-bounded) while certificates and signed attributes are still verified over their own exact bytes, and the details say the signature was BER; tests build the nesting bombs a crafted /Contents could hold. Behind that, later revisions that add DSS data (indirect `/Certs`, `/VRI`) or rewrite the catalog's XMP were classified as disallowed changes; they are permitted now, narrowly (review by echelon on #144): only objects reached through `/DSS` `/Certs|CRLs|OCSPs|VRI` with the shape of their role, and new or only grown since signing, count as the store, and only the catalog's `/Metadata` is the document's XMP. A `/DSS` entry naming a page's contents, or a `/Type /DSS` or `/Type /Metadata` label on an existing dictionary or stream, makes nothing permitted. Six of the new tests fail on the previous code, among them a Form XObject labelled `/Type /Metadata` before signing that could be rewritten under a Valid signature. Negative tests at every DocMDP level. Cross-checked with pdfsig (both signatures valid). Still missing: AATL/EUTL trust lists (a separate change: they need CA-constraint checks in chain building first). Overall estimate unchanged (about 30–35%). + - **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%. - **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%). diff --git a/crates/sign/README.md b/crates/sign/README.md index 6e8e785b..cb7455f8 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -38,7 +38,10 @@ for s in signatures(&doc, &bytes, &trust) { // li validity). `dss::embed` merges revocation evidence into the catalog's `/DSS` with `/VRI` entries 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. `revocation` parses and verifies RFC 5280 CRLs and + treats the store as a permitted change: only objects reached through `/Certs`, `/CRLs`, + `/OCSPs` and `/VRI` with the shape of their role, and new or only grown since the signature, + count (a `/DSS` entry naming a page's contents, or a `/Type /DSS` label, does not). + `revocation` parses 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. @@ -59,6 +62,16 @@ ECDSA P-256/P-384; the store integration is tested with software-backed keys. `pathLenConstraint` that 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 and `openssl cms -stream` write 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 `/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 + 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. diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index d665b4c5..7627c44e 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -50,6 +50,8 @@ pub struct SignedData { pub signer: SignerInfo, /// Encapsulated content (`adbe.pkcs7.sha1` carries the document digest here). pub content: Option>, + /// Encoding irregularities that were tolerated (BER instead of DER, …), for the details. + pub quirks: Vec<&'static str>, } fn bad(what: &str) -> SignError { @@ -60,7 +62,14 @@ impl SignedData { /// Parse a `ContentInfo` holding SignedData. Bytes after it (a PDF placeholder's zero /// padding) are ignored. pub fn parse(bytes: &[u8]) -> Result { - let (ci, _) = Tlv::parse(bytes)?; + // CMS from DocuSign, Documenso, Windows and `openssl cms -stream` is BER (indefinite lengths, + // segmented strings). Only its structure is read this way: certificates and signed + // attributes are still verified over their own exact bytes. + let (ci, _) = Tlv::parse_ber(bytes)?; + let mut quirks = Vec::new(); + if ci.is_indefinite() { + quirks.push("The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently."); + } let ci = ci.expect(tag::SEQUENCE, "ContentInfo")?.children()?; let [ct, content] = ci.as_slice() else { return Err(bad("ContentInfo")) }; if ct.oid()? != SIGNED_DATA { @@ -73,9 +82,12 @@ impl SignedData { let encap = it.next().ok_or_else(|| bad("encapContentInfo"))?.children()?; 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. let inner = c.inner()?; - // Constructed OCTET STRINGs (BER) are not expected in DER-signed PDFs. - Some(inner.expect(tag::OCTET_STRING, "eContent")?.value.to_vec()) + if inner.tag != tag::OCTET_STRING { + quirks.push("The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway."); + } + Some(inner.octets("eContent")?.into_owned()) } _ => None, }; @@ -99,7 +111,7 @@ impl SignedData { } 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 }) + Ok(SignedData { certificates, signer: parse_signer(si)?, content, quirks }) } /// The signer's certificate among those carried. @@ -150,8 +162,11 @@ fn parse_signer(si: &Tlv<'_>) -> Result { let (mut message_digest, mut content_type, mut signing_time, mut signing_certificate) = (None, None, None, false); if next.tag == tag::ctx(0) { // Signed over the DER SET OF, i.e. the same contents with the universal SET tag. - let mut set = next.raw.to_vec(); - set[0] = tag::SET; + // (An indefinite-length encoding is re-encoded with a definite length.) + let mut set = if next.is_indefinite() { der::tlv(tag::SET, next.value) } else { next.raw.to_vec() }; + if let Some(t) = set.first_mut() { + *t = tag::SET; + } for a in next.children()? { let p = a.children()?; let Some(o) = p.first().and_then(|o| o.oid().ok()) else { continue }; @@ -168,7 +183,7 @@ fn parse_signer(si: &Tlv<'_>) -> Result { next = it.next().ok_or_else(|| bad("signatureAlgorithm"))?; } let (scheme, scheme_digest) = signature_algorithm(&next)?; - let signature = it.next().ok_or_else(|| bad("signature"))?.expect(tag::OCTET_STRING, "signature")?.value.to_vec(); + let signature = it.next().ok_or_else(|| bad("signature"))?.octets("signature")?.into_owned(); let mut timestamp = false; let mut timestamp_token = None; for t in it { diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 949e7a92..5c816eae 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -140,6 +140,12 @@ impl<'a> Tlv<'a> { Ok(t) } + /// Whether this element is written with the indefinite length form (BER, never DER). Tags are + /// single-octet here, so the length octet is the second. + pub fn is_indefinite(&self) -> bool { + self.raw.get(1) == Some(&0x80) + } + /// The children of a constructed element, read with the same rules as their parent. pub fn children(&self) -> Result>, SignError> { let mut out = Vec::new(); diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ba7db951..d2925baa 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -537,6 +537,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf Err(e) => return invalid(info, &format!("The signature could not be read ({e}).")), }; let s = &sd.signer; + info.details.extend(sd.quirks.iter().map(|q| q.to_string())); info.digest = Some(s.digest); info.timestamp = s.timestamp; let mut unverified_time = None; @@ -873,6 +874,7 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // 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))); let (mut allowed, mut disallowed): (Vec<&str>, Vec<&str>) = (Vec::new(), Vec::new()); + let dss = dss_parts(doc, &old); // Stored at the same place in both (and not edited since): the same bytes, unchanged. let same_place = |num: u32| -> bool { use pdfcraft_cos::XrefEntry; @@ -898,18 +900,18 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod continue; } let kind = match &*new { + // The Document Security Store's own arrays and dictionaries, when they only grew. + _ if dss.contains(&num) => "document security store", Object::Dict(d) => change_kind(doc, &old, d, before.as_deref().and_then(Object::as_dict)), Object::Stream(s) => { if old_content.contains(&r) { "page content" } else if s.dict.name(b"Type") == Some(b"XRef") || s.dict.name(b"Type") == Some(b"ObjStm") || before.is_none() { continue; - } else if s.dict.name(b"Type") == Some(b"Metadata") - && (Some(r) == old_metadata || matches!(before.as_deref(), Some(Object::Stream(b)) if b.dict.name(b"Type") == Some(b"Metadata"))) - { - // Only the signed catalog's XMP stream, or a stream that was already - // metadata when signed: a later /Type /Metadata label on anything else - // (a page's Form XObject) does not make rewriting it a metadata change. + } else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata { + // Only the signed catalog's XMP stream. A /Type /Metadata label on anything + // 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. "metadata" } else { "other changes" @@ -935,6 +937,127 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod if disallowed.is_empty() { Modification::Allowed(own(allowed)) } else { Modification::Disallowed(own(disallowed)) } } +/// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3). +#[derive(Clone, Copy, PartialEq, Eq)] +enum DssRole { + /// The `/DSS` dictionary. + Dss, + /// A `/Certs`, `/CRLs`, `/OCSPs` (or a VRI entry's `/Cert`, `/CRL`, `/OCSP`) array. + DataArray, + /// A certificate, CRL, OCSP response or timestamp token stream. + Data, + /// The `/VRI` dictionary. + VriMap, + /// One entry of `/VRI`. + VriEntry, +} + +/// The indirect objects the catalog's `/DSS` reaches by the keys the standard defines, and the +/// role each plays there. Any other key is not followed: `/DSS << /X 5 0 R >>` does not make +/// object 5 part of the store. +fn dss_walk(doc: &Document) -> Vec<(ObjRef, DssRole)> { + const LIMIT: usize = 100_000; + let mut out = Vec::new(); + let Some(dss) = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"DSS").cloned())) else { return out }; + // The roles nest at most four deep (DSS, VRI, entry, array, data), so no seen-set is needed. + let mut stack = vec![(dss, DssRole::Dss)]; + let mut steps = 0usize; + while let Some((o, role)) = stack.pop() { + steps += 1; + if steps > LIMIT { + break; + } + if let Object::Ref(r) = &o { + out.push((*r, role)); + } + match (role, &*doc.resolve(&o)) { + (DssRole::Dss, Object::Dict(d)) => { + for (k, v) in d.iter() { + match k.as_slice() { + b"Certs" | b"CRLs" | b"OCSPs" => stack.push((v.clone(), DssRole::DataArray)), + b"VRI" => stack.push((v.clone(), DssRole::VriMap)), + _ => {} + } + } + } + (DssRole::DataArray, Object::Array(a)) => stack.extend(a.iter().map(|v| (v.clone(), DssRole::Data))), + (DssRole::VriMap, Object::Dict(d)) => stack.extend(d.iter().map(|(_, v)| (v.clone(), DssRole::VriEntry))), + (DssRole::VriEntry, Object::Dict(d)) => { + for (k, v) in d.iter() { + match k.as_slice() { + b"Cert" | b"CRL" | b"OCSP" => stack.push((v.clone(), DssRole::DataArray)), + b"TS" => stack.push((v.clone(), DssRole::Data)), + _ => {} + } + } + } + _ => {} + } + } + out +} + +/// Whether `now` only adds to `before`: an array keeps every element, a dictionary keeps every +/// entry (an entry that is an array or dictionary may itself have grown). +fn only_grew(doc: &Document, old: &Document, before: &Object, now: &Object, depth: u8) -> bool { + /// Arrays of anything but references are compared element by element (quadratic), so only + /// up to this size; a store's `/Certs`/`/CRLs`/`/OCSPs` are references, compared as sets. + const MAX_COMPARED: usize = 10_000; + match (before, now) { + (Object::Array(b), Object::Array(n)) => { + if b.len() > n.len() { + return false; + } + let refs = |a: &[Object]| a.iter().map(Object::as_ref).collect::>>(); + match (refs(b), refs(n)) { + (Some(b), Some(n)) => b.is_subset(&n), + _ => b.len() <= MAX_COMPARED && n.len() <= MAX_COMPARED && b.iter().all(|x| n.contains(x)), + } + } + (Object::Dict(b), Object::Dict(n)) => b.iter().all(|(k, v)| match n.get(k) { + Some(nv) => nv == v || (depth < 4 && only_grew(doc, old, &old.resolve(v), &doc.resolve(nv), depth + 1)), + None => false, + }), + _ => before == now, + } +} + +/// The objects of the Document Security Store that count as validation data added after +/// signing: its dictionary, `/Certs`/`/CRLs`/`/OCSPs` arrays, `/VRI` and its entries, when +/// - they are reached by the standard's keys and have the shape of their role, and +/// - they are new, or were already part of the signed store and have only grown. +/// +/// An existing object is never made "document security store" by pointing the store at it: the +/// page's `/Contents` array, or a dictionary labelled `/Type /DSS`, keep being judged as what they +/// are. The data streams need no entry here: new objects are not changes, and rewriting an +/// existing one is judged on its own. +fn dss_parts(doc: &Document, old: &Document) -> HashSet { + let signed: HashSet = dss_walk(old).into_iter().map(|(r, _)| r.num).collect(); + let is_stream = |o: &Object| matches!(&*doc.resolve(o), Object::Stream(_)); + let mut parts = HashSet::new(); + for (r, role) in dss_walk(doc) { + let now = doc.get(r); + let shaped = match (role, &*now) { + (DssRole::Dss, Object::Dict(d)) => d.iter().all(|(k, _)| matches!(k.as_slice(), b"Type" | b"Certs" | b"CRLs" | b"OCSPs" | b"VRI")), + (DssRole::DataArray, Object::Array(a)) => a.iter().all(|e| matches!(e, Object::Ref(_)) && is_stream(e)), + (DssRole::VriMap, Object::Dict(d)) => d.iter().all(|(_, v)| matches!(&*doc.resolve(v), Object::Dict(_))), + (DssRole::VriEntry, Object::Dict(d)) => { + d.iter().all(|(k, _)| matches!(k.as_slice(), b"Type" | b"Cert" | b"CRL" | b"OCSP" | b"TU" | b"TS")) + } + _ => false, + }; + let before = old.try_get(r.num).ok().filter(|o| !matches!(**o, Object::Null)); + let history = match before { + None => true, + Some(b) => signed.contains(&r.num) && only_grew(doc, old, &b, &now, 0), + }; + if shaped && history { + parts.insert(r.num); + } + } + parts +} + /// What adding `added` annotations amounts to: signing (signature widgets only), form fill /// (other widgets) or comments. fn added_annots(doc: &Document, added: &[ObjRef]) -> &'static str { @@ -993,7 +1116,6 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) }; match ty { Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") => return "signature", - Some(b"DSS") => return "document security store", Some(b"Catalog") => { let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"]; return match before { @@ -1024,7 +1146,6 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) }; } Some(b"Pages") => return if before.is_some() { "pages added or removed" } else { "document structure" }, - Some(b"Metadata") => return "metadata", _ => {} } match d.name(b"Subtype") { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index e9e40f64..66bf7c8a 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -352,3 +352,22 @@ fn real_file_discovery_cache_diagnostic() { eprintln!("signature-discovery-reopen\tfull_scans={}", scans(&cache)); assert_eq!(scans(&cache), 2); } + +/// A store's arrays only "grew" when every old element is still there; huge arrays (a crafted +/// `/Certs` of 200,000 references) are compared in linear time, and huge arrays of values that +/// aren't references are not taken as growth at all, rather than compared quadratically. +#[test] +fn only_grew_compares_huge_arrays_without_quadratic_work() { + let doc = Document::new_empty(); + let refs = |n: u32| Object::Array((1..=n).map(|i| Object::Ref(ObjRef::new(i, 0))).collect()); + let start = std::time::Instant::now(); + assert!(only_grew(&doc, &doc, &refs(200_000), &refs(200_001), 0)); + assert!(!only_grew(&doc, &doc, &refs(200_001), &refs(200_000), 0)); + let mut dropped = (1..=200_000).map(|i| Object::Ref(ObjRef::new(i, 0))).collect::>(); + dropped[100_000] = Object::Ref(ObjRef::new(999_999, 0)); + assert!(!only_grew(&doc, &doc, &refs(200_000), &Object::Array(dropped), 0)); + let ints = |n: i64| Object::Array((0..n).map(Object::Int).collect()); + assert!(only_grew(&doc, &doc, &ints(100), &ints(101), 0)); + assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); +} diff --git a/crates/sign/tests/ber.rs b/crates/sign/tests/ber.rs new file mode 100644 index 00000000..4a0032f5 --- /dev/null +++ b/crates/sign/tests/ber.rs @@ -0,0 +1,76 @@ +//! CMS read as BER: the bounds on what a crafted /Contents can make the reader do. +//! +//! The BER reader itself is `der::Tlv::parse_ber`; these tests go through `SignedData::parse`, +//! the way a PDF's signature reaches it. + +use pdfcraft_sign::cms::SignedData; +use pdfcraft_sign::der::{self, tag}; + +/// `levels` constructed OCTET STRINGs inside one another, each with a definite length, around one +/// empty primitive OCTET STRING. Built from the inside out in linear time. +fn nested_octet_strings(levels: usize) -> Vec { + let header = |len: usize| -> Vec { + let mut h = vec![tag::OCTET_STRING_SEGMENTS]; + if len < 0x80 { + h.push(len as u8); + } else { + let be: Vec = len.to_be_bytes().into_iter().skip_while(|b| *b == 0).collect(); + h.push(0x80 | be.len() as u8); + h.extend(be); + } + h + }; + // The content length of each level, the innermost (the empty `04 00`, 2 bytes) first. + let mut inner = vec![2usize]; + for _ in 0..levels { + let last = inner.last().copied().unwrap_or(2); + inner.push(last + header(last).len()); + } + let mut out = Vec::new(); + for len in inner.iter().rev().skip(1) { + out.extend(header(*len)); + } + out.extend([tag::OCTET_STRING, 0x00]); + out +} + +/// A ContentInfo holding SignedData whose encapsulated content is `econtent`. It has no +/// SignerInfo, so parsing always ends in an error: what matters is where. +fn cms_with_econtent(econtent: &[u8]) -> Vec { + let encap = der::seq(&[&der::oid("1.2.840.113549.1.7.1"), &der::explicit(0, econtent)]); + let signed_data = der::seq(&[&der::int(1), &der::tlv(tag::SET, &[]), &encap]); + der::seq(&[&der::oid("1.2.840.113549.1.7.2"), &der::explicit(0, &signed_data)]) +} + +fn error_of(cms: &[u8]) -> String { + SignedData::parse(cms).err().map(|e| e.to_string()).unwrap_or_default() +} + +#[test] +fn deeply_nested_segmented_content_is_an_error_not_a_stack_overflow() { + // Definite lengths nest without any end-of-contents scan to bound them, so a /Contents of a few + // hundred KB can hold 100 000 levels. It used to be able to overflow the stack and abort. + let deep = cms_with_econtent(&nested_octet_strings(100_000)); + assert!(deep.len() < 1 << 20, "a plausible /Contents"); + let why = error_of(&deep); + assert!(why.contains("nested too deeply"), "{why}"); + // A few segments, as a BER signer writes them, are read: the error is the missing SignerInfo. + let shallow = error_of(&cms_with_econtent(&nested_octet_strings(2))); + assert!(shallow.contains("signerInfos"), "{shallow}"); +} + +#[test] +fn indefinite_length_nesting_bombs_are_refused() { + // 10 000 indefinite-length SEQUENCEs: every level would have to be walked to find its end. + let bomb: Vec = std::iter::repeat_n([tag::SEQUENCE, 0x80], 10_000).flatten().collect(); + assert!(SignedData::parse(&bomb).is_err()); + let in_content = cms_with_econtent(&bomb); + assert!(SignedData::parse(&in_content).is_err()); +} + +#[test] +fn a_truncated_indefinite_length_cms_is_an_error() { + // The end-of-contents octets are missing, as in a /Contents cut short. + let cut = [0x30, 0x80, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02]; + assert!(SignedData::parse(&cut).is_err()); +} diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 206277f6..6029f19c 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -589,6 +589,384 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() { } } +/// Re-encode a DER CMS the way Windows CryptoAPI / Adobe PPKMS / `openssl cms -stream` write it: +/// indefinite lengths on ContentInfo, [0] and SignedData (the signed attributes stay DER). +fn to_ber(der_cms: &[u8]) -> Vec { + use pdfcraft_sign::der::Tlv; + let ci = Tlv::parse(der_cms).unwrap().0.children().unwrap(); + let sd = ci[1].inner().unwrap().children().unwrap(); + let mut out = vec![0x30, 0x80]; + out.extend_from_slice(ci[0].raw); + out.extend_from_slice(&[0xA0, 0x80, 0x30, 0x80]); + for c in sd { + out.extend_from_slice(c.raw); + } + out.extend_from_slice(&[0; 6]); + out +} + +/// The signature `/Contents` hex of the only signed field, and its position in `pdf`. +fn contents_span(pdf: &[u8]) -> (usize, usize) { + let key = b"/Contents <"; + let start = pdf.windows(key.len()).position(|w| w == key).map(|i| i + key.len()).unwrap(); + (start, start + pdf[start..].iter().position(|b| *b == b'>').unwrap()) +} + +#[test] +fn validates_signatures_written_with_ber_indefinite_lengths() { + for file in ["rsa-aes.p12", "ec-p256.p12"] { + let id = pkcs12::open(&data(file), "test").unwrap(); + let mut signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + let (a, b) = contents_span(&signed); + let hex = |s: &[u8]| s.iter().map(|x| format!("{x:02x}")).collect::(); + let der: Vec = (a..b).step_by(2).map(|i| u8::from_str_radix(std::str::from_utf8(&signed[i..i + 2]).unwrap(), 16).unwrap()).collect(); + let der_len = pdfcraft_sign::der::Tlv::parse(&der).unwrap().0.raw.len(); + let mut ber = to_ber(&der[..der_len]); + assert_eq!(ber[1], 0x80, "indefinite"); + ber.resize((b - a) / 2, 0); + signed[a..b].copy_from_slice(hex(&ber).as_bytes()); + let anchor = id.certificate.clone(); + let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{file}: {:?}", s.details); + assert!(s.details.iter().any(|d| d.contains("BER")), "the tolerance is reported: {:?}", s.details); + // Tampering is still caught: the digest check is not relaxed. + let mut tampered = signed.clone(); + let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap(); + tampered[i] = b'K'; + let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid); + } +} + +#[test] +fn validation_data_and_xmp_added_after_signing_are_not_tampering() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + // As DocuSign / Acrobat LTV do: a DSS whose /Certs and /VRI are indirect objects, and XMP. + let edited = edit_after(&signed, |doc| { + let mut cert = pdfcraft_cos::Dict::new(); + cert.set(b"Length".to_vec(), Object::Int(3)); + let cert = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(cert, vec![1, 2, 3]))); + let certs = doc.add(Object::Array(vec![Object::Ref(cert)])); + let vri = doc.add(Object::Dict(pdfcraft_cos::Dict::new())); + let mut dss = pdfcraft_cos::Dict::new(); + dss.set(b"Certs".to_vec(), Object::Ref(certs)); + dss.set(b"VRI".to_vec(), Object::Ref(vri)); + let dss = doc.add(Object::Dict(dss)); + let mut xmp = pdfcraft_cos::Dict::new(); + xmp.set(b"Type".to_vec(), Object::name("Metadata")); + xmp.set(b"Subtype".to_vec(), Object::name("XML")); + xmp.set(b"Length".to_vec(), Object::Int(1)); + let xmp = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(xmp, b"x".to_vec()))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"DSS".to_vec(), Object::Ref(dss)); + c.set(b"Metadata".to_vec(), Object::Ref(xmp)); + }) + .unwrap(); + }); + let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert!(matches!(s.modification, Modification::Allowed(_)), "{:?}", s.modification); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + // Page content changes are still refused. + let edited = edit_after(&signed, change_text); + let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid); +} + +// ── DSS and metadata: what later revisions may add ───────────────────────────────────────── + +fn oref(num: u32) -> Object { + Object::Ref(pdfcraft_cos::ObjRef { num, generation: 0 }) +} + +fn dict_of(entries: Vec<(&str, Object)>) -> pdfcraft_cos::Dict { + let mut d = pdfcraft_cos::Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + d +} + +fn stream_of(body: &[u8]) -> Object { + let d = dict_of(vec![("Length", Object::Int(body.len() as i64))]); + Object::Stream(pdfcraft_cos::Stream::from_raw(d, body.to_vec())) +} + +/// Set the catalog's `/DSS`. +fn set_dss(doc: &mut Document, dss: Object) { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"DSS".to_vec(), dss)).unwrap(); +} + +/// Rewrite object `num` (the page's contents) so the page shows other text. +fn forge_contents(doc: &mut Document, num: u32) { + doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, stream_of(b"BT /F1 14 Tf 20 250 Td (Forged) Tj ET")); +} + +/// `fixture()` with the page's `/Contents` an indirect array (object 7) holding object 4. +fn fixture_with_contents_array() -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 7 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 >>", + b"[4 0 R]", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Every DocMDP level: no certification (an approval signature), then P=1, 2 and 3. +const LEVELS: [Option; 4] = [None, Some(1), Some(2), Some(3)]; + +/// Each tampering `attack` of the signed `base` is refused at every level, with `kind` among the +/// reasons, while the untouched file stays valid. +fn assert_refused_at_every_level(base: &[u8], kind: &str, attack: impl Fn(&mut Document)) { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + for certify in LEVELS { + let signed = pdfcraft_sign::sign(&open(base), &id, &SignOptions { certify, ..opts() }).unwrap(); + assert_eq!(check(&signed).status, Status::Valid, "{certify:?}: untouched"); + let s = check(&edit_after(&signed, &attack)); + assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?} {:?}", s.modification, s.details); + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.iter().any(|k| k == kind)), "{certify:?}: {:?}", s.modification); + } +} + +#[test] +fn a_dss_entry_does_not_make_the_object_it_names_part_of_the_store() { + // `/DSS << /X 4 0 R >>` and a rewrite of object 4, the page's contents: the contents are + // still page content. + assert_refused_at_every_level(&fixture(), "page content", |doc| { + set_dss(doc, Object::Dict(dict_of(vec![("X", oref(4))]))); + forge_contents(doc, 4); + }); + // The same through an indirect store, and through every key the store does define. + for key in ["X", "Certs", "CRLs", "OCSPs"] { + assert_refused_at_every_level(&fixture(), "page content", |doc| { + let dss = doc.add(Object::Dict(dict_of(vec![(key, Object::Array(vec![oref(4)]))]))); + set_dss(doc, Object::Ref(dss)); + forge_contents(doc, 4); + }); + } + // A VRI entry's /Cert array, and its /TS. + for key in ["Cert", "CRL", "OCSP", "TS"] { + assert_refused_at_every_level(&fixture(), "page content", |doc| { + let value = if key == "TS" { oref(4) } else { Object::Array(vec![oref(4)]) }; + let entry = doc.add(Object::Dict(dict_of(vec![(key, value)]))); + let vri = doc.add(Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", Object::Ref(entry))]))); + let dss = doc.add(Object::Dict(dict_of(vec![("VRI", Object::Ref(vri))]))); + set_dss(doc, Object::Ref(dss)); + forge_contents(doc, 4); + }); + } +} + +#[test] +fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() { + // The font dictionary (object 5), rewritten and then listed as the store, its /VRI, a VRI + // entry, or simply labelled /Type /DSS. + let rewrite_font = |doc: &mut Document, extra: Option<(&str, Object)>| { + let mut d = dict_of(vec![("Type", Object::name("Font")), ("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Courier"))]); + if let Some((k, v)) = extra { + d.set(k.as_bytes().to_vec(), v); + } + doc.set(pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(d)); + }; + assert_refused_at_every_level(&fixture(), "other changes", |doc| { + rewrite_font(doc, Some(("Type", Object::name("DSS")))); + }); + assert_refused_at_every_level(&fixture(), "other changes", |doc| { + set_dss(doc, oref(5)); + rewrite_font(doc, None); + }); + assert_refused_at_every_level(&fixture(), "other changes", |doc| { + set_dss(doc, Object::Dict(dict_of(vec![("VRI", oref(5))]))); + rewrite_font(doc, None); + }); + // An existing dictionary with only DSS-looking keys that the signed file never had as a store. + assert_refused_at_every_level(&fixture(), "other changes", |doc| { + doc.set( + pdfcraft_cos::ObjRef { num: 5, generation: 0 }, + Object::Dict(dict_of(vec![("Type", Object::name("Font")), ("Cert", Object::Array(vec![]))])), + ); + set_dss(doc, Object::Dict(dict_of(vec![("VRI", Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", oref(5))])))]))); + }); +} + +#[test] +fn an_existing_array_stays_what_it_is_when_the_store_points_at_it() { + // The page's /Contents is an indirect array (object 7). Pointing /Certs at it and appending + // a new stream still changes what the page shows. + assert_refused_at_every_level(&fixture_with_contents_array(), "other changes", |doc| { + let extra = doc.add(stream_of(b"BT /F1 14 Tf 20 200 Td (Forged) Tj ET")); + doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Array(vec![oref(4), Object::Ref(extra)])); + set_dss(doc, Object::Dict(dict_of(vec![("Certs", oref(7))]))); + }); +} + +#[test] +fn a_dictionary_labelled_metadata_is_not_a_metadata_change() { + // Only the catalog's XMP stream is metadata (a stream, not a dictionary a label was put on). + assert_refused_at_every_level(&fixture(), "other changes", |doc| { + doc.set( + pdfcraft_cos::ObjRef { num: 5, generation: 0 }, + Object::Dict(dict_of(vec![ + ("Type", Object::name("Metadata")), + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Courier")), + ])), + ); + }); +} + +/// A one-page document like `fixture()` (objects 1–6) plus a stream, object 7, with the given +/// dictionary entries and body. `catalog_extra` goes into the catalog; the page draws object 7 +/// as a Form XObject when `drawn`. +fn fixture_with_stream_7(catalog_extra: &str, drawn: bool, dict: &str, body: &[u8]) -> Vec { + let page_content: &[u8] = + if drawn { b"BT /F1 14 Tf 20 250 Td (Contract text) Tj ET /Fm1 Do" } else { b"BT /F1 14 Tf 20 250 Td (Contract text) Tj ET" }; + let xobject = if drawn { " /XObject << /Fm1 7 0 R >>" } else { "" }; + let stream = |dict: &str, body: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes(); + o.extend_from_slice(body); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> {catalog_extra} >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >>{xobject} >> /Annots [6 0 R] >>" + ) + .into_bytes(), + stream("", page_content), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>".to_vec(), + stream(dict, body), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it() { + // A Form XObject the page draws, labelled /Type /Metadata before signing (the renderer ignores + // /Type). Rewriting it later changes what the page shows, so it is not a metadata change. + let base = fixture_with_stream_7("", true, "/Type /Metadata /Subtype /Form /BBox [0 0 300 300]", b"0 0 1 rg 10 10 100 100 re f"); + assert_refused_at_every_level(&base, "other changes", |doc| { + let d = dict_of(vec![ + ("Type", Object::name("Metadata")), + ("Subtype", Object::name("Form")), + ("BBox", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)])), + ("Length", Object::Int(27)), + ]); + doc.set( + pdfcraft_cos::ObjRef { num: 7, generation: 0 }, + Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"1 0 0 rg 10 10 100 100 re f".to_vec())), + ); + }); +} + +#[test] +fn the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing() { + // The control for the test above: the stream the signed catalog's /Metadata names is the + // document's XMP, and rewriting it is allowed at every level. + let base = fixture_with_stream_7("/Metadata 7 0 R", false, "/Type /Metadata /Subtype /XML", b""); + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for certify in LEVELS { + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { certify, ..opts() }).unwrap(); + let edited = edit_after(&signed, |doc| { + let d = dict_of(vec![("Type", Object::name("Metadata")), ("Subtype", Object::name("XML")), ("Length", Object::Int(13))]); + doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"".to_vec()))); + }); + let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(matches!(&s.modification, Modification::Allowed(k) if k.iter().any(|k| k == "metadata")), "{certify:?}: {:?}", s.modification); + assert_eq!(s.status, Status::Valid, "{certify:?}: {:?}", s.details); + } +} + +/// A document whose catalog already has a store: `/Certs` (object `.0`) with one certificate, +/// and `/VRI` (object `.1`). +fn fixture_with_dss() -> (Vec, u32, u32) { + let mut ids = (0, 0); + let bytes = edit_after(&fixture(), |doc| { + let cert = doc.add(stream_of(&[1, 2, 3])); + let certs = doc.add(Object::Array(vec![Object::Ref(cert)])); + let vri = doc.add(Object::Dict(pdfcraft_cos::Dict::new())); + let dss = doc.add(Object::Dict(dict_of(vec![("Type", Object::name("DSS")), ("Certs", Object::Ref(certs)), ("VRI", Object::Ref(vri))]))); + set_dss(doc, Object::Ref(dss)); + ids = (certs.num, vri.num); + }); + (bytes, ids.0, ids.1) +} + +#[test] +fn a_signed_store_may_grow_but_not_lose_or_swap_entries() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let (base, certs, vri) = fixture_with_dss(); + for certify in LEVELS { + let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { certify, ..opts() }).unwrap(); + assert_eq!(check(&signed).status, Status::Valid, "{certify:?}"); + // Validation data appended to the arrays, and a VRI entry: what Acrobat's LTV does. + let grown = edit_after(&signed, |doc| { + let more = doc.add(stream_of(&[4, 5, 6])); + let entry = doc.add(Object::Dict(dict_of(vec![("Cert", Object::Array(vec![Object::Ref(more)]))]))); + let old = open(&signed); + let Object::Array(mut kept) = (*old.get(pdfcraft_cos::ObjRef { num: certs, generation: 0 })).clone() else { panic!("array") }; + kept.push(Object::Ref(more)); + doc.set(pdfcraft_cos::ObjRef { num: certs, generation: 0 }, Object::Array(kept)); + doc.set( + pdfcraft_cos::ObjRef { num: vri, generation: 0 }, + Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", Object::Ref(entry))])), + ); + }); + let s = check(&grown); + assert_eq!(s.status, Status::Valid, "{certify:?}: {:?} {:?}", s.modification, s.details); + assert!(matches!(&s.modification, Modification::Allowed(k) if k.iter().any(|k| k == "document security store")), "{:?}", s.modification); + // Dropping what was signed is a change, not an addition. + let dropped = edit_after(&signed, |doc| { + doc.set(pdfcraft_cos::ObjRef { num: certs, generation: 0 }, Object::Array(vec![])); + }); + let s = check(&dropped); + assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.modification); + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.iter().any(|k| k == "other changes")), "{:?}", s.modification); + } +} + #[test] fn a_form_xobject_relabelled_metadata_is_not_a_metadata_change() { let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 42aebb07..5b68de8b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5942,8 +5942,8 @@ milestone = "M9" status = "shipped" commands = ["sign.validate"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl"] -notes = "validated; new signatures use CAdES" +evidence = ["crates/sign/tests/pdf.rs::validates_a_signature_made_by_openssl", "crates/sign/tests/pdf.rs::validates_signatures_written_with_ber_indefinite_lengths", "crates/sign/tests/ber.rs::deeply_nested_segmented_content_is_an_error_not_a_stack_overflow", "crates/sign/tests/ber.rs::indefinite_length_nesting_bombs_are_refused"] +notes = "validated, including BER-encoded CMS (indefinite lengths and segmented strings, as DocuSign, Documenso, Adobe PPKMS and Windows write it; the BER is reported in the details); new signatures use CAdES. Not read: BER high tag numbers. Still missing: multiple SignerInfos, counter-signatures, CMS with the signer's cert referenced but absent" [[feature]] id = "sign.cades-pades-bb" @@ -6218,8 +6218,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure" +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level)" [[feature]] id = "sign.view-signed-version"