fix(sign): zero-filled placeholders are unsigned; look inside appearance XObjects (#5939)

- `has_signatures` treats a /Contents of only zero bytes as unsigned and finds
  signature dictionaries in one pass over the file (field /V values, plus
  /ByteRange dictionaries and document timestamps) instead of two.
- Before the first signature, a field's own appearance counts as drawing text
  only if it or a form XObject it paints shows text; a background-only XObject
  no longer keeps an appearance that would leave the field blank. The look
  inside opens at most 16 streams; past that it counts as text, as before.
- Tests: zero-filled placeholders (hex and literal), document timestamps, a
  widget without /AP, background-only and text-drawing XObject appearances
  with an unencodable character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-08 05:31:05 +01:00
1 parent d4b8c94a00
commit dcad5ff3b3
5 files changed
+97 -13

No files matched your search

+34 -3
View File
@@ -372,11 +372,42 @@ fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>)
std::borrow::Cow::Owned(drawn)
}
/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through a
/// form XObject it paints (counted as text without looking inside).
/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through the
/// form XObjects it paints (a background-only one doesn't count).
fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool {
let ap = widget.get(b"AP").map(|ap| cos.resolve(ap));
let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n));
let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false };
s.decoded().is_ok_and(|data| pdfcraft_content::ops(&data).any(|op| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do")))
stream_draws_text(cos, s, &mut 16)
}
/// Whether the form stream `s` shows text, following `Do` into its form XObjects. `budget` caps
/// the streams opened along the way; running out (deep nesting, a cycle, many XObjects) counts
/// as text, so the field keeps its own appearance as it would without the look inside.
fn stream_draws_text(cos: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, budget: &mut u8) -> bool {
let Ok(data) = s.decoded() else { return false };
let resources = s.dict.get(b"Resources").map(|r| cos.resolve(r));
let xobjects = resources.as_deref().and_then(|r| r.as_dict()).and_then(|r| r.get(b"XObject")).map(|x| cos.resolve(x));
for op in pdfcraft_content::ops(&data) {
match op.op.as_slice() {
b"Tj" | b"TJ" | b"'" | b"\"" => return true,
b"Do" => {
let name = op.operands.first().and_then(|n| n.as_name());
let x = name.and_then(|n| xobjects.as_deref().and_then(|x| x.as_dict()).and_then(|x| x.get(n))).map(|x| cos.resolve(x));
let Some(pdfcraft_cos::Object::Stream(f)) = x.as_deref() else { continue };
if f.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
if *budget == 0 {
return true;
}
*budget -= 1;
if stream_draws_text(cos, f, budget) {
return true;
}
}
_ => {}
}
}
false
}
+18 -3
View File
@@ -2701,14 +2701,17 @@ fn a_signed_file_carries_no_password_field_value() {
/// A form with `/NeedAppearances true` whose filled fields have stored appearances that draw no
/// text: `name` an empty placeholder, `town` one too with a value Helvetica can't hold (`ň`),
/// `city` such a value with its own appearance that draws it (#5939).
/// `city` such a value with its own appearance that draws it (#5939), `street` no appearance at
/// all, `river` a value Helvetica can't hold (`ř`) with an appearance that paints only a
/// background form XObject, and `lake` such a value drawn by a form XObject its appearance
/// paints.
fn need_appearances_form() -> Vec<u8> {
let stream =
|content: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(),
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada Lovelace) /Rect [20 250 180 270] /P 3 0 R /AP << /N 8 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (town) /V <FEFF0050006C007A00650148> /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V <FEFF00420072006E006F00200159> /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(),
@@ -2716,6 +2719,13 @@ fn need_appearances_form() -> Vec<u8> {
stream("/Tx BMC EMC"),
stream("/Tx BMC EMC"),
stream("/Tx BMC BT /Helv 10 Tf 2 5 Td (Brno r) Tj ET EMC"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (street) /V (Main Street) /Rect [20 100 180 120] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (river) /V <FEFF004F0159006500630068> /Rect [20 50 180 70] /P 3 0 R /AP << /N 13 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Bg 14 0 R >> >> /Length 10 >>\nstream\nq /Bg Do Q\nendstream".into(),
stream("0.9 g 0 0 160 20 re f"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (lake) /V <FEFF004F0159006500630068> /Rect [20 20 180 40] /P 3 0 R /AP << /N 16 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Fm 17 0 R >> >> /Length 10 >>\nstream\nq /Fm Do Q\nendstream".into(),
stream("BT /Helv 10 Tf 2 5 Td (Orech) Tj ET"),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
@@ -2757,9 +2767,14 @@ fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() {
assert!(normal_appearance(&doc, 4).contains("(Ada Lovelace) Tj"), "{}", normal_appearance(&doc, 4));
assert!(normal_appearance(&doc, 5).contains("(Plze?) Tj"), "{}", normal_appearance(&doc, 5));
assert!(normal_appearance(&doc, 6).contains("(Brno r) Tj"), "{}", normal_appearance(&doc, 6));
assert!(normal_appearance(&doc, 11).contains("(Main Street) Tj"), "{}", normal_appearance(&doc, 11));
// Its own appearance only paints a background, so `river` gets the drawing, `?` and all.
assert!(normal_appearance(&doc, 12).contains("(O?ech) Tj"), "{}", normal_appearance(&doc, 12));
let city = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(6, 0)).as_dict().unwrap().get(b"AP").cloned();
let original = pdfcraft_cos::Document::open(Arc::new(need_appearances_form())).unwrap();
assert_eq!(city(&doc), city(&original), "city keeps its own appearance");
let lake = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(15, 0)).as_dict().unwrap().get(b"AP").cloned();
assert_eq!(lake(&doc), lake(&original), "lake keeps the appearance its form XObject draws");
s.mark_signed(id, signed, None).unwrap();
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
let sig = &s.get(id).unwrap().signatures[0];
+14 -6
View File
@@ -120,16 +120,24 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S
const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
/// Whether `doc` already holds a signature or a document timestamp: a signature dictionary whose
/// `/Contents` is not empty (a field whose `/V` has an empty `/Contents` is not signed yet). A
/// signature field without a name (`/T`) counts too: indirect signature dictionaries are found by
/// their `/ByteRange`, not through the field tree.
/// `/Contents` holds a nonzero byte (a field whose `/V` has an empty or zero-filled `/Contents`
/// is not signed yet). A signature field without a name (`/T`) counts too: indirect signature
/// dictionaries and document timestamps are found in one pass over the file (by `/ByteRange`,
/// or as timestamps), not through the field tree.
pub fn has_signatures(doc: &Document) -> bool {
let signed = |d: &Dict| {
let contents = d.get(b"Contents").map(|c| doc.resolve(c));
contents.as_deref().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty())
// Hex strings decode to the DER when parsed; a literal string carries hex text.
let der = contents
.as_deref()
.and_then(Object::as_string)
.and_then(|s| if s.hex { Some(s.bytes.clone()) } else { super::validate::unhex(&s.bytes) });
der.is_some_and(|der| der.iter().any(|b| *b != 0))
};
super::validate::signature_contents(doc).iter().any(|der| !der.is_empty())
|| doc.scan_objects().any(|(_, o)| o.as_dict().is_some_and(|d| d.contains(b"ByteRange") && signed(d)))
sig_fields(doc).iter().any(|f| f.dict.get(b"V").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).is_some_and(signed))
|| doc
.scan_objects()
.any(|(_, o)| o.as_dict().is_some_and(|d| (d.contains(b"ByteRange") || super::validate::is_doc_timestamp(d)) && signed(d)))
}
/// Sign `doc` with `id`; returns the signed file (an incremental update when possible).
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -
list(doc, bytes, trust).into_iter().find(|s| s.field == field)
}
fn unhex(s: &[u8]) -> Option<Vec<u8>> {
pub(super) fn unhex(s: &[u8]) -> Option<Vec<u8>> {
let digits: Vec<u8> = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect();
let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect()
+30
View File
@@ -167,6 +167,36 @@ fn an_empty_signature_value_does_not_count_as_an_earlier_signature() {
assert!(form_of(&signed).get(b"NeedAppearances").is_none());
}
/// A signature dictionary whose /Contents is still the zero-filled placeholder (hex or literal
/// hex text, with a /ByteRange) is not signed either: the first real signature drops the flag.
#[test]
fn a_zero_filled_signature_placeholder_does_not_count_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
for contents in [&b"<0000000000000000>"[..], b"(00000000)"] {
let sig = [&b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents "[..], contents, b" >>"]
.concat();
let bytes = fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>",
&[b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Pending) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", &sig],
);
assert!(!pdfcraft_sign::has_signatures(&open(&bytes)), "{}", String::from_utf8_lossy(contents));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
assert!(form_of(&signed).get(b"NeedAppearances").is_none());
assert!(pdfcraft_sign::has_signatures(&open(&signed)));
}
}
/// A document timestamp outside the field tree counts by its /Contents alone, even without a
/// /ByteRange; a zero-filled one does not.
#[test]
fn a_document_timestamp_counts_unless_its_contents_are_zero_filled() {
for (contents, stamped) in [(&b"<3080>"[..], true), (b"<0000>", false)] {
let ts = [&b"<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 /Contents "[..], contents, b" >>"].concat();
let bytes = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[&ts]);
assert_eq!(pdfcraft_sign::has_signatures(&open(&bytes)), stamped, "{}", String::from_utf8_lossy(contents));
}
}
/// A signed field without a name (`/T` is optional) is an earlier signature too: signing another
/// field leaves `/NeedAppearances` as it is.
#[test]