mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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:
1 parent
9f04acccef
commit
e1a3c033aa
2 files changed
+122
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user