nigig-org/crates/apps/pdf/pdf-document/tests/signatures.rs
andodeki e4a0c0a79e feat(pdf): read signatures, and fix a third ObjRef-destroying resolve
Phase 8 #6 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md
("Signatures"). Design and merge criteria in
REVIEWS/adr/0010-pdf-signatures.md.

Two defects, and the first is not about signatures at all.

1. acroform() dereferenced /AcroForm with self.resolve(), which recurses.
   That replaced every /Fields [5 0 R] entry with an inline dictionary,
   so AcroForm::walk saw node.as_ref() == None, decided the field had no
   identity to key an edit on, and dropped it. The form came back EMPTY
   rather than wrong, which is indistinguishable from a document with no
   fields, so nothing announced the loss.

   This is the third appearance of one mistake: page_annotations once
   destroyed every annotation's obj_ref the same way, and
   extract_xobjects resolved a reference then asked the resolved object
   for as_ref(), leaving every page's XObject map empty.

   It was invisible because both existing fixtures declare /AcroForm as
   an INDIRECT reference, where only one level is dereferenced and the
   refs survive. A direct /AcroForm dictionary - equally legal - hits it.
   The new fixture uses one deliberately; reverting the one-line fix
   fails 9 of the 12 new acceptance tests.

2. Nothing read signatures. FieldType::Signature was classified and then
   ignored; /ByteRange, /Contents, /SubFilter and /DocMDP appear nowhere
   in the codebase. A signed contract was presented exactly like an
   unsigned one.

New pdf-document/src/signature.rs reads the signature dictionary and
checks BYTE-RANGE INTEGRITY, which needs no cryptography and catches the
common real-world tampering: whether the signed range reaches the end of
the file. A signature that stops short leaves appended bytes uncovered,
which is exactly how an incremental-update attack hides content behind a
signature that still verifies.

Cryptographic verification is NOT implemented and cannot be faked:
VerificationStatus has no Valid variant. That is enforced by the type,
not by convention, because the failure mode for a signature feature is
not "it doesn't work" - it is a green tick beside a document nobody
checked. A test asserts the capability's absence so adding Valid without
the cryptography breaks the build rather than shipping a false tick.
Signing is out of scope entirely: no private keys in this crate.

Also verified ADR 0003's claim that an append-only save preserves a
signed byte range, byte for byte, rather than leaving it asserted.

Implementation bug worth recording: /Contents was initially hex-decoded,
but the COS lexer already decodes <...>. Running it twice on the common
128-zero-byte placeholder - which contains no hex digits - produced an
EMPTY vector, silently discarding the signature blob while every other
field looked right. Caught by asserting the blob is non-empty rather
than asserting the parse returned Ok.

6 corpus fixtures, 12 acceptance tests, 28 unit tests, and a
parse_signature fuzz target because /ByteRange is four
attacker-controlled integers used to index the file.

TEST_TARGET=pdf 497 -> 536 passing. rustfmt and clippy -D warnings clean.
2026-07-31 20:02:29 +00:00

313 lines
11 KiB
Rust

