//! Signing and validating whole documents. use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures, }; fn data(name: &str) -> Vec { std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() } /// One page with text and an unsigned signature field "Approval". fn fixture() -> Vec { fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) } /// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { let mut objs: Vec<&[u8]> = vec![ catalog, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", ]; objs.extend_from_slice(more); write_pdf(&objs) } /// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog. fn write_pdf(objs: &[&[u8]]) -> Vec { 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", i + 1).as_bytes()); out.extend_from_slice(o); out.extend_from_slice(b"\nendobj\n"); } 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 open(b: &[u8]) -> Document { Document::open(Arc::new(b.to_vec())).unwrap() } #[test] fn standalone_timestamp_scan_survives_cache_batches_and_object_streams() { let mut doc = open(&fixture()); let filler: Vec<_> = (0..300).map(|i| Object::Ref(doc.add(Object::Int(i)))).collect(); let mut stamp = pdfcraft_cos::Dict::new(); stamp.set(b"Type".to_vec(), Object::name("Sig")); stamp.set(b"SubFilter".to_vec(), Object::name("ETSI.RFC3161")); stamp.set(b"ByteRange".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(2), Object::Int(1)])); stamp.set(b"Contents".to_vec(), Object::String(PdfString::literal("invalid-token"))); let stamp = doc.add(Object::Dict(stamp)); doc.update_dict(doc.root().unwrap(), |catalog| { catalog.set(b"UninspectedObjects".to_vec(), Object::Array(filler)); catalog.set(b"UninspectedTimestamp".to_vec(), Object::Ref(stamp)); }) .unwrap(); let bytes = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); let doc = open(&bytes); assert!(doc.object_numbers().len() > 300); let listed = signatures(&doc, &bytes, &TrustStore::default()); assert!(listed.iter().any(|s| s.doc_timestamp && s.status != Status::Valid)); assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed)); } fn opts() -> SignOptions { SignOptions { page: 0, rect: Some([20.0, 100.0, 220.0, 150.0]), reason: Some("I approve this document".into()), location: Some("London".into()), date: "D:20261002120000+01'00'".into(), ..SignOptions::default() } } #[test] fn signing_then_validating_with_and_without_trust() { for file in ["rsa-aes.p12", "ec-p256.p12", "ec-p384.p12", "chain.p12"] { let id = pkcs12::open(&data(file), "test").unwrap(); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); assert!(signed.starts_with(&fixture()), "{file}: an incremental update"); let doc = open(&signed); let sigs = signatures(&doc, &signed, &TrustStore::default()); let s = sigs.iter().find(|s| s.signed).expect("a signed field"); assert_eq!(s.field, "Signature1"); assert_eq!(s.status, Status::Unknown, "{file}: {:?}", s.details); assert_eq!(s.modification, Modification::None); assert_eq!(s.signer.as_deref(), id.certificate.subject.common_name()); assert_eq!((s.reason.as_deref(), s.location.as_deref(), s.page), (Some("I approve this document"), Some("London"), Some(0))); assert!(s.visible && s.signed_len == signed.len() && s.revision == 2); assert!(s.details.iter().any(|d| d.message.contains("identity is unknown"))); // The unsigned field is listed too. assert!(sigs.iter().any(|s| s.field == "Approval" && !s.signed)); // Trusting the signer (or its root) makes it valid. let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); let trusted = signatures(&doc, &signed, &TrustStore { certs: vec![anchor] }); let s = trusted.iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Valid, "{file}: {:?}", s.details); assert_eq!(s.chain.len(), if file == "chain.p12" { 2 } else { 1 }); // Changing one signed byte breaks it. let mut tampered = signed.clone(); let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap(); tampered[i] = b'K'; let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Invalid); assert!(s.details[0].message.contains("altered or corrupted"), "{:?}", s.details); } } #[test] fn signing_an_existing_field_and_counter_signing() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let first = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); let second = pdfcraft_sign::sign(&open(&first), &id2, &opts()).unwrap(); let doc = open(&second); let sigs = signatures(&doc, &second, &TrustStore::default()); let a = sigs.iter().find(|s| s.field == "Approval").unwrap(); assert!(a.signed); assert_eq!(a.rect, Some([150.0, 20.0, 280.0, 70.0])); assert_eq!(a.revision, 2); assert_eq!(a.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", a.details); assert_eq!(a.status, Status::Unknown); let b = sigs.iter().find(|s| s.field == "Signature1").unwrap(); assert_eq!((b.revision, b.modification.clone()), (3, Modification::None)); assert!(matches!( pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }), Err(pdfcraft_sign::SignError::Pdf(_)) )); } /// A page whose /Annots is an object of its own: signing and commenting update that array in /// place, not the page. A second signature is a signature change for the first, as with an /// inline /Annots, and a comment is still a comment (refused under a form-fill certification). #[test] fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 }; let unsigned = edit_after(&fixture(), |doc| { let annots = doc.add(Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef { num: 6, generation: 0 })])); doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Ref(annots))).unwrap(); }); let annots_ref = |bytes: &[u8]| open(bytes).get(page).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref)); let annots = annots_ref(&unsigned).unwrap(); let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); let trust = TrustStore { certs: vec![id.certificate.clone()] }; let first = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { rect: Some([20.0, 160.0, 220.0, 210.0]), ..opts() }).unwrap(); assert_eq!(annots_ref(&second), Some(annots), "the signer kept the page's array indirect"); let doc = open(&second); let sigs = signatures(&doc, &second, &trust); let s = sigs.iter().find(|s| s.field == "Signature1").unwrap(); assert_eq!(s.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", s.details); assert_eq!(s.status, Status::Valid); // A comment appended to the indirect array. let comment = |doc: &mut Document| { let mut d = pdfcraft_cos::Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); d.set(b"Subtype".to_vec(), Object::name("Text")); d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)])); let r = doc.add(Object::Dict(d)); let mut a = doc.get(annots).as_array().cloned().unwrap(); a.push(Object::Ref(r)); doc.set(annots, Object::Array(a)); }; let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); let s = check(&edit_after(&first, comment)); assert_eq!((s.status, s.modification), (Status::Valid, Modification::Allowed(vec![kind::COMMENTS]))); let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); let s = check(&edit_after(&certified, comment)); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); // Under that certification, a signed comment swapped for a new signature widget is still a // deleted comment, and a form field taken off the page is permitted by no level. let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); let swap = |drop: &dyn Fn(&Document, &Object) -> bool| { edit_after(&commented, |doc| { let mut w = pdfcraft_cos::Dict::new(); w.set(b"Type".to_vec(), Object::name("Annot")); w.set(b"Subtype".to_vec(), Object::name("Widget")); w.set(b"FT".to_vec(), Object::name("Sig")); w.set(b"T".to_vec(), PdfString::text("Later")); w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4])); let w = doc.add(Object::Dict(w)); let mut a = doc.get(annots).as_array().cloned().unwrap(); a.retain(|o| !drop(doc, o)); a.push(Object::Ref(w)); doc.set(annots, Object::Array(a)); }) }; let subtype = |doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t))); let s = check(&swap(&|doc, o| subtype(doc, o, b"Text"))); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6))); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER]))); } /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: /// it is listed as unsigned and can be signed by name. #[test] fn a_field_with_a_null_value_is_unsigned_and_can_be_signed() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 }; let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Null)).unwrap()); let indirect = edit_after(&fixture(), |doc| { let null = doc.add(Object::Null); doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(null))).unwrap(); }); for (what, unsigned) in [("direct null", direct), ("indirect null", indirect)] { let doc = open(&unsigned); assert!(doc.get(field).as_dict().is_some_and(|d| d.contains(b"V")), "{what}: the fixture keeps its /V"); let listed = signatures(&doc, &unsigned, &TrustStore::default()); assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{what}"); let signed = pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap_or_else(|e| panic!("{what}: {e:?}")); let listed = signatures(&open(&signed), &signed, &TrustStore::default()); assert!(listed.iter().any(|s| s.field == "Approval" && s.signed), "{what}"); assert!(!listed.iter().any(|s| s.field == "Signature1"), "{what}: no new field"); } } /// A signature field whose widget is hidden (annotation flag Hidden, 2) or not shown on screen /// (NoView, 32) is listed as not visible, whatever its rectangle, as form widgets are: a form's /// requestor box with flags 96 (NoView and ReadOnly) has no box on the page to show. A widget /// that is only printable (4) stays visible. #[test] fn a_hidden_signature_field_is_listed_as_not_visible() { let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 }; let visible_with = |flags: i64| { let bytes = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"F".to_vec(), Object::Int(flags))).unwrap()); let listed = signatures(&open(&bytes), &bytes, &TrustStore::default()); listed.iter().find(|s| s.field == "Approval").map(|s| s.visible).expect("listed") }; assert!(visible_with(4), "printable only: shown"); for flags in [2, 32, 96, 34] { assert!(!visible_with(flags), "flags {flags}: not shown"); } } /// Follow-up to #1374: a signature field whose /V is neither null nor a signature dictionary is /// refused by name with an error saying so, instead of being overwritten or called signed. #[test] fn a_field_whose_value_is_not_a_signature_is_refused_by_name() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 }; let values = [ ("empty string", Object::String(PdfString::literal(""))), ("number", Object::Int(5)), ("name", Object::name("Off")), ("array", Object::Array(Vec::new())), ]; for (what, value) in values { let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), value.clone())).unwrap()); let indirect = edit_after(&fixture(), |doc| { let v = doc.add(value.clone()); doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(v))).unwrap(); }); for (how, bytes) in [("direct", direct), ("indirect", indirect)] { let doc = open(&bytes); let listed = signatures(&doc, &bytes, &TrustStore::default()); assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{how} {what}"); match pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }) { Err(SignError::Pdf(e)) => assert_eq!(e, "the value of the field \"Approval\" is not a signature", "{how} {what}"), other => panic!("{how} {what}: {:?}", other.map(|b| b.len())), } } } } /// Append a revision that changes object `num` with `f`. fn edit_after(signed: &[u8], f: impl FnOnce(&mut Document)) -> Vec { let mut doc = open(signed); f(&mut doc); write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() } fn add_comment(doc: &mut Document) { let mut d = pdfcraft_cos::Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); d.set(b"Subtype".to_vec(), Object::name("Text")); d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)])); d.set(b"Contents".to_vec(), PdfString::text("A note")); let r = doc.add(Object::Dict(d)); let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 }; doc.update_dict(page, |p| { if let Some(Object::Array(a)) = p.get_mut(b"Annots") { a.push(Object::Ref(r)); } }) .unwrap(); } fn change_text(doc: &mut Document) { let mut d = pdfcraft_cos::Dict::new(); d.set(b"Length".to_vec(), Object::Int(40)); let s = pdfcraft_cos::Stream::from_raw(d, b"BT /F1 14 Tf 20 250 Td (Other text) Tj ET".to_vec()); doc.set(pdfcraft_cos::ObjRef { num: 4, generation: 0 }, Object::Stream(s)); } #[test] fn later_changes_are_classified_under_the_signature_permissions() { let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); let trust = TrustStore { certs: vec![id.certificate.clone()] }; let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); // Approval signature: comments are permitted, rewriting page content is not. let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let s = check(&edit_after(&signed, add_comment)); assert_eq!(s.status, Status::Valid, "{:?}", s.details); assert_eq!(s.modification, Modification::Allowed(vec![kind::COMMENTS])); let s = check(&edit_after(&signed, change_text)); assert_eq!(s.status, Status::Invalid); assert_eq!(s.modification, Modification::Disallowed(vec![kind::PAGE_CONTENT])); // Certified with "no changes allowed": even a comment invalidates it. let certified = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap(); let s = check(&certified); assert_eq!((s.certify, s.status), (Some(1), Status::Valid)); assert_eq!(check(&edit_after(&certified, add_comment)).status, Status::Invalid); // Certified allowing comments: fine. let certified3 = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap(); assert_eq!(check(&edit_after(&certified3, add_comment)).status, Status::Valid); // #1788: a later revision relaxing the signed P 1 to P 3 does not permit the comment. let relaxed = edit_after(&certified, |doc| { let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); let sig = doc.resolve(catalog.get(b"Perms").unwrap()).as_dict().and_then(|p| p.get(b"DocMDP").and_then(Object::as_ref)).unwrap(); doc.update_dict(sig, |v| { if let Some(Object::Array(r)) = v.get_mut(b"Reference") && let Some(Object::Dict(sr)) = r.first_mut() && let Some(Object::Dict(tp)) = sr.get_mut(b"TransformParams") { tp.set(b"P".to_vec(), Object::Int(3)); } }) .unwrap(); add_comment(doc); }); let s = check(&relaxed); assert_eq!((s.certify, s.status), (Some(1), Status::Invalid), "{:?}", s.modification); } #[test] fn invisible_signatures_and_refusals() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { rect: None, ..opts() }).unwrap(); let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert!(!s.visible); assert!(pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { page: 5, ..opts() }).is_err()); assert_eq!(pdfcraft_sign::pdf::display_date("D:20261002120000+01'00'"), "2026.10.02 12:00:00 +01'00'"); } /// The normal appearance of the signature field `name`: its decoded content and resources. fn appearance_of(bytes: &[u8], name: &str) -> (String, pdfcraft_cos::Dict) { appearance_in(&open(bytes), name) } fn appearance_in(doc: &Document, name: &str) -> (String, pdfcraft_cos::Dict) { let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); let form = doc.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); let fields = doc.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); let field = fields .iter() .filter_map(|f| doc.resolve(f).as_dict().cloned()) .find(|f| matches!(f.get(b"T"), Some(Object::String(s)) if s.bytes == name.as_bytes())) .unwrap(); let ap = doc.resolve(field.get(b"AP").unwrap()).as_dict().cloned().unwrap(); let Object::Stream(s) = &*doc.resolve(ap.get(b"N").unwrap()) else { panic!("no normal appearance") }; let res = doc.resolve(s.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); (String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), res) } /// A 40 x 10 image, opaque or with one translucent pixel. fn picture(translucent: bool) -> pdfcraft_sign::Graphic { let mut rgba: Vec = (0..400).flat_map(|_| [10, 20, 30, 255]).collect(); if translucent { rgba[3] = 128; } pdfcraft_sign::Graphic::Image { width: 40, height: 10, rgba: Arc::new(rgba) } } #[test] fn visible_signatures_show_a_drawn_typed_or_image_graphic() { use pdfcraft_sign::{Appearance, Graphic}; let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); let name = format!("({})", id.certificate.display_name()); let plain = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); // Without a graphic the name is large on the left and in the details. assert_eq!(appearance_of(&plain, "Signature1").0.matches(&name).count(), 2); let strokes = Graphic::Strokes(vec![vec![[0.0, 0.0], [0.3, 0.2], [0.6, 0.0], [1.0, 0.2]], vec![[0.5, 0.1]]]); let typed = Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]], vec![[0.2, 0.1], [0.8, 0.1], [0.5, 0.4]]]); for (graphic, drawn) in [ (strokes, " c\n"), (typed, "f\n"), // 40 x 10 in the left half (94 x 44 inside the padding): 94 wide, centred vertically. (picture(true), "q 94 0 0 23.5 3 13.25 cm /Im1 Do Q"), ] { let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(graphic.clone()), ..opts() }).unwrap(); let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert_eq!((s.status, s.modification), (Status::Unknown, Modification::None), "{graphic:?}: {:?}", s.details); let (content, res) = appearance_of(&signed, "Signature1"); assert!(content.contains(drawn), "{graphic:?}: {content}"); // The graphic takes the name's place on the left; the details still name the signer. assert_eq!(content.matches(&name).count(), 1, "{content}"); assert_eq!(res.get(b"XObject").is_some(), matches!(graphic, Graphic::Image { .. })); } // Translucent images keep their alpha as a soft mask; opaque ones need none. for translucent in [true, false] { let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(picture(translucent)), ..opts() }).unwrap(); let doc = open(&signed); let (_, res) = appearance_of(&signed, "Signature1"); let xobjects = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); let Object::Stream(im) = &*doc.resolve(xobjects.get(b"Im1").unwrap()) else { panic!() }; assert_eq!((im.dict.int(b"Width"), im.dict.int(b"Height")), (Some(40), Some(10))); assert_eq!(im.decoded().unwrap().len(), 40 * 10 * 3); assert_eq!(im.dict.get(b"SMask").is_some(), translucent); } // With no text at all, the graphic has the whole box (194 x 44): 176 x 44, centred. let bare = Appearance { name: false, date: false, reason: false, location: false, distinguished_name: false, labels: false }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(picture(false)), appearance: bare, ..opts() }).unwrap(); assert!(appearance_of(&signed, "Signature1").0.contains("q 176 0 0 44 12 3 cm /Im1 Do Q")); // An invisible signature has nothing to show: no image is embedded. let invisible = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { rect: None, graphic: Some(picture(false)), ..opts() }).unwrap(); assert!(!invisible.windows(6).any(|w| w == b"/Image")); } /// [`fixture`]'s page and "Approval" widget with extra entries: `page` (a `/Rotate`, say) and /// `widget` (an `/MK`); `pages` goes on the page tree node, where `/Rotate` is inheritable. fn rotated_fixture(pages: &str, page: &str, widget: &str) -> Vec { let pages = format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"); let page = format!( "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] {page} >>" ); let widget = format!("<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 {widget} >>"); write_pdf(&[ b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", pages.as_bytes(), page.as_bytes(), b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", widget.as_bytes(), ]) } /// The signature field `name`'s widget and its normal appearance's dictionary and content. fn widget_and_appearance(bytes: &[u8], name: &str) -> (pdfcraft_cos::Dict, pdfcraft_cos::Dict, String) { let doc = open(bytes); let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); let form = doc.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); let fields = doc.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); let widget = fields .iter() .filter_map(|f| doc.resolve(f).as_dict().cloned()) .find(|f| matches!(f.get(b"T"), Some(Object::String(s)) if s.bytes == name.as_bytes())) .unwrap(); let ap = doc.resolve(widget.get(b"AP").unwrap()).as_dict().cloned().unwrap(); let Object::Stream(s) = &*doc.resolve(ap.get(b"N").unwrap()) else { panic!("no normal appearance") }; (widget.clone(), s.dict.clone(), String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) } fn numbers(d: &pdfcraft_cos::Dict, key: &[u8]) -> Option> { d.get(key).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|n| n.as_f64()).collect()) } /// On a page shown turned, a visible signature reads upright as displayed: its appearance is the /// one an unturned page gets in the box with the displayed proportions, turned back onto the /// widget by `/MK /R` and the matching `/Matrix`. #[test] fn visible_signatures_read_upright_on_rotated_pages() { use pdfcraft_sign::Appearance; let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); // 200 x 50 in user space: shown 50 wide and 200 tall on a page turned a quarter. let rect = [20.0, 100.0, 220.0, 150.0]; let swapped = [20.0, 100.0, 70.0, 300.0]; let shown = Appearance { reason: true, location: true, ..Appearance::default() }; let sign = |bytes: &[u8], rect: [f64; 4]| { let o = SignOptions { rect: Some(rect), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() }; pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap() }; let upright = |rect: [f64; 4]| widget_and_appearance(&sign(&fixture(), rect), "Signature1"); for (pages, page, turn) in [ ("", "", 0), ("", "/Rotate 90", 90), ("", "/Rotate 180", 180), ("", "/Rotate 270", 270), ("", "/Rotate -90", 270), // Inherited from the page tree. ("/Rotate 270", "", 270), // Not a quarter turn: drawn as unturned, as the page is. ("", "/Rotate 45", 0), ] { let doc = rotated_fixture(pages, page, ""); let signed = sign(&doc, rect); let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert_eq!((s.status, s.modification), (Status::Unknown, Modification::None), "{pages} {page}: {:?}", s.details); let (widget, form, content) = widget_and_appearance(&signed, "Signature1"); let (_, expected_form, expected) = upright(if turn % 180 == 0 { rect } else { swapped }); assert_eq!(content, expected, "{pages} {page}: the layout is the upright box's"); assert_eq!(numbers(&form, b"BBox"), numbers(&expected_form, b"BBox"), "{pages} {page}"); let mk = widget.get(b"MK").and_then(|m| m.as_dict()).and_then(|m| m.int(b"R")); let matrix = numbers(&form, b"Matrix"); if turn == 0 { assert_eq!((mk, matrix), (None, None), "{pages} {page}: an unturned page's signature is unchanged"); } else { assert_eq!(mk, Some(turn), "{pages} {page}"); assert_eq!(matrix, Some(pdfcraft_model::view_matrix_for(turn, rect).to_vec()), "{pages} {page}"); } } // An existing field keeps its own orientation: `/MK /R` turns it, whatever the page does. let field = |bytes: &[u8]| { let o = SignOptions { field: Some("Approval".into()), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() }; widget_and_appearance(&pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap(), "Approval") }; let field_rect = [150.0, 20.0, 280.0, 70.0]; let (_, plain, plain_content) = field(&rotated_fixture("", "/Rotate 90", "")); assert_eq!((numbers(&plain, b"Matrix"), numbers(&plain, b"BBox")), (None, Some(vec![0.0, 0.0, 130.0, 50.0]))); let (_, turned, turned_content) = field(&rotated_fixture("", "/Rotate 90", "/MK << /R 90 >>")); assert_eq!(numbers(&turned, b"BBox"), Some(vec![0.0, 0.0, 50.0, 130.0])); assert_eq!(numbers(&turned, b"Matrix"), Some(pdfcraft_model::view_matrix_for(90, field_rect).to_vec())); assert_ne!(turned_content, plain_content); // A malformed `/MK /R` counts as 0. let (_, odd, _) = field(&rotated_fixture("", "", "/MK << /R (ninety) >>")); assert_eq!(numbers(&odd, b"Matrix"), None); } #[test] fn malformed_signature_graphics_are_refused_not_drawn() { use pdfcraft_sign::Graphic; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let huge = vec![vec![[0.0, 0.0]; 300_001]]; for graphic in [ Graphic::Strokes(vec![]), Graphic::Strokes(vec![vec![]]), Graphic::Strokes(vec![vec![[0.0, 0.0], [f64::NAN, 1.0]]]), Graphic::Strokes(vec![vec![[-1e308, 0.0], [1e308, 1.0]]]), Graphic::Strokes(huge), // Outlines need three points to enclose anything. Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 1.0]]]), Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, f64::INFINITY], [1.0, 0.0]]]), Graphic::Image { width: 40, height: 10, rgba: Arc::new(vec![0; 399]) }, Graphic::Image { width: 0, height: 10, rgba: Arc::new(vec![]) }, Graphic::Image { width: u32::MAX, height: u32::MAX, rgba: Arc::new(vec![]) }, ] { let what = format!("{graphic:?}"); assert!(what.len() < 200, "Debug never dumps pixels or points: {what}"); let r = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(graphic), ..opts() }); assert!(matches!(r, Err(SignError::Pdf(ref m)) if m.contains("signature graphic")), "{what}: {r:?}"); } // A single dot is a (tiny) drawing, not an error. let dot = Graphic::Strokes(vec![vec![[0.5, 0.5]]]); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { graphic: Some(dot), ..opts() }).unwrap(); let (content, _) = appearance_of(&signed, "Signature1"); assert!(!content.contains("NaN") && !content.contains("inf"), "{content}"); } /// A deterministic TSA: signs an RFC 3161 response locally with a test digital ID at a fixed /// time. No sockets; the whole stamping path runs in-process. struct TestTsa { id: pkcs12::DigitalId, time: Time, } /// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for /// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. fn tsa_id() -> DigitalId { tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) } /// [`tsa_id`] with these extended key usages. fn tsa_id_with(ekus: &[&str]) -> DigitalId { let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); let alg = key.preferred_digest(); let sig_alg = key.signature_algorithm(alg); let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; let until = Time { year: 2036, ..from }; let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); let tbs = der::seq(&[ &der::explicit(0, &der::int(2)), &der::uint(&[0x31]), &sig_alg, &name.raw, &der::seq(&[&from.encode(), &until.encode()]), &name.raw, &key.public_key().spki(), &der::explicit(3, &der::seq(&[&eku])), ]); let signature = key.sign(alg, &tbs).unwrap(); let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } } impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; pdfcraft_sign::timestamp::respond( pdfcraft_sign::timestamp::TimestampSigner { key: &self.id.key, certificate: &self.id.certificate, chain: &self.id.chain, digest: pdfcraft_sign::DigestAlg::Sha256, }, pdfcraft_sign::timestamp::TimestampIssuance { query: &q, policy: "1.2.3.4", generation_time: self.time, serial: 7 }, ) } } #[test] fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); assert!(signed.starts_with(&fixture()), "still an incremental update"); let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); let trust = TrustStore { certs: vec![anchor.clone(), tsa.id.certificate.clone()] }; let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Valid, "{:?}", s.details); assert!(s.timestamp, "{:?}", s.details); assert_eq!(s.timestamp_time, Some(tsa.time), "the token's generation time, verified over the signature value"); assert!(s.details.iter().any(|d| d.message.contains("trusted time")), "{:?}", s.details); // A trusted signer with an untrusted TSA: still valid, but the token's time is unverified. let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Valid, "{:?}", s.details); assert!(s.timestamp); assert_eq!(s.timestamp_time, None); assert!(s.details.iter().any(|d| d.message.contains("unverified timestamp")), "{:?}", s.details); // Untrusted, the signature stays intact-but-unknown; the timestamp is still noted. let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Unknown); assert!(s.timestamp && s.timestamp_time.is_none()); } #[test] fn a_malformed_timestamp_response_fails_signing_without_a_file() { struct Bad; impl TimestampAuthority for Bad { fn timestamp(&self, _request: &[u8]) -> Result, SignError> { Ok(b"not der".to_vec()) } } let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); assert!(pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &Bad).is_err()); } #[test] fn a_document_timestamp_covers_the_file_and_validates() { let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); assert!(stamped.starts_with(&fixture()), "an incremental update"); let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let s = signatures(&open(&stamped), &stamped, &trust_tsa).into_iter().find(|s| s.doc_timestamp).unwrap(); assert!(s.doc_timestamp && s.timestamp); assert_eq!(s.sub_filter.as_deref(), Some("ETSI.RFC3161")); assert_eq!(s.timestamp_time, Some(tsa.time)); assert_eq!(s.status, Status::Valid, "{:?}", s.details); assert_eq!(s.modification, Modification::None); assert_eq!(s.signed_len, stamped.len()); // A later byte change breaks the timestamp's imprint. let mut tampered = stamped.clone(); let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap(); tampered[i] = b'K'; let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Invalid); // Combined with a field signature: both are listed, the stamp covers both revisions. let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let both = pdfcraft_sign::timestamp_document(&open(&signed), &tsa, "D:20261006130000Z").unwrap(); let all = signatures(&open(&both), &both, &TrustStore { certs: vec![tsa.id.certificate.clone()] }); let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap(); let allowed = match &field.modification { Modification::Allowed(k) => k, other => panic!("{other:?}"), }; assert!(allowed.contains(&kind::SIGNATURE), "{allowed:?}"); let stamp = all.iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details); } #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let mut source = open(&fixture()); source .update_dict(source.root().unwrap(), |d| { d.remove(b"AcroForm"); }) .unwrap(); let bytes = pdfcraft_sign::timestamp_document(&source, &tsa, "D:20261006120000Z").unwrap(); let doc = open(&bytes); let cache = pdfcraft_sign::DigestCache::default(); let trusted = TrustStore { certs: vec![tsa.id.certificate.clone()] }; let check = |doc: &Document, bytes: &[u8], trust: &TrustStore| { let listed = pdfcraft_sign::pdf::list_cached(doc, bytes, trust, &cache); assert_eq!(listed.len(), 1); assert!(listed[0].doc_timestamp); listed.into_iter().next().unwrap() }; assert_eq!(check(&doc, &bytes, &TrustStore::default()).status, Status::Unknown); assert_eq!(check(&doc, &bytes, &trusted).status, Status::Valid); assert_eq!(check(&doc, &bytes, &TrustStore::default()).status, Status::Unknown); let mut tampered = bytes.clone(); let at = tampered.windows(13).position(|w| w == b"Contract text").unwrap(); tampered[at] = b'K'; assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid); assert_eq!(check(&doc, &[], &trusted).status, Status::Invalid); let stamp = doc.scan_objects().find(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"SubFilter") == Some(b"ETSI.RFC3161"))).unwrap().0; let mut edited = doc.clone(); edited.update_dict(stamp, |d| d.set(b"ByteRange".to_vec(), Object::Array(vec![Object::Int(-1)]))).unwrap(); assert_eq!(check(&edited, &bytes, &trusted).status, Status::Invalid); assert_eq!(check(&doc, &bytes, &trusted).status, Status::Valid); // Even a malformed token in the same byte ranges must be read again after discovery. let br = doc.get(stamp).as_dict().unwrap().get(b"ByteRange").unwrap().as_array().unwrap().clone(); let gap_start = br[1].as_int().unwrap() as usize; tampered = bytes.clone(); tampered[gap_start + 1] = b'F'; tampered[gap_start + 2] = b'F'; assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid); } #[test] fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() { let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); let trust = TrustStore { certs: vec![id.certificate.clone()] }; let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); let ext = |base: &[u8], level: i64| { let mut esic = pdfcraft_cos::Dict::new(); esic.set(b"BaseVersion".to_vec(), Object::name("1.7")); esic.set(b"ExtensionLevel".to_vec(), Object::Int(level)); let mut e = pdfcraft_cos::Dict::new(); e.set(b"ESIC".to_vec(), Object::Dict(esic)); edit_after(base, |doc| { let root = doc.root().unwrap(); doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap(); }) }; // As PAdES LTV writers save it: the store and the ESIC declaration in one revision. let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap(); let s = check(<v); assert_eq!(s.status, Status::Valid, "{:?}", s.details); assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification); // A declaration that was signed may not change afterwards. let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap(); let s = check(&ext(&signed_with, 3)); assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE]))); } #[test] fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let evidence = Evidence { certs: vec![id.certificate.raw.clone()], ocsps: vec![b"synthetic ocsp".to_vec()], crls: vec![b"synthetic crl".to_vec()] }; let ltv = dss::embed(&open(&signed), &evidence).unwrap(); assert!(ltv.starts_with(&signed), "incremental"); let doc = open(<v); // The store is in the catalog with one certificate and the /VRI entry for the signature. let root = doc.root().unwrap(); let dss_dict: pdfcraft_cos::Dict = doc.get(root).as_dict().unwrap().get(b"DSS").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap(); assert_eq!(dss_dict.name(b"Type"), Some(b"DSS".as_slice())); assert!(dss_dict.contains(b"Certs") && dss_dict.contains(b"OCSPs") && dss_dict.contains(b"CRLs") && dss_dict.contains(b"VRI")); // The earlier signature still validates; the DSS counts as a permitted change. let s = signatures(&doc, <v, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Unknown); let allowed = match &s.modification { Modification::Allowed(k) => k, other => panic!("{other:?}"), }; assert!(allowed.contains(&kind::SECURITY_STORE), "{allowed:?}"); // Embedding twice keeps one certificate (byte-identical dedup) and stays valid. let twice = dss::embed(&doc, &evidence).unwrap(); let doc2 = open(&twice); let dss2: pdfcraft_cos::Dict = doc2.get(doc2.root().unwrap()).as_dict().unwrap().get(b"DSS").map(|d| doc2.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap(); let certs = dss2.get(b"Certs").map(|c| doc2.resolve(c)).and_then(|c| c.as_array().cloned()).unwrap(); assert_eq!(certs.len(), 1, "byte-identical evidence is not duplicated"); } #[test] fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap(); let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap(); let all = signatures(&open(<a), <a, &TrustStore { certs: vec![tsa.id.certificate.clone()] }); assert_eq!(all.iter().filter(|s| s.signed).count(), 2); let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap(); assert_eq!(field.status, Status::Unknown); let allowed = match &field.modification { Modification::Allowed(k) => k, other => panic!("{other:?}"), }; assert!(allowed.contains(&kind::SECURITY_STORE) && allowed.contains(&kind::SIGNATURE), "{allowed:?}"); assert!(!allowed.contains(&kind::PAGE_CONTENT), "{allowed:?}"); let stamp = all.iter().find(|s| s.doc_timestamp).unwrap(); assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details); } /// A redaction mark and a reply to it, added to the page and removed again before signing (what /// clearing a mark leaves behind), are in none of the signing outputs: a signature, a timestamped /// one, embedded revocation evidence or a document timestamp. Nothing can take them out of a /// signed revision afterwards. Each output still validates. #[test] fn what_was_removed_before_signing_is_not_signed() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] }; let text = |s: &str| Object::String(PdfString::literal(s.as_bytes().to_vec())); let dict = |entries: &[(&str, Object)]| { let mut d = Dict::new(); entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); Object::Dict(d) }; let cleared = |bytes: &[u8]| { let mut doc = open(bytes); let page = ObjRef::new(3, 0); let annots = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()).unwrap(); let mark = doc.add(dict(&[ ("Type", Object::name("Annot")), ("Subtype", Object::name("Redact")), ("Rect", Object::Array([80, 140, 100, 180].map(Object::Int).to_vec())), ("OverlayText", text("MARKED OVERLAY")), ("T", text("Mark Author")), ("P", Object::Ref(page)), ])); let reply = doc.add(dict(&[ ("Type", Object::name("Annot")), ("Subtype", Object::name("Text")), ("IRT", Object::Ref(mark)), ("Contents", text("REPLY TO THE MARK")), ("T", text("Replier")), ])); let mut with = annots.as_array().cloned().unwrap(); with.extend([Object::Ref(mark), Object::Ref(reply)]); doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(with))).unwrap(); doc.update_dict(page, |d| d.set(b"Annots".to_vec(), annots.clone())).unwrap(); doc }; let unsigned = fixture(); let signed = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); let evidence = Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; let outputs = [ ("sign", &unsigned, pdfcraft_sign::sign(&cleared(&unsigned), &id, &opts()).unwrap()), ("sign_with_timestamp", &unsigned, pdfcraft_sign::sign_with_timestamp(&cleared(&unsigned), &id, &opts(), &tsa).unwrap()), ("dss::embed", &signed, dss::embed(&cleared(&signed), &evidence).unwrap()), ("timestamp_document", &unsigned, pdfcraft_sign::timestamp_document(&cleared(&unsigned), &tsa, "D:20261006120000Z").unwrap()), ]; for (what, input, out) in outputs { assert!(out.starts_with(input), "{what}: an incremental update"); let holds = |s: &str| out.windows(s.len()).any(|w| w == s.as_bytes()); for trace in ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"] { assert!(!holds(trace), "{what} wrote {trace:?}"); } let doc = open(&out); assert!(doc.repair_log().is_empty(), "{what}: {:?}", doc.repair_log()); let all: Vec<_> = signatures(&doc, &out, &trust).into_iter().filter(|s| s.signed).collect(); assert_eq!(all.iter().filter(|s| s.doc_timestamp).count(), usize::from(what == "timestamp_document"), "{what}"); assert!(!all.is_empty() && all.iter().all(|s| s.status == Status::Valid), "{what}: {all:?}"); } } #[test] fn an_embedded_verified_revocation_invalidates_the_signature() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); // The signer's own CRL (self-signed test identity) revokes its certificate, in a window // that covers the signing date (October 2026). let (this, next) = (Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }); let alg = id.key.signature_algorithm(pdfcraft_sign::DigestAlg::Sha256); let tbs = der::seq(&[ &der::int(1), &alg, &id.certificate.subject.raw, &this.encode(), &next.encode(), &der::seq(&[&der::seq(&[ &der::uint(&id.certificate.serial), &Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 }.encode(), ])]), ]); let sig = id.key.sign(pdfcraft_sign::DigestAlg::Sha256, &tbs).unwrap(); let crl = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]); let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap(); let s = signatures(&open(<v), <v, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap(); assert_eq!(s.status, Status::Invalid, "{:?}", s.details); assert!(s.details.iter().any(|d| d.message.contains("revoked") && d.message.contains("CRL")), "{:?}", s.details); } #[test] fn validates_a_signature_made_by_openssl() { let bytes = data("openssl-signed.pdf"); let doc = open(&bytes); let s = signatures(&doc, &bytes, &TrustStore::default()).into_iter().next().unwrap(); assert_eq!((s.field.as_str(), s.sub_filter.as_deref()), ("OpenSSL", Some("adbe.pkcs7.detached"))); assert_eq!(s.status, Status::Unknown, "{:?}", s.details); assert_eq!(s.signer.as_deref(), Some("Test Signer RSA")); assert!(s.signing_time.is_some_and(|t| t.year == 2026), "from the CMS signing-time attribute"); assert!(!s.visible); let rsa = pdfcraft_sign::Certificate::parse(&pkcs12::open(&data("rsa-aes.p12"), "test").unwrap().certificate.raw).unwrap(); let s = signatures(&doc, &bytes, &TrustStore { certs: vec![rsa] }).into_iter().next().unwrap(); assert_eq!(s.status, Status::Valid, "{:?}", s.details); // A later comment is allowed for this approval signature. let edited = edit_after(&bytes, add_comment); let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().next().unwrap(); assert_eq!(s.modification, Modification::Allowed(vec![kind::COMMENTS])); } #[test] fn files_without_a_cross_reference_table_are_signed_with_a_full_write() { // No xref: the document is reconstructed, so saving rewrites (and renumbers) everything. let bytes = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] >> endobj trailer << /Root 1 0 R >> %%EOF" .to_vec(); let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); for certify in [None, Some(2)] { let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { certify, rect: None, ..opts() }).unwrap(); let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); assert_eq!((s.status, s.certify, s.modification.clone()), (Status::Unknown, certify, Modification::None), "{:?}", s.details); } } /// Held by every test that changes the user's keychain search list. Each saves the list, changes /// it and restores what it saved, so two at once could undo each other's temporary keychain or /// put back one already deleted. #[cfg(target_os = "macos")] static KEYCHAIN_SEARCH_LIST: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Signing with Keychain identities, in a throwaway keychain file. Ignored by default: creating /// a keychain touches the user's keychain search list (restored afterwards). #[cfg(target_os = "macos")] #[test] #[ignore = "creates a temporary macOS keychain; run with --ignored"] fn signing_with_keychain_identities() { // A test that failed while holding it leaves the list restored, so a poisoned lock is fine. let _list = KEYCHAIN_SEARCH_LIST.lock().unwrap_or_else(std::sync::PoisonError::into_inner); use std::process::Command; let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let kc = dir.join("test.keychain-db"); let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap(); let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap(); let saved: Vec = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect(); let k = kc.to_str().unwrap(); assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success()); let restore = || { let mut args = vec!["list-keychains", "-d", "user", "-s"]; args.extend(saved.iter().map(String::as_str)); sec(&args) }; restore(); // Everything that can fail runs before the clean-up below. let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sec(&["unlock-keychain", "-p", "pc-test", k]); // macOS imports only legacy-format PKCS #12 (SHA-1 MAC). for f in ["rsa-legacy.p12", "ec-legacy.p12"] { let p = format!("{}/tests/data/{f}", env!("CARGO_MANIFEST_DIR")); let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]); assert!(out.status.success(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); } let ids = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap(); assert_eq!(ids.len(), 2, "{ids:?}"); for id in &ids { assert!(id.key.is_external()); let signed = pdfcraft_sign::sign(&open(&fixture()), id, &opts()).unwrap(); let trusted = signatures(&open(&signed), &signed, &TrustStore { certs: vec![id.certificate.clone()] }); let s = trusted.iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Valid, "{:?}", s.details); } })); // The list first: it is what outlives the test if deleting the keychain goes wrong. let restored = restore(); sec(&["delete-keychain", k]); let _ = std::fs::remove_dir_all(&dir); assert!(restored.status.success(), "the keychain search list wasn't restored: {}", String::from_utf8_lossy(&restored.stderr)); result.unwrap(); } /// A Keychain identity carries the certificates macOS chains it to, as a `.p12`'s does, so the /// signature embeds its issuers. Ignored by default: the throwaway keychain joins the user's /// keychain search list while the test runs (restored afterwards). #[cfg(target_os = "macos")] #[test] #[ignore = "creates a temporary macOS keychain; run with --ignored"] fn keychain_identities_carry_their_issuers() { // A test that failed while holding it leaves the list restored, so a poisoned lock is fine. let _list = KEYCHAIN_SEARCH_LIST.lock().unwrap_or_else(std::sync::PoisonError::into_inner); use std::process::Command; // The same path every run: trustd remembers which keychain file it found an issuer in, and // after a run's keychain is deleted it keeps looking there, so a keychain at a new path // would get an empty chain until that memory expires. let dir = std::env::temp_dir().join("pdfcraft-keychain-chain"); let kc = dir.join("test.keychain-db"); let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap(); let k = kc.to_str().unwrap(); // Left over by a run that was killed before its clean-up. sec(&["delete-keychain", k]); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap(); let saved: Vec = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect(); assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success()); let set_list = |extra: Option<&str>| { let mut args = vec!["list-keychains", "-d", "user", "-s"]; args.extend(saved.iter().map(String::as_str)); args.extend(extra); sec(&args) }; // macOS finds an identity's issuers in the keychains on the search list. set_list(Some(k)); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sec(&["unlock-keychain", "-p", "pc-test", k]); let p = format!("{}/tests/data/chain-legacy.p12", env!("CARGO_MANIFEST_DIR")); let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]); assert!(out.status.success(), "chain-legacy.p12: {}", String::from_utf8_lossy(&out.stderr)); let listed = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap(); assert_eq!(listed.len(), 1, "{listed:?}"); // The Security framework caches the search list in-process and learns of `security // list-keychains` asynchronously, so after another Keychain test it takes a moment to look // in the new keychain. let reference = pdfcraft_sign::keychain::reference(&listed[0].certificate); let mut found = pdfcraft_sign::keychain::find(&reference); for _ in 0..50 { if found.is_ok() { break; } std::thread::sleep(std::time::Duration::from_millis(100)); found = pdfcraft_sign::keychain::find(&reference); } let id = found.unwrap(); let issuers: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect(); assert_eq!(issuers, [Some("PrintCraft Test Root CA")]); let file = pkcs12::open(&data("chain.p12"), "test").unwrap(); assert_eq!(id.chain.iter().map(|c| &c.raw).collect::>(), file.chain.iter().map(|c| &c.raw).collect::>()); })); // The list first: it is what outlives the test if deleting the keychain goes wrong. let restored = set_list(None); sec(&["delete-keychain", k]); let _ = std::fs::remove_dir_all(&dir); assert!(restored.status.success(), "the keychain search list wasn't restored: {}", String::from_utf8_lossy(&restored.stderr)); result.unwrap(); } /// `fixture()` plus a Form XObject (object 7) that the page draws. fn fixture_with_xobject() -> Vec { let objs: Vec<&[u8]> = vec![ b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /X1 7 0 R >> >> /Annots [6 0 R] >>", b"<< /Length 8 >>\nstream\n/X1 Do\n\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", b"<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", ]; 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", i + 1).as_bytes()); out.extend_from_slice(o); out.extend_from_slice(b"\nendobj\n"); } 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 } /// An update that declares the form dynamic XFA (/XFA + /NeedsRendering) and swaps the whole /// page tree for a new one. Marking a form XFA must not turn a page replacement into "form /// fill": the signed pages are gone. fn xfa_page_swap(doc: &mut Document) { let xdp = b"