sign: judge the signed page tree, catalog and form by their signed roles

Rebased onto 102cae3b/9afa1266 for landing (#6358). The signed page tree's
nodes, a /Kids array of its own, the catalog by object number and an indirect
AcroForm keep the roles they had when signed; a field dropped from the form is
no permitted change at any level; a signed widget's /Rect or /F is page content.

Co-Authored-By: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
Martin Ball
2026-11-05 21:40:30 -05:00
committed by Dylan M. Taylor
parent 9afa126653
commit ddeca878df
3 changed files with 295 additions and 22 deletions
+95 -22
View File
@@ -68,14 +68,26 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
};
placed_annots.extend(found.into_iter().map(|r| r.num));
}
// The signed page tree's nodes: a change to one changes the pages, whatever it is labelled
// now. Every node, and every /Kids array object of its own, is read once; past the bound the
// tree is not known in full, and no change is judged against it.
let page_tree = page_tree_nodes(&old);
let page_tree_unbounded = page_tree.is_none();
let (page_nodes, kids_arrays) = page_tree.unwrap_or_default();
// What each signed page draws from, by the role it held when signed (#1787).
let signed_resources = signed_page_resources(&old, &signed_page_refs);
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref).map(|r| r.num);
// The signed catalog's /AcroForm, when it is an object of its own.
let old_form = old.root().and_then(|c| old.get(c).as_dict().and_then(|c| c.get(b"AcroForm").and_then(Object::as_ref))).map(|r| r.num);
let signed_role = |num: u32| old_content.contains(&num) || signed_pages.contains(&num) || signed_resources.contains_key(&num);
let (mut allowed, mut disallowed): (Vec<&'static str>, Vec<&'static str>) = (Vec::new(), Vec::new());
if annots_unbounded {
disallowed.push(kind::OTHER);
}
if page_tree_unbounded {
// Past the bound the tree is not known in full: no change is judged against it.
disallowed.push(kind::PAGES);
}
// Every reference the signed revision makes, read only when a slot's generations narrowed.
let signed_references = OnceCell::new();
let dss = dss_parts(doc, &old);
@@ -117,6 +129,9 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
old_xfa_data: &old_xfa_data,
signed_pages: &signed_pages,
signed_annots: &signed_annots,
page_nodes: &page_nodes,
kids_arrays: &kids_arrays,
old_form,
placed_annots: &placed_annots,
annot_budget: &annot_budget,
signed_resources: &signed_resources,
@@ -259,10 +274,13 @@ struct ChangedObjectClassifier<'a> {
old_xfa_data: &'a HashSet<u32>,
signed_pages: &'a HashSet<u32>,
signed_annots: &'a HashSet<u32>,
page_nodes: &'a HashSet<u32>,
kids_arrays: &'a HashSet<u32>,
placed_annots: &'a HashSet<u32>,
annot_budget: &'a Cell<usize>,
signed_resources: &'a HashMap<u32, bool>,
old_info: Option<u32>,
old_form: Option<u32>,
dss: &'a HashSet<u32>,
}
@@ -275,6 +293,8 @@ impl ChangedObjectClassifier<'_> {
return Some(kind::SECURITY_STORE);
}
match new {
// A signed page-tree node's /Kids array of its own lists the pages.
Object::Array(_) if before.is_some() && self.kids_arrays.contains(&r.num) => Some(kind::PAGES),
Object::Array(_) if self.is_signed_annotation_array(r, before) => self.annotation_array_change(new, before),
Object::Dict(dict) => self.dictionary_change(r, dict, before),
Object::Stream(stream) => self.stream_change(r, stream, before),
@@ -342,6 +362,15 @@ impl ChangedObjectClassifier<'_> {
if self.signed_pages.contains(&r.num) {
// A signed page is judged by what the page shows, whatever its new labels say.
self.page_change_kind(dict, previous)
} else if previous.is_some() && self.page_nodes.contains(&r.num) {
// A signed page-tree node is a page-tree change, whatever its new version is labelled.
Some(kind::PAGES)
} else if let Some(previous) = previous.filter(|_| self.old.root().is_some_and(|root| root.num == r.num)) {
// The signed catalog, whatever /Type its new version carries.
Some(catalog_kind(self.doc, self.old, dict, previous))
} else if let Some(previous) = previous.filter(|_| self.old_form == Some(r.num)) {
// The signed form dictionary, held in an object of its own, whatever keys it carries now.
Some(acroform_kind(self.doc, self.old, dict, Some(previous)))
} else if previous.is_some() && Some(r.num) == self.old_info {
// Only the signed revision's own /Info dictionary is a metadata change.
Some(kind::METADATA)
@@ -601,6 +630,10 @@ fn acroform(doc: &Document) -> Option<Dict> {
fn acroform_kind(doc: &Document, old: &Document, now: &Dict, before: Option<&Dict>) -> &'static str {
let fallback = acroform(old).unwrap_or_default();
let before = before.unwrap_or(&fallback);
// A field taken out of the form: no DocMDP permission covers dropping a signed field.
if lost_entries(doc, old, before.get(b"Fields"), now.get(b"Fields")) {
return kind::OTHER;
}
let strip = |d: &Dict| -> Vec<(Vec<u8>, Object)> {
let mut v: Vec<(Vec<u8>, Object)> =
d.iter().filter(|(k, _)| !matches!(k.as_slice(), b"Fields" | b"SigFlags")).map(|(k, o)| (k.to_vec(), o.clone())).collect();
@@ -724,6 +757,62 @@ fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool {
}
}
/// A change to the signed catalog `b`, now `d`.
fn catalog_kind(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> &'static str {
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"];
if without(d, keys) != without(b, keys) {
kind::STRUCTURE
} else if !extensions_kept(doc, old, d, b) {
// Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A writer
// adding a /DSS also declares the ESIC extension it belongs to, so a declaration may be
// added, but none that was signed may change or go.
kind::STRUCTURE
} else if d.get(b"AcroForm") != b.get(b"AcroForm") {
// Compare the forms themselves, inline or not.
let now = acroform(doc).unwrap_or_default();
acroform_kind(doc, old, &now, None)
} else if b.get(b"Perms") != d.get(b"Perms") {
kind::SIGNATURE
} else {
kind::METADATA
}
}
/// The nodes of `doc`'s page tree and the /Kids arrays that are objects of their own, by number:
/// every dictionary with /Kids from the catalog's /Pages down. Each node, and each /Kids array
/// object, is read once. `None` past a million nodes.
fn page_tree_nodes(doc: &Document) -> Option<(HashSet<u32>, HashSet<u32>)> {
const MAX_NODES: usize = 1_000_000;
let (mut nodes, mut arrays) = (HashSet::new(), HashSet::new());
let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"Pages").and_then(Object::as_ref)));
let mut stack: Vec<ObjRef> = top.into_iter().collect();
while let Some(r) = stack.pop() {
if nodes.len() >= MAX_NODES {
return None;
}
let node = doc.get(r);
// A leaf (a page) has no /Kids.
let Some(kids) = node.as_dict().and_then(|d| d.get(b"Kids")) else { continue };
if !nodes.insert(r.num) || kids.as_ref().is_some_and(|a| !arrays.insert(a.num)) {
continue;
}
if let Some(kids) = doc.resolve(kids).as_array() {
stack.extend(kids.iter().filter_map(Object::as_ref));
}
}
Some((nodes, arrays))
}
/// Whether a list of references (`/Fields`) no longer holds an entry it held when signed. An
/// entry is still held only by a reference that resolves now and that the signed revision
/// resolved too, so to the same slot (a repaired alias counts): one rewritten to a generation
/// the slot does not accept names nothing, and one to a newer generation names another object.
fn lost_entries(doc: &Document, old: &Document, was: Option<&Object>, now: Option<&Object>) -> bool {
let live = |d: &Document, r: &ObjRef| !matches!(*d.get(*r), Object::Null);
let now: HashSet<u32> = refs(doc, now).into_iter().filter(|r| live(doc, r) && live(old, r)).map(|r| r.num).collect();
refs(old, was).iter().filter(|r| live(old, r)).any(|r| !now.contains(&r.num))
}
fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
let ty = d.name(b"Type");
// A later revision cannot make an existing object a signature by relabelling it: a signature
@@ -746,31 +835,15 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
}) {
return kind::OTHER;
}
// Where a signed widget sits and whether it shows are what the page shows: filling the form
// changes its value and appearance, never its rectangle or its flags.
if before.is_some_and(|b| b.name(b"Subtype") == Some(b"Widget") && (d.get(b"Rect") != b.get(b"Rect") || d.get(b"F") != b.get(b"F"))) {
return kind::PAGE_CONTENT;
}
match ty {
// Only a new signature value or policy: rewriting a signed one is not a signature (#1788).
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE,
Some(b"Catalog") => {
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"];
return match before {
Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE,
// Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A
// writer adding a /DSS also declares the ESIC extension it belongs to, so a
// declaration may be added, but none that was signed may change or go.
Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE,
Some(b) => {
if d.get(b"AcroForm") != b.get(b"AcroForm") {
// Compare the forms themselves, inline or not.
let now = acroform(doc).unwrap_or_default();
acroform_kind(doc, old, &now, None)
} else if b.get(b"Perms") != d.get(b"Perms") {
kind::SIGNATURE
} else {
kind::METADATA
}
}
None => kind::FORM_FILL,
};
}
Some(b"Catalog") => return before.map_or(kind::FORM_FILL, |b| catalog_kind(doc, old, d, b)),
Some(b"Page") => {
return match before {
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT,
+1
View File
@@ -8,4 +8,5 @@ mod crypto;
mod encrypted_signing;
mod pdf;
mod revision_classification;
mod signed_structure;
mod store_keys;
+199
View File
@@ -0,0 +1,199 @@
//! A certified document's page tree, catalog and form keep the roles they had when signed: a
//! later revision that relabels them, drops a field from the form, or hides or moves a signed
//! field's widget is no form fill.
use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object, PdfString, SaveOptions, write_incremental};
use pdfcraft_sign::{Modification, SignOptions, Status, TrustStore, kind, pkcs12, signatures};
fn data(name: &str) -> Vec<u8> {
std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
}
fn open(b: &[u8]) -> Document {
Document::open(Arc::new(b.to_vec())).unwrap()
}
/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog.
fn write_pdf(objs: &[&str]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn r(num: u32) -> ObjRef {
ObjRef { num, generation: 0 }
}
/// Two pages, listed by the /Kids array object 6; page 3 holds text field 5, listed in /Fields.
fn form() -> Vec<u8> {
write_pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>",
"<< /Type /Pages /Kids 6 0 R /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [5 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Ann) /Rect [20 200 120 220] /P 3 0 R >>",
"[3 0 R 4 0 R]",
])
}
fn certified(p: u8) -> Vec<u8> {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let opts = SignOptions {
page: 1,
rect: Some([20.0, 100.0, 220.0, 150.0]),
date: "D:20261002120000+01'00'".into(),
certify: Some(p),
..SignOptions::default()
};
pdfcraft_sign::sign(&open(&form()), &id, &opts).unwrap()
}
fn edit_after(signed: &[u8], f: impl FnOnce(&mut Document)) -> Vec<u8> {
let mut doc = open(signed);
f(&mut doc);
write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap()
}
/// The certification signature of `bytes`: its status and modification verdict.
fn verdict(bytes: &[u8]) -> (Status, Modification) {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let s = signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed && s.certify.is_some()).unwrap();
(s.status, s.modification)
}
/// `bytes` is refused, for (among others) `expected`.
fn assert_refused(what: &str, bytes: &[u8], expected: &str) {
let (status, modification) = verdict(bytes);
assert!(
matches!(&modification, Modification::Disallowed(kinds) if kinds.contains(&expected)) && status != Status::Valid,
"{what}: {status:?} {modification:?}, expected {expected}"
);
}
#[test]
fn a_signed_page_tree_node_is_judged_as_the_page_tree() {
let signed = certified(2);
// Relabelled as a field and dropping a page.
assert_refused(
"node relabelled",
&edit_after(&signed, |doc| {
doc.update_dict(r(2), |n| {
n.remove(b"Type");
n.set(b"T".to_vec(), PdfString::text("x"));
n.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r(4))]));
n.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
}),
kind::PAGES,
);
// Turning every page through an inherited attribute.
assert_refused(
"inherited rotation",
&edit_after(&signed, |doc| {
doc.update_dict(r(2), |n| {
n.set(b"DR".to_vec(), Object::Dict(Default::default()));
n.set(b"Rotate".to_vec(), Object::Int(180));
})
.unwrap();
}),
kind::PAGES,
);
// Reordering the pages through the node's /Kids array object.
assert_refused(
"pages reordered",
&edit_after(&signed, |doc| doc.set(r(6), Object::Array(vec![Object::Ref(r(4)), Object::Ref(r(3))]))),
kind::PAGES,
);
}
#[test]
fn a_signed_catalog_is_judged_as_the_catalog_whatever_its_type() {
let signed = certified(2);
assert_refused(
"catalog relabelled",
&edit_after(&signed, |doc| {
let content = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Default::default(), b"0 0 m 300 300 l S".to_vec())));
let pages = doc.add(Object::Dict(Default::default()));
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array([0, 0, 300, 300].map(Object::Int).to_vec()));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
doc.update_dict(pages, |n| {
n.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
n.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.remove(b"Type");
c.set(b"FT".to_vec(), Object::name("Tx"));
c.set(b"Pages".to_vec(), Object::Ref(pages));
})
.unwrap();
}),
kind::STRUCTURE,
);
}
#[test]
fn a_field_dropped_from_the_form_is_no_change_any_level_permits() {
for p in [1, 2] {
let signed = certified(p);
let set_fields = |fields: Vec<Object>| {
edit_after(&signed, move |doc| {
let root = doc.root().unwrap();
let mut form =
doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm")).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap();
let kept: Vec<Object> = fields
.into_iter()
.chain(
form.get(b"Fields")
.and_then(Object::as_array)
.into_iter()
.flatten()
.filter(|f| f.as_ref().is_some_and(|f| f.num != 5))
.cloned(),
)
.collect();
form.set(b"Fields".to_vec(), Object::Array(kept));
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
})
};
assert_refused(&format!("P={p} field dropped"), &set_fields(Vec::new()), kind::OTHER);
let dead = ObjRef { num: 5, generation: 3 };
assert_refused(&format!("P={p} field listed by a dead generation"), &set_fields(vec![Object::Ref(dead)]), kind::OTHER);
// The field's slot taken by a newer generation: another object, listed in its place.
let newer = ObjRef { num: 5, generation: 1 };
let replaced = edit_after(&set_fields(vec![Object::Ref(newer)]), |doc| {
let mut field = doc.get(r(5)).as_dict().cloned().unwrap();
field.set(b"V".to_vec(), PdfString::text("Eve"));
doc.set(newer, Object::Dict(field));
});
assert_refused(&format!("P={p} field replaced by a newer generation"), &replaced, kind::OTHER);
}
}
#[test]
fn a_signed_fields_widget_may_be_filled_but_not_hidden_or_moved() {
let signed = certified(2);
let edit = |f: &dyn Fn(&mut pdfcraft_cos::Dict)| edit_after(&signed, |doc| doc.update_dict(r(5), |w| f(w)).unwrap());
let filled = edit(&|w| w.set(b"V".to_vec(), PdfString::text("Bob")));
assert_eq!(verdict(&filled), (Status::Valid, Modification::Allowed(vec![kind::FORM_FILL])));
assert_refused("hidden", &edit(&|w| w.set(b"F".to_vec(), Object::Int(2))), kind::PAGE_CONTENT);
assert_refused("moved", &edit(&|w| w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4]))), kind::PAGE_CONTENT);
}