//! Signing and validating whole documents. use std::sync::Arc; use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ Detail, Modification, 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 { 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 >> >> /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 >>", ]; 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(_)) )); } /// 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"); } } /// 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); } #[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")); } #[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, } impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; pdfcraft_sign::timestamp::respond( &self.id.key, &self.id.certificate, &self.id.chain, pdfcraft_sign::DigestAlg::Sha256, &q, "1.2.3.4", self.time, 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), 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 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), 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); } #[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); } } /// 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() { 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); } })); sec(&["delete-keychain", k]); restore(); let _ = std::fs::remove_dir_all(&dir); 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"