//! 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 { 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); } }