mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
M9: rewriting a certified form's XFA data is a form fill (#2759)
The IRCC IMM forms are certified (DocMDP P=2) and then saved once more with Reader Extensions: a later revision adds the UR3 usage-rights signature and rewrites the XFA `datasets` packet. The diff classified any rewritten stream that wasn't page content or the catalog's XMP as "other changes", so these untouched forms validated as invalid. A stream the signed revision's /AcroForm /XFA array names `datasets` or `form` (the form's data and state) is now a form fill when rewritten, permitted at P=2 and P=3 and refused at P=1 as before. The roles come from the signed revision, so a later revision that names the template "datasets" doesn't make rewriting it a form fill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f0c9901c39
commit
4a35a1cb23
@@ -1200,6 +1200,8 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
.collect();
|
||||
// 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)));
|
||||
// The signed revision's XFA data packets: filling the form rewrites them.
|
||||
let old_xfa_data = xfa_data_packets(&old);
|
||||
// What each object was in the signed revision: its pages, and its trailer /Info. A changed
|
||||
// object is judged by the role it held when signed, never by the labels or keys its new,
|
||||
// attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its
|
||||
@@ -1295,6 +1297,12 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
// 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.
|
||||
kind::METADATA
|
||||
} else if old_xfa_data.contains(&r) {
|
||||
// The form's XFA data (`datasets`) or state (`form`) as the signed revision
|
||||
// named it: filling an XFA form rewrites these, as a Reader Extensions (UR3)
|
||||
// save of a certified form does (#2759). The role comes from the signed
|
||||
// revision, so a later one can't name page content as XFA data.
|
||||
kind::FORM_FILL
|
||||
} else {
|
||||
kind::OTHER
|
||||
}
|
||||
@@ -1318,6 +1326,21 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) }
|
||||
}
|
||||
|
||||
/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form`
|
||||
/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one
|
||||
/// XFA stream holds its template too, and is not listed.
|
||||
fn xfa_data_packets(doc: &Document) -> HashSet<ObjRef> {
|
||||
let Some(form) = acroform(doc) else { return HashSet::new() };
|
||||
let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() };
|
||||
let Object::Array(xfa) = &*xfa else { return HashSet::new() };
|
||||
xfa.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form")))
|
||||
.filter_map(|[_, packet]| packet.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DssRole {
|
||||
|
||||
@@ -13,14 +13,20 @@ fn data(name: &str) -> Vec<u8> {
|
||||
|
||||
/// One page with text and an unsigned signature field "Approval".
|
||||
fn fixture() -> Vec<u8> {
|
||||
let objs: Vec<&[u8]> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
|
||||
fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[])
|
||||
}
|
||||
|
||||
/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on.
|
||||
fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec<u8> {
|
||||
let mut objs: Vec<&[u8]> = vec![
|
||||
catalog,
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 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 >>",
|
||||
];
|
||||
objs.extend_from_slice(more);
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
@@ -749,6 +755,59 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9).
|
||||
fn xfa_fixture() -> Vec<u8> {
|
||||
fixture_with(
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>",
|
||||
&[
|
||||
b"<< /Length 11 >>\nstream\n<template/>\nendstream",
|
||||
b"<< /Length 22 >>\nstream\n<xfa:datasets><a>1</a>\nendstream",
|
||||
b"<< /Length 7 >>\nstream\n<form/>\nendstream",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Rewrite object `num` as a stream holding `body`.
|
||||
fn rewrite_stream(num: u32, body: &'static [u8]) -> impl FnOnce(&mut Document) {
|
||||
move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, stream_of(body))
|
||||
}
|
||||
|
||||
/// #2759: filling a certified XFA form rewrites its `datasets` (and `form`) packets, as a Reader
|
||||
/// Extensions save of the IRCC forms does. That is form fill, which certification level 2
|
||||
/// permits; level 1 still refuses it, and rewriting the template is not a form fill. The packets'
|
||||
/// roles come from the signed revision: a later revision that names the template "datasets"
|
||||
/// doesn't make rewriting it a form fill.
|
||||
#[test]
|
||||
fn rewriting_a_certified_forms_xfa_data_is_a_form_fill() {
|
||||
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 certified = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
|
||||
assert_eq!(check(&certified).status, Status::Valid);
|
||||
for num in [8, 9] {
|
||||
let s = check(&edit_after(&certified, rewrite_stream(num, b"<xfa:datasets><a>2</a></xfa:datasets>")));
|
||||
assert_eq!((s.status, s.modification.clone()), (Status::Valid, Modification::Allowed(vec![kind::FORM_FILL])), "{num}: {:?}", s.details);
|
||||
}
|
||||
let s = check(&edit_after(&certified, rewrite_stream(7, b"<template><subform/></template>")));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
|
||||
let renamed = edit_after(&certified, |doc| {
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm") {
|
||||
form.set(b"XFA".to_vec(), Object::Array(vec![Object::String(PdfString::text("datasets")), oref(7)]));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
rewrite_stream(7, b"<template><subform/></template>")(doc);
|
||||
});
|
||||
let s = check(&renamed);
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.modification);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::OTHER)), "{:?}", s.modification);
|
||||
let locked = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap();
|
||||
let s = check(&edit_after(&locked, rewrite_stream(8, b"<xfa:datasets><a>2</a></xfa:datasets>")));
|
||||
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::FORM_FILL])));
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
|
||||
@@ -6320,8 +6320,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", "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", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"]
|
||||
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). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)"
|
||||
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", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill"]
|
||||
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). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision"
|
||||
|
||||
[[feature]]
|
||||
id = "sign.view-signed-version"
|
||||
|
||||
Reference in New Issue
Block a user