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.
313 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|