//! Signature acceptance tests.
//!
//! The merge criteria from `REVIEWS/adr/0010-pdf-signatures.md`.
//!
//! Two things are being guarded here, and the second is the more important:
//!
//! 1. Signatures are read and their byte-range coverage is checked.
//! 2. **Nothing ever claims a signature is cryptographically valid.** The
//! dangerous failure for this feature is not "it doesn't work" — it is a
//! green tick beside a document nobody verified.
use std::path::PathBuf;
use nigig_pdf_document::form::FieldType;
use nigig_pdf_document::signature::{
DocMdpPermission, SignatureError, SubFilter, VerificationStatus,
};
use nigig_pdf_document::PdfDocument;
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
/// The bug this fixture exists for: `acroform()` deep-resolved the
/// `/AcroForm` entry, which replaced every `/Fields [N 0 R]` reference with
/// an inline dictionary. `AcroForm::walk` then saw no object reference,
/// decided the field had no identity to key an edit on, and dropped it — so
/// the form came back **empty rather than wrong**, and nothing announced it.
///
/// `signatures/whole_file.pdf` declares `/AcroForm` as a direct dictionary,
/// which is what triggers it. Every pre-existing fixture uses an indirect
/// `/AcroForm`, where only one level is dereferenced and the refs survive,
/// so the old test suite could not have caught this.
#[test]
fn a_direct_acroform_dictionary_still_yields_its_fields() {
let bytes = corpus("signatures/whole_file.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let form = doc.acroform().expect("acroform").expect("present");
assert_eq!(
form.fields().len(),
1,
"a direct /AcroForm dictionary lost its fields to deep resolution"
);
let field = &form.fields()[0];
assert_eq!(field.field_type, FieldType::Signature);
assert_eq!(field.full_name, "Signature1");
assert_eq!(
field.obj_ref.num, 5,
"the field must keep the object reference an edit would key on"
);
}
#[test]
fn a_whole_file_signature_is_reported_as_covering_everything() {
let bytes = corpus("signatures/whole_file.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert!(report.is_signed());
assert_eq!(report.signatures.len(), 1);
assert!(report.any_covers_whole_file());
assert!(report.partially_covering().is_empty());
let sig = &report.signatures[0];
assert!(sig.covers_whole_file);
assert_eq!(
sig.coverage_end,
bytes.len(),
"coverage must reach the last byte"
);
assert_eq!(sig.sub_filter, Some(SubFilter::Pkcs7Detached));
assert_eq!(sig.filter.as_deref(), Some("Adobe.PPKLite"));
}
/// The attack case. Bytes appended after signing are not covered, and a
/// reader that reports the document as merely "signed" is misleading its
/// user.
#[test]
fn appended_bytes_are_reported_as_uncovered() {
let whole = corpus("signatures/whole_file.pdf");
let bytes = corpus("signatures/partial_coverage.pdf");
assert!(
bytes.len() > whole.len(),
"the fixture must actually have appended bytes"
);
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert!(report.is_signed(), "it is still a signed document");
assert!(
!report.any_covers_whole_file(),
"no signature covers the appended bytes, and that must be visible"
);
assert_eq!(report.partially_covering().len(), 1);
let sig = &report.signatures[0];
assert!(!sig.covers_whole_file);
assert!(
sig.coverage_end < bytes.len(),
"coverage ends at {} but the file is {} bytes",
sig.coverage_end,
bytes.len()
);
match &sig.status {
VerificationStatus::NotVerified { reason } => assert!(
reason.contains("does not cover"),
"the reason must name the coverage gap: {reason}"
),
other => panic!("expected a coverage warning, got {other:?}"),
}
}
#[test]
fn signer_metadata_survives_a_real_parse() {
let bytes = corpus("signatures/whole_file.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
let sig = &report.signatures[0];
assert_eq!(sig.name.as_deref(), Some("Test Signer"));
assert_eq!(sig.reason.as_deref(), Some("Approval"));
assert_eq!(sig.location.as_deref(), Some("Nairobi"));
assert_eq!(sig.signing_time.as_deref(), Some("D:20260731120000Z"));
assert!(
!sig.contents.is_empty(),
"the /Contents blob must be retained for a future verifier"
);
}
#[test]
fn a_certification_signature_reports_its_docmdp_permission() {
let bytes = corpus("signatures/certification.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert_eq!(report.doc_mdp(), Some(DocMdpPermission::NoChanges));
let sig = &report.signatures[0];
assert!(sig.is_certification());
assert!(
!sig.doc_mdp.expect("docmdp").allows_form_fill(),
"P=1 forbids even form filling"
);
}
#[test]
fn an_unknown_subfilter_is_named_rather_than_dropped() {
let bytes = corpus("signatures/unknown_subfilter.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert_eq!(
report.signatures[0].sub_filter,
Some(SubFilter::Unknown("acme.custom.signature".to_string())),
"an unrecognised handler must be reported by name"
);
}
#[test]
fn a_malformed_byte_range_is_a_typed_error_not_a_pass() {
let bytes = corpus("signatures/malformed_byte_range.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
let sig = &report.signatures[0];
assert!(!sig.covers_whole_file, "a bad range cannot cover anything");
assert!(
matches!(
sig.status,
VerificationStatus::ByteRangeInvalid(SignatureError::ByteRangeOutOfBounds { .. })
),
"expected an out-of-bounds range, got {:?}",
sig.status
);
assert!(
!report.any_covers_whole_file(),
"a document whose only signature has a broken range is not covered"
);
}
#[test]
fn an_unsigned_signature_field_is_not_a_signature() {
let bytes = corpus("signatures/unsigned_field.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert!(
!report.is_signed(),
"an empty signature placeholder must not count as a signature"
);
assert_eq!(report.unsigned_fields, vec!["Signature1".to_string()]);
}
/// The central guarantee of ADR 0010, asserted against real files.
///
/// No fixture, however well formed, may come back as verified. If someone
/// later adds a `Valid` variant without the cryptography behind it, this
/// fails.
#[test]
fn no_fixture_is_ever_reported_as_cryptographically_valid() {
for name in [
"whole_file.pdf",
"partial_coverage.pdf",
"certification.pdf",
"unknown_subfilter.pdf",
"malformed_byte_range.pdf",
] {
let bytes = corpus(&format!("signatures/{name}"));
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
for sig in &report.signatures {
match &sig.status {
VerificationStatus::NotVerified { .. }
| VerificationStatus::ByteRangeInvalid(_) => {}
// Unreachable today by construction; here so that adding a
// `Valid` variant without real verification breaks a test
// rather than shipping a false green tick.
#[allow(unreachable_patterns)]
other => panic!("{name} was reported as verified: {other:?}"),
}
}
}
}
/// An unsigned document must not acquire a signature report.
#[test]
fn an_unsigned_document_reports_no_signatures() {
let bytes = corpus("forms/all_types.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let report = doc.signatures().expect("signatures");
assert!(!report.is_signed());
assert!(report.signatures.is_empty());
assert!(report.unsigned_fields.is_empty());
assert_eq!(report.doc_mdp(), None);
}
/// ADR 0003 chose an append-only save specifically so a signed byte range
/// survives. That claim is worth testing rather than asserting.
#[test]
fn an_incremental_save_preserves_the_signed_byte_range() {
use nigig_pdf_document::annotation_edit::{AnnotationEditor, PageAnnotations};
use nigig_pdf_document::save::save_annotation_edits;
let bytes = corpus("signatures/whole_file.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let before = doc.signatures().expect("signatures");
let covered_before = before.signatures[0].covered_ranges();
assert!(before.any_covers_whole_file());
// Make an edit and save it incrementally.
let annots = doc.page_annotations(0).expect("annotations");
let trailer = doc.trailer().clone();
let page_ref = doc.page_object_ref(0).expect("page ref");
let page_dict = doc
.resolve_ref(page_ref)
.expect("page")
.as_dict()
.cloned()
.expect("dict");
let mut page = PageAnnotations::new(0, annots);
let target = page
.live()
.next()
.and_then(|a| a.annotation.obj_ref)
.expect("the signature widget");
AnnotationEditor::new(&mut page)
.move_to(target, 100.0, 500.0)
.expect("move");
let (out, _) =
save_annotation_edits(&bytes, &trailer, &page, page_ref, &page_dict).expect("save");
// The original bytes must still be a prefix, so the signed range is
// byte-for-byte intact.
assert!(out.starts_with(&bytes), "an incremental save must append");
for (start, end) in &covered_before {
assert_eq!(
&out[*start..*end],
&bytes[*start..*end],
"the signed range {start}..{end} changed across a save"
);
}
// And the signature must now report partial coverage, because the save
// appended bytes it does not cover. That is correct and must be visible.
let mut resaved = PdfDocument::parse(&out).expect("reparses");
let after = resaved.signatures().expect("signatures");
assert!(after.is_signed(), "the signature survived the save");
assert!(
!after.any_covers_whole_file(),
"bytes appended by the save are not covered, and that must be reported"
);
}
#[test]
fn every_signature_fixture_parses_without_panicking() {
for name in [
"whole_file.pdf",
"partial_coverage.pdf",
"certification.pdf",
"unknown_subfilter.pdf",
"malformed_byte_range.pdf",
"unsigned_field.pdf",
] {
let bytes = corpus(&format!("signatures/{name}"));
let mut doc = PdfDocument::parse(&bytes)
.unwrap_or_else(|e| panic!("signatures/{name} should parse: {e}"));
let _ = doc.signatures();
let _ = doc.acroform();
let _ = doc.page(0);
}
}