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 <noreply@anthropic.com>
This commit is contained in:
Martin BallandClaude Opus 4.8 committed 2026-11-06 00:19:58 +00:00
1 parent 9f04acccef
commit e1a3c033aa
2 files changed
+122

No files matched your search

+51
View File
@@ -136,9 +136,60 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, 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,
+71
View File
@@ -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<u8>) {
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();