mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
Merge pull request #6364 from benandez/fix/sign-p1-only-invisible-timestamps
sign: under no-changes certification, only invisible document timestamps may follow
This commit is contained in:
2 files changed
+122
No files matched your search
@@ -197,9 +197,60 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
|||||||
disallowed.push(what);
|
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) }
|
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> {
|
struct ChangedObjectClassifier<'a> {
|
||||||
doc: &'a Document,
|
doc: &'a Document,
|
||||||
old: &'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);
|
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]
|
#[test]
|
||||||
fn invisible_signatures_and_refusals() {
|
fn invisible_signatures_and_refusals() {
|
||||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user