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:
mtlcl410
2026-10-17 01:21:28 +08:00
co-authored by Claude Opus 5.5
parent f0c9901c39
commit 4a35a1cb23
3 changed files with 86 additions and 4 deletions
+23
View File
@@ -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 {
+61 -2
View File
@@ -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> {
+2 -2
View File
@@ -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"