From e1a3c033aaf749a758010bd1c9342c426eee2b39 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 6 Nov 2026 00:19:58 +0000 Subject: [PATCH] sign: under no-changes certification, only invisible document timestamps may follow DocMDP P=1 allowed any change classified as a signature. A signature added after signing now breaks the certification unless it is a document timestamp drawing nothing (no positive-area /Rect, or Hidden/NoView). The added objects are judged directly, so a widget with no /T or one only in a page's /Annots is covered. The document security store and a genuine timestamp still pass. Co-Authored-By: Claude Opus 4.8 --- crates/sign/src/pdf/changes.rs | 51 ++++++++++++++++++++++++ crates/sign/tests/it/pdf.rs | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 3a10a74e41..3f77fd0554 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -136,9 +136,60 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< disallowed.push(what); } } + // DocMDP P=1 ("no changes allowed", ISO 32000-2 §12.8.2.2) permits only validation data to + // follow: the document security store, and document timestamps added for PAdES long-term + // archiving (§12.8.5). Those timestamps draw nothing. Any other signature added since — an + // approval signature, an unsigned signature widget, or a widget that paints over the signed + // page — is a change the certification forbids, however it is labelled. + if p == Some(1) && allowed.contains(&kind::SIGNATURE) && !only_invisible_timestamps_added(doc, &old) { + allowed.retain(|k| *k != kind::SIGNATURE); + if !disallowed.contains(&kind::SIGNATURE) { + disallowed.push(kind::SIGNATURE); + } + } if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +/// Whether every signature object `doc` gained or changed since the signed revision `old` is an +/// invisible document timestamp. A signature annotation (a widget, or a dictionary carrying a +/// signature value or role) that the signed revision did not hold byte for byte must be a +/// `/SubFilter /ETSI.RFC3161` value in a widget that shows nothing — no positive-area `/Rect`, +/// or the Hidden or NoView flag set. An approval signature, an empty signature widget, or a +/// visible one fails, whatever `/Type` or `/SubFilter` it claims. Judged on the objects +/// themselves, not on the `/AcroForm /Fields` listing, so a widget with no `/T` or one reached +/// only through a page's `/Annots` is covered too. +fn only_invisible_timestamps_added(doc: &Document, old: &Document) -> bool { + /// A widget shows nothing: no `/Rect` enclosing a positive area, or Hidden (2) / NoView (32). + fn invisible(doc: &Document, w: &Dict) -> bool { + let flags = w.get(b"F").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0); + let area = + super::listing::nums(doc, w, b"Rect").filter(|r| r.len() == 4).is_some_and(|r| (r[0] - r[2]).abs() > 0.5 && (r[1] - r[3]).abs() > 0.5); + !area || flags & (2 | 32) != 0 + } + for num in doc.object_numbers() { + let new = doc.get(ObjRef { num, generation: doc.generation(num) }); + let Some(d) = new.as_dict() else { continue }; + // A signature object: a signature value, or a field or widget that carries one. + let is_value = matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")); + let is_field = d.name(b"FT") == Some(b"Sig") || d.name(b"Subtype") == Some(b"Widget"); + if !is_value && !is_field { + continue; + } + // Unchanged since signing: the certification already covered it. + if old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)).as_deref() == Some(&*new) { + continue; + } + let value = if is_value { Some(d.clone()) } else { d.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) }; + // A new or changed signature object passes only as an invisible document timestamp. A + // widget with no value (an empty signature field placed on the page) never does. + let timestamp = value.as_ref().is_some_and(super::validate::is_doc_timestamp); + if !timestamp || (is_field && !invisible(doc, d)) { + return false; + } + } + true +} + struct ChangedObjectClassifier<'a> { doc: &'a Document, old: &'a Document, diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 869a4bebd5..57efadbaf3 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -351,6 +351,77 @@ fn later_changes_are_classified_under_the_signature_permissions() { assert_eq!((s.certify, s.status), (Some(1), Status::Invalid), "{:?}", s.modification); } +/// Certify `fixture` with "no changes allowed" (DocMDP P=1). +fn certified_p1() -> (DigitalId, TrustStore, Vec) { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap(); + (id, trust, signed) +} + +/// Add a signature widget to page 3's /Annots (not to /Fields), with value `v` and rectangle +/// `rect`. +fn add_sig_widget(doc: &mut Document, v: Option<&[(&[u8], Object)]>, rect: [i64; 4]) { + let mut w = Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"FT".to_vec(), Object::name("Sig")); + w.set(b"Rect".to_vec(), Object::Array(rect.map(Object::Int).to_vec())); + w.set(b"P".to_vec(), Object::Ref(ObjRef { num: 3, generation: 0 })); + if let Some(entries) = v { + let mut value = Dict::new(); + for (k, o) in entries { + value.set(k.to_vec(), o.clone()); + } + let vr = doc.add(Object::Dict(value)); + w.set(b"V".to_vec(), Object::Ref(vr)); + } + let wr = doc.add(Object::Dict(w)); + doc.update_dict(ObjRef { num: 3, generation: 0 }, |pg| { + let mut a = pg.get(b"Annots").and_then(Object::as_array).cloned().unwrap_or_default(); + a.push(Object::Ref(wr)); + pg.set(b"Annots".to_vec(), Object::Array(a)); + }) + .unwrap(); +} + +/// Under P=1, a signature added after signing breaks the certification unless it is an invisible +/// document timestamp. A visible overlay, an unsigned widget, an approval signature and a visible +/// widget wearing a timestamp /SubFilter each break it; a genuine document timestamp does not. +#[test] +fn only_an_invisible_document_timestamp_survives_a_no_changes_certification() { + let (_, trust, signed) = certified_p1(); + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed && s.certify == Some(1)).unwrap(); + assert_eq!(check(&signed).status, Status::Valid, "{:?}", check(&signed).details); + let approval: &[(&[u8], Object)] = &[(b"Type", Object::name("Sig")), (b"SubFilter", Object::name("adbe.pkcs7.detached"))]; + let timestamp: &[(&[u8], Object)] = &[(b"Type", Object::name("DocTimeStamp")), (b"SubFilter", Object::name("ETSI.RFC3161"))]; + // Each of these breaks the certification: it is a change P=1 does not allow. + for (what, v, rect) in [ + ("visible overlay widget", None, [0, 0, 300, 300]), + ("invisible unsigned widget", None, [0, 0, 0, 0]), + ("visible approval signature", Some(approval), [0, 0, 300, 300]), + ("invisible approval signature", Some(approval), [0, 0, 0, 0]), + ("visible widget wearing a timestamp subfilter", Some(timestamp), [0, 0, 300, 300]), + ] { + let edited = edit_after(&signed, |doc| add_sig_widget(doc, v, rect)); + let s = check(&edited); + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::SIGNATURE)), "{what}: {:?}", s.modification); + assert_ne!(s.status, Status::Valid, "{what}"); + } +} + +/// A genuine document timestamp, which draws nothing, is validation data P=1 permits: the +/// certification stays valid. +#[test] +fn a_document_timestamp_keeps_a_no_changes_certification_valid() { + let (_, trust, signed) = certified_p1(); + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&signed), &tsa, "D:20261006120000Z").unwrap(); + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.certify == Some(1)).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SIGNATURE)), "{:?}", s.modification); +} + #[test] fn invisible_signatures_and_refusals() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();