Files
pdfcraft/crates/sign/tests/it/pdf.rs
T
Dylan M. Taylor 60643020b6 Merge pull request #4244 from dylanmtaylor/fix/high-severity-redact-sign
fix(redact,sign,cos): close the still-open high-severity scan findings
2026-10-23 10:25:51 -04:00

2671 lines
149 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Signing and validating whole documents.
use std::sync::Arc;
use pdfcraft_cos::{Document, 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<u8> {
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<u8> {
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<u8> {
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);
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");
}
}
/// 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<u8> {
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<u8> = (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,
}
/// 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<Vec<u8>> = 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<Vec<u8>, 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: 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<Vec<u8>, 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 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(&ltv);
// 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, &ltv, &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(&ltv), &tsa, "D:20261006120000Z").unwrap();
let all = signatures(&open(&lta), &lta, &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(&ltv), &ltv, &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<String> = 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<String> = 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::<Vec<_>>(), file.chain.iter().map(|c| &c.raw).collect::<Vec<_>>());
}));
// 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<u8> {
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"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template/></xdp:xdp>".to_vec();
let mut xd = pdfcraft_cos::Dict::new();
xd.set(b"Length".to_vec(), Object::Int(xdp.len() as i64));
let xfa = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(xd, xdp)));
let body = b"BT /F1 14 Tf 20 250 Td (Forged text) Tj ET".to_vec();
let mut cd = pdfcraft_cos::Dict::new();
cd.set(b"Length".to_vec(), Object::Int(body.len() as i64));
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(cd, body)));
let pages = doc.add(Object::Null);
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)]));
page.set(b"Contents".to_vec(), Object::Ref(contents));
let mut font = pdfcraft_cos::Dict::new();
font.set(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef { num: 5, generation: 0 }));
let mut res = pdfcraft_cos::Dict::new();
res.set(b"Font".to_vec(), Object::Dict(font));
page.set(b"Resources".to_vec(), Object::Dict(res));
let page = doc.add(Object::Dict(page));
let mut tree = pdfcraft_cos::Dict::new();
tree.set(b"Type".to_vec(), Object::name("Pages"));
tree.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
tree.set(b"Count".to_vec(), Object::Int(1));
doc.set(pages, Object::Dict(tree));
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.set(b"Pages".to_vec(), Object::Ref(pages));
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm") {
form.set(b"XFA".to_vec(), Object::Ref(xfa));
form.set(b"NeedsRendering".to_vec(), Object::Bool(true));
}
})
.unwrap();
}
#[test]
fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() {
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();
for certify in [None, Some(2), Some(3)] {
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap();
assert_eq!(check(&signed).status, Status::Valid);
let s = check(&edit_after(&signed, xfa_page_swap));
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::STRUCTURE)), "{:?}", s.modification);
}
}
/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9).
fn xfa_fixture() -> Vec<u8> {
fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>",
&[
b"<< /Length 11 >>\nstream\n<template/>\nendstream",
b"<< /Length 22 >>\nstream\n<xfa:datasets><a>1</a>\nendstream",
b"<< /Length 7 >>\nstream\n<form/>\nendstream",
],
)
}
/// Rewrite object `num` as a stream holding `body`.
fn rewrite_stream(num: u32, body: &'static [u8]) -> impl FnOnce(&mut Document) {
move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, stream_of(body))
}
/// #2759: filling a certified XFA form rewrites its `datasets` (and `form`) packets, as a Reader
/// Extensions save of the IRCC forms does. That is form fill, which certification level 2
/// permits; level 1 still refuses it, and rewriting the template is not a form fill. The packets'
/// roles come from the signed revision: a later revision that names the template "datasets"
/// doesn't make rewriting it a form fill.
#[test]
fn rewriting_a_certified_forms_xfa_data_is_a_form_fill() {
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 certified = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
assert_eq!(check(&certified).status, Status::Valid);
for num in [8, 9] {
let s = check(&edit_after(&certified, rewrite_stream(num, b"<xfa:datasets><a>2</a></xfa:datasets>")));
assert_eq!((s.status, s.modification.clone()), (Status::Valid, Modification::Allowed(vec![kind::FORM_FILL])), "{num}: {:?}", s.details);
}
let s = check(&edit_after(&certified, rewrite_stream(7, b"<template><subform/></template>")));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
let renamed = edit_after(&certified, |doc| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm") {
form.set(b"XFA".to_vec(), Object::Array(vec![Object::String(PdfString::text("datasets")), oref(7)]));
}
})
.unwrap();
rewrite_stream(7, b"<template><subform/></template>")(doc);
});
let s = check(&renamed);
assert_eq!(s.status, Status::Invalid, "{:?}", s.modification);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::OTHER)), "{:?}", s.modification);
let locked = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap();
let s = check(&edit_after(&locked, rewrite_stream(8, b"<xfa:datasets><a>2</a></xfa:datasets>")));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::FORM_FILL])));
}
/// Re-encode a DER CMS the way Windows CryptoAPI / Adobe PPKMS / `openssl cms -stream` write it:
/// indefinite lengths on ContentInfo, [0] and SignedData (the signed attributes stay DER).
fn to_ber(der_cms: &[u8]) -> Vec<u8> {
use pdfcraft_sign::der::Tlv;
let ci = Tlv::parse(der_cms).unwrap().0.children().unwrap();
let sd = ci[1].inner().unwrap().children().unwrap();
let mut out = vec![0x30, 0x80];
out.extend_from_slice(ci[0].raw);
out.extend_from_slice(&[0xA0, 0x80, 0x30, 0x80]);
for c in sd {
out.extend_from_slice(c.raw);
}
out.extend_from_slice(&[0; 6]);
out
}
/// The signature `/Contents` hex of the only signed field, and its position in `pdf`.
fn contents_span(pdf: &[u8]) -> (usize, usize) {
let key = b"/Contents <";
let start = pdf.windows(key.len()).position(|w| w == key).map(|i| i + key.len()).unwrap();
(start, start + pdf[start..].iter().position(|b| *b == b'>').unwrap())
}
#[test]
fn validates_signatures_written_with_ber_indefinite_lengths() {
for file in ["rsa-aes.p12", "ec-p256.p12"] {
let id = pkcs12::open(&data(file), "test").unwrap();
let mut signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let (a, b) = contents_span(&signed);
let hex = |s: &[u8]| s.iter().map(|x| format!("{x:02x}")).collect::<String>();
let der: Vec<u8> = (a..b).step_by(2).map(|i| u8::from_str_radix(std::str::from_utf8(&signed[i..i + 2]).unwrap(), 16).unwrap()).collect();
let der_len = pdfcraft_sign::der::Tlv::parse(&der).unwrap().0.raw.len();
let mut ber = to_ber(&der[..der_len]);
assert_eq!(ber[1], 0x80, "indefinite");
ber.resize((b - a) / 2, 0);
signed[a..b].copy_from_slice(hex(&ber).as_bytes());
let anchor = id.certificate.clone();
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{file}: {:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("BER")), "the tolerance is reported: {:?}", s.details);
// Tampering is still caught: the digest check is not relaxed.
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);
}
}
/// A signed attribute: the attribute type and a SET holding its one value.
fn attribute(oid: &str, value: &[u8]) -> Vec<u8> {
der::seq(&[&der::oid(oid), &der::set_of(&[value])])
}
/// `signed` with its signature made again by `id` over the signed attributes `attrs` and its
/// encapsulated content typed `encap_type`, the rest of the CMS built as the signer builds it, so
/// only those differ.
fn resigned_with_attributes(signed: &[u8], id: &pkcs12::DigitalId, encap_type: &str, attrs: &[Vec<u8>]) -> Vec<u8> {
use pdfcraft_sign::DigestAlg;
use pdfcraft_sign::der::tag;
let alg = DigestAlg::Sha256;
let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect();
let set = der::set_of(&refs);
let signature = id.key.sign(alg, &set).unwrap();
let mut signed_attrs = set.clone();
signed_attrs[0] = tag::ctx(0);
let cert = &id.certificate;
let sid = der::seq(&[&cert.issuer.raw, &der::uint(&cert.serial)]);
let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &id.key.signature_algorithm(alg), &der::octets(&signature)]);
let encap = der::seq(&[&der::oid(encap_type)]);
let signed_data =
der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), &encap, &der::tlv(tag::ctx(0), &cert.raw), &der::set_of(&[&signer])]);
let cms = der::seq(&[&der::oid("1.2.840.113549.1.7.2"), &der::explicit(0, &signed_data)]);
let (a, b) = contents_span(signed);
assert!(cms.len() * 2 <= b - a, "the CMS fits the /Contents placeholder");
let hex: String = cms.iter().chain(std::iter::repeat(&0)).take((b - a) / 2).map(|x| format!("{x:02x}")).collect();
let mut out = signed.to_vec();
out[a..b].copy_from_slice(hex.as_bytes());
out
}
const DATA: &str = "1.2.840.113549.1.7.1";
const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4";
const CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
const MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
/// RFC 5652 §5.3: signed attributes carry the message digest, which ties the signature to the
/// document, and the content type (data, for a document). A signature over attributes without
/// the digest covers nothing of the document and is invalid; another content type is not a
/// document signature; a missing content type is read anyway, with a note. `eContentType` must
/// be data too, and the same type as the attribute, which the signature covers and it does not.
#[test]
fn signed_attributes_must_bind_the_document() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let (a, b) = contents_span(&signed);
let digest = pdfcraft_sign::DigestAlg::Sha256.digest(&[&signed[..a - 1], &signed[b + 1..]]);
let data_type = attribute(CONTENT_TYPE, &der::oid(DATA));
let tst_type = attribute(CONTENT_TYPE, &der::oid(TST_INFO));
let message_digest = attribute(MESSAGE_DIGEST, &der::octets(&digest));
let check_as = |encap_type: &str, attrs: &[Vec<u8>]| {
let pdf = resigned_with_attributes(&signed, &id, encap_type, attrs);
signatures(&open(&pdf), &pdf, &trust).into_iter().find(|s| s.signed).unwrap()
};
let check = |attrs: &[Vec<u8>]| check_as(DATA, attrs);
let said = |s: &pdfcraft_sign::SignatureInfo, sentence: &str| s.details.iter().any(|d| d.message == sentence);
let unreadable_because = |s: &pdfcraft_sign::SignatureInfo, why: &str| {
s.details.iter().any(|d| d.pattern == "The signature could not be read ({})." && d.message.contains(why))
};
// Both attributes, as a signer writes them: valid, so the cases below differ only in them.
let s = check(&[data_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
let s = check(std::slice::from_ref(&data_type));
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(said(&s, "The signature's signed attributes have no message digest, so the signature does not cover the document."), "{:?}", s.details);
// An RFC 3161 token's types, TSTInfo in the attribute and in eContentType.
let s = check_as(TST_INFO, &[tst_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(
said(&s, "The signature's signed attributes name a content type other than data, so it is not a signature of the document."),
"{:?}",
s.details
);
let s = check(std::slice::from_ref(&message_digest));
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert!(said(&s, "The signature's signed attributes have no content type, which CMS requires; it was read anyway."), "{:?}", s.details);
// A content type the attribute does not name, and one that only eContentType names.
let s = check_as(TST_INFO, &[data_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "the signed content type is not the encapsulated content type"), "{:?}", s.details);
let s = check_as(TST_INFO, std::slice::from_ref(&message_digest));
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "the encapsulated content is not data"), "{:?}", s.details);
// A second content-type attribute, with the same value or another one.
for second in [&data_type, &tst_type] {
let s = check(&[data_type.clone(), second.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "one content type with one value"), "{:?}", s.details);
}
}
#[test]
fn validation_data_and_xmp_added_after_signing_are_not_tampering() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
// As DocuSign / Acrobat LTV do: a DSS whose /Certs and /VRI are indirect objects, and XMP.
let edited = edit_after(&signed, |doc| {
let mut cert = pdfcraft_cos::Dict::new();
cert.set(b"Length".to_vec(), Object::Int(3));
let cert = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(cert, vec![1, 2, 3])));
let certs = doc.add(Object::Array(vec![Object::Ref(cert)]));
let vri = doc.add(Object::Dict(pdfcraft_cos::Dict::new()));
let mut dss = pdfcraft_cos::Dict::new();
dss.set(b"Certs".to_vec(), Object::Ref(certs));
dss.set(b"VRI".to_vec(), Object::Ref(vri));
let dss = doc.add(Object::Dict(dss));
let mut xmp = pdfcraft_cos::Dict::new();
xmp.set(b"Type".to_vec(), Object::name("Metadata"));
xmp.set(b"Subtype".to_vec(), Object::name("XML"));
xmp.set(b"Length".to_vec(), Object::Int(1));
let xmp = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(xmp, b"x".to_vec())));
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.set(b"DSS".to_vec(), Object::Ref(dss));
c.set(b"Metadata".to_vec(), Object::Ref(xmp));
})
.unwrap();
});
let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert!(matches!(s.modification, Modification::Allowed(_)), "{:?}", s.modification);
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
// Page content changes are still refused.
let edited = edit_after(&signed, change_text);
let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid);
}
// ── DSS and metadata: what later revisions may add ─────────────────────────────────────────
fn oref(num: u32) -> Object {
Object::Ref(pdfcraft_cos::ObjRef { num, generation: 0 })
}
fn dict_of(entries: Vec<(&str, Object)>) -> pdfcraft_cos::Dict {
let mut d = pdfcraft_cos::Dict::new();
for (k, v) in entries {
d.set(k.as_bytes().to_vec(), v);
}
d
}
fn stream_of(body: &[u8]) -> Object {
let d = dict_of(vec![("Length", Object::Int(body.len() as i64))]);
Object::Stream(pdfcraft_cos::Stream::from_raw(d, body.to_vec()))
}
/// Set the catalog's `/DSS`.
fn set_dss(doc: &mut Document, dss: Object) {
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"DSS".to_vec(), dss)).unwrap();
}
/// Rewrite object `num` (the page's contents) so the page shows other text.
fn forge_contents(doc: &mut Document, num: u32) {
doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, stream_of(b"BT /F1 14 Tf 20 250 Td (Forged) Tj ET"));
}
/// `fixture()` with the page's `/Contents` an indirect array (object 7) holding object 4.
fn fixture_with_contents_array() -> Vec<u8> {
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 7 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 >>",
b"[4 0 R]",
];
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
}
/// Every DocMDP level: no certification (an approval signature), then P=1, 2 and 3.
const LEVELS: [Option<u8>; 4] = [None, Some(1), Some(2), Some(3)];
/// Each tampering `attack` of the signed `base` is refused at every level, with `kind` among the
/// reasons, while the untouched file stays valid.
fn assert_refused_at_every_level(base: &[u8], kind: &str, attack: impl Fn(&mut Document)) {
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();
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(base), &id, &SignOptions { certify, ..opts() }).unwrap();
assert_eq!(check(&signed).status, Status::Valid, "{certify:?}: untouched");
let s = check(&edit_after(&signed, &attack));
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?} {:?}", s.modification, s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind)), "{certify:?}: {:?}", s.modification);
}
}
#[test]
fn a_dss_entry_does_not_make_the_object_it_names_part_of_the_store() {
// `/DSS << /X 4 0 R >>` and a rewrite of object 4, the page's contents: the contents are
// still page content.
assert_refused_at_every_level(&fixture(), "page content", |doc| {
set_dss(doc, Object::Dict(dict_of(vec![("X", oref(4))])));
forge_contents(doc, 4);
});
// The same through an indirect store, and through every key the store does define.
for key in ["X", "Certs", "CRLs", "OCSPs"] {
assert_refused_at_every_level(&fixture(), "page content", |doc| {
let dss = doc.add(Object::Dict(dict_of(vec![(key, Object::Array(vec![oref(4)]))])));
set_dss(doc, Object::Ref(dss));
forge_contents(doc, 4);
});
}
// A VRI entry's /Cert array, and its /TS.
for key in ["Cert", "CRL", "OCSP", "TS"] {
assert_refused_at_every_level(&fixture(), "page content", |doc| {
let value = if key == "TS" { oref(4) } else { Object::Array(vec![oref(4)]) };
let entry = doc.add(Object::Dict(dict_of(vec![(key, value)])));
let vri = doc.add(Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", Object::Ref(entry))])));
let dss = doc.add(Object::Dict(dict_of(vec![("VRI", Object::Ref(vri))])));
set_dss(doc, Object::Ref(dss));
forge_contents(doc, 4);
});
}
}
#[test]
fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() {
// The font dictionary (object 5), rewritten and then listed as the store, its /VRI, a VRI
// entry, or simply labelled /Type /DSS. The page draws it, so each is page content (#1787).
let rewrite_font = |doc: &mut Document, extra: Option<(&str, Object)>| {
let mut d = dict_of(vec![("Type", Object::name("Font")), ("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Courier"))]);
if let Some((k, v)) = extra {
d.set(k.as_bytes().to_vec(), v);
}
doc.set(pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(d));
};
assert_refused_at_every_level(&fixture(), "page content", |doc| {
rewrite_font(doc, Some(("Type", Object::name("DSS"))));
});
assert_refused_at_every_level(&fixture(), "page content", |doc| {
set_dss(doc, oref(5));
rewrite_font(doc, None);
});
assert_refused_at_every_level(&fixture(), "page content", |doc| {
set_dss(doc, Object::Dict(dict_of(vec![("VRI", oref(5))])));
rewrite_font(doc, None);
});
// An existing dictionary with only DSS-looking keys that the signed file never had as a store.
assert_refused_at_every_level(&fixture(), "page content", |doc| {
doc.set(
pdfcraft_cos::ObjRef { num: 5, generation: 0 },
Object::Dict(dict_of(vec![("Type", Object::name("Font")), ("Cert", Object::Array(vec![]))])),
);
set_dss(doc, Object::Dict(dict_of(vec![("VRI", Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", oref(5))])))])));
});
}
#[test]
fn an_existing_array_stays_what_it_is_when_the_store_points_at_it() {
// The page's /Contents is an indirect array (object 7). Pointing /Certs at it and appending
// a new stream still changes what the page shows.
assert_refused_at_every_level(&fixture_with_contents_array(), "other changes", |doc| {
let extra = doc.add(stream_of(b"BT /F1 14 Tf 20 200 Td (Forged) Tj ET"));
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Array(vec![oref(4), Object::Ref(extra)]));
set_dss(doc, Object::Dict(dict_of(vec![("Certs", oref(7))])));
});
}
#[test]
fn a_dictionary_labelled_metadata_is_not_a_metadata_change() {
// Only the catalog's XMP stream is metadata (a stream, not a dictionary a label was put on).
// The dictionary is the page's font, so the rewrite is page content (#1787).
assert_refused_at_every_level(&fixture(), "page content", |doc| {
doc.set(
pdfcraft_cos::ObjRef { num: 5, generation: 0 },
Object::Dict(dict_of(vec![
("Type", Object::name("Metadata")),
("Subtype", Object::name("Type1")),
("BaseFont", Object::name("Courier")),
])),
);
});
}
/// A one-page document like `fixture()` (objects 1–6) plus a stream, object 7, with the given
/// dictionary entries and body. `catalog_extra` goes into the catalog; the page draws object 7
/// as a Form XObject when `drawn`.
fn fixture_with_stream_7(catalog_extra: &str, drawn: bool, dict: &str, body: &[u8]) -> Vec<u8> {
let page_content: &[u8] =
if drawn { b"BT /F1 14 Tf 20 250 Td (Contract text) Tj ET /Fm1 Do" } else { b"BT /F1 14 Tf 20 250 Td (Contract text) Tj ET" };
let xobject = if drawn { " /XObject << /Fm1 7 0 R >>" } else { "" };
let stream = |dict: &str, body: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
o.extend_from_slice(body);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> {catalog_extra} >>").into_bytes(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >>{xobject} >> /Annots [6 0 R] >>"
)
.into_bytes(),
stream("", page_content),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>".to_vec(),
stream(dict, body),
];
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
}
#[test]
fn a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it() {
// A Form XObject the page draws, labelled /Type /Metadata before signing (the renderer ignores
// /Type). Rewriting it later changes what the page shows, so it is not a metadata change
// but page content (#1787).
let base = fixture_with_stream_7("", true, "/Type /Metadata /Subtype /Form /BBox [0 0 300 300]", b"0 0 1 rg 10 10 100 100 re f");
assert_refused_at_every_level(&base, "page content", |doc| {
let d = dict_of(vec![
("Type", Object::name("Metadata")),
("Subtype", Object::name("Form")),
("BBox", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)])),
("Length", Object::Int(27)),
]);
doc.set(
pdfcraft_cos::ObjRef { num: 7, generation: 0 },
Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"1 0 0 rg 10 10 100 100 re f".to_vec())),
);
});
}
#[test]
fn the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing() {
// The control for the test above: the stream the signed catalog's /Metadata names is the
// document's XMP, and rewriting it is allowed at every level.
let base = fixture_with_stream_7("/Metadata 7 0 R", false, "/Type /Metadata /Subtype /XML", b"<x:xmpmeta/>");
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { certify, ..opts() }).unwrap();
let edited = edit_after(&signed, |doc| {
let d = dict_of(vec![("Type", Object::name("Metadata")), ("Subtype", Object::name("XML")), ("Length", Object::Int(13))]);
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"<x:xmpmeta />".to_vec())));
});
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::METADATA)), "{certify:?}: {:?}", s.modification);
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?}", s.details);
}
}
/// A document whose catalog already has a store: `/Certs` (object `.0`) with one certificate,
/// and `/VRI` (object `.1`).
fn fixture_with_dss() -> (Vec<u8>, u32, u32) {
let mut ids = (0, 0);
let bytes = edit_after(&fixture(), |doc| {
let cert = doc.add(stream_of(&[1, 2, 3]));
let certs = doc.add(Object::Array(vec![Object::Ref(cert)]));
let vri = doc.add(Object::Dict(pdfcraft_cos::Dict::new()));
let dss = doc.add(Object::Dict(dict_of(vec![("Type", Object::name("DSS")), ("Certs", Object::Ref(certs)), ("VRI", Object::Ref(vri))])));
set_dss(doc, Object::Ref(dss));
ids = (certs.num, vri.num);
});
(bytes, ids.0, ids.1)
}
#[test]
fn a_signed_store_may_grow_but_not_lose_or_swap_entries() {
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 (base, certs, vri) = fixture_with_dss();
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { certify, ..opts() }).unwrap();
assert_eq!(check(&signed).status, Status::Valid, "{certify:?}");
// Validation data appended to the arrays, and a VRI entry: what Acrobat's LTV does.
let grown = edit_after(&signed, |doc| {
let more = doc.add(stream_of(&[4, 5, 6]));
let entry = doc.add(Object::Dict(dict_of(vec![("Cert", Object::Array(vec![Object::Ref(more)]))])));
let old = open(&signed);
let Object::Array(mut kept) = (*old.get(pdfcraft_cos::ObjRef { num: certs, generation: 0 })).clone() else { panic!("array") };
kept.push(Object::Ref(more));
doc.set(pdfcraft_cos::ObjRef { num: certs, generation: 0 }, Object::Array(kept));
doc.set(
pdfcraft_cos::ObjRef { num: vri, generation: 0 },
Object::Dict(dict_of(vec![("0123456789ABCDEF0123456789ABCDEF01234567", Object::Ref(entry))])),
);
});
let s = check(&grown);
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?} {:?}", s.modification, s.details);
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SECURITY_STORE)), "{:?}", s.modification);
// Dropping what was signed is a change, not an addition.
let dropped = edit_after(&signed, |doc| {
doc.set(pdfcraft_cos::ObjRef { num: certs, generation: 0 }, Object::Array(vec![]));
});
let s = check(&dropped);
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.modification);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::OTHER)), "{:?}", s.modification);
}
}
#[test]
fn a_form_xobject_relabelled_metadata_is_not_a_metadata_change() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture_with_xobject()), &id, &opts()).unwrap();
let relabel = |doc: &mut Document| {
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)]));
let body = b"BT /F1 14 Tf 20 250 Td (Forged text) Tj ET".to_vec();
d.set(b"Length".to_vec(), Object::Int(body.len() as i64));
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, body)));
};
let edited = edit_after(&signed, relabel);
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&kind::METADATA)), "{:?}", s.modification);
// The catalog's own XMP stream may still be updated.
let with_xmp = edit_after(&fixture_with_xobject(), |doc| {
let xmp = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>".to_vec();
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("XML"));
d.set(b"Length".to_vec(), Object::Int(xmp.len() as i64));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, xmp)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(r))).unwrap();
});
let signed_xmp = pdfcraft_sign::sign(&open(&with_xmp), &id, &opts()).unwrap();
let rewrite_xmp = edit_after(&signed_xmp, |doc| {
let root = doc.root().unwrap();
let r = doc.get(root).as_dict().unwrap().get(b"Metadata").and_then(Object::as_ref).unwrap();
let xmp = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><!-- edited --></x:xmpmeta>".to_vec();
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("XML"));
d.set(b"Length".to_vec(), Object::Int(xmp.len() as i64));
doc.set(r, Object::Stream(pdfcraft_cos::Stream::from_raw(d, xmp)));
});
let s = signatures(&open(&rewrite_xmp), &rewrite_xmp, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert_eq!(s.modification, Modification::Allowed(vec!["metadata"]));
}
#[test]
fn a_form_xobject_relabelled_as_file_plumbing_is_still_judged() {
// Whether a changed stream is an xref or object stream is what it was when signed: a
// drawn Form XObject rewritten after signing and labelled /ObjStm or /XRef still shows its
// new content (the renderer ignores /Type), so it must not be skipped.
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&fixture_with_xobject()), &id, &SignOptions { certify, ..opts() }).unwrap();
for label in ["ObjStm", "XRef"] {
let edited = edit_after(&signed, |doc| {
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name(label));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)]));
let body = b"BT /F1 14 Tf 20 250 Td (Forged text) Tj ET".to_vec();
d.set(b"Length".to_vec(), Object::Int(body.len() as i64));
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, body)));
});
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{certify:?} /{label}: {:?}", s.details);
// The page draws the XObject: its rewrite is page content (#1787).
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")), "{:?}", s.modification);
}
}
}
/// `fixture()` with a document information dictionary (object 7) named by the trailer's `/Info`.
fn fixture_with_info() -> Vec<u8> {
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 >>",
b"<< /Producer (PdfCraft) /CreationDate (D:20260101000000Z) >>",
];
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 /Info 7 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_signed_page_relabelled_or_stripped_of_type_is_still_page_content() {
// A page is judged by the role it had when signed, not by the /Type its new version carries.
// A hand-written incremental revision that rewrites the signed page's /Contents while
// relabelling it /Type /Sig (permitted at every level) or dropping /Type and adding /ModDate
// (read as a metadata change) must still be a disallowed page content change at every level.
let forge_page = |ty: Option<&'static str>| {
move |doc: &mut Document| {
let forged = doc.add(stream_of(b"BT /F1 14 Tf 20 250 Td (Amount due: 999999 EUR) Tj ET"));
let mut entries = vec![
("Parent", oref(2)),
("MediaBox", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)])),
("Contents", Object::Ref(forged)),
("Resources", Object::Dict(dict_of(vec![("Font", Object::Dict(dict_of(vec![("F1", oref(5))])))]))),
("Annots", Object::Array(vec![oref(6)])),
];
match ty {
Some(name) => entries.insert(0, ("Type", Object::name(name))),
None => entries.push(("ModDate", Object::String(PdfString::literal("D:20261003000000Z")))),
}
doc.set(pdfcraft_cos::ObjRef { num: 3, generation: 0 }, Object::Dict(dict_of(entries)));
}
};
assert_refused_at_every_level(&fixture(), "page content", forge_page(Some("Sig")));
assert_refused_at_every_level(&fixture(), "page content", forge_page(None));
// Positive control: an ordinary /Info /ModDate bump on the signed revision's own information
// dictionary stays a permitted metadata change.
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&fixture_with_info()), &id, &SignOptions { certify, ..opts() }).unwrap();
let bumped = edit_after(&signed, |doc| {
doc.update_dict(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, |d| {
d.set(b"ModDate".to_vec(), Object::String(PdfString::literal("D:20261003120000Z")));
})
.unwrap();
});
let s = signatures(&open(&bumped), &bumped, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{certify:?}: {:?}", s.details);
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&"metadata")), "{certify:?}: {:?}", s.modification);
}
}
/// A self-signed CRL from `id` revoking its own certificate, valid from `this` to `next`.
fn self_crl(id: &pkcs12::DigitalId, this: Time, next: Time, revoked_at: Time) -> Vec<u8> {
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), &revoked_at.encode()])]),
]);
let sig = id.key.sign(pdfcraft_sign::DigestAlg::Sha256, &tbs).unwrap();
der::seq(&[&tbs, &alg, &der::bit_string(&sig)])
}
#[test]
fn a_revoked_certificate_stays_invalid_when_it_was_also_expired_at_signing() {
use pdfcraft_sign::dss::{self, Evidence};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
// Signed in 2040, after the test certificate expired (2036).
let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &late).unwrap();
let t = |year| Time { year, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
let crl = self_crl(&id, t(2039), t(2041), t(2039));
let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
for trust in [TrustStore::default(), TrustStore { certs: vec![anchor] }] {
let s = signatures(&open(&ltv), &ltv, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
assert!(s.details.iter().any(|d| d.message.contains("not valid at the time of signing")), "{:?}", s.details);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("revoked")), "{:?}", s.details);
}
}
#[test]
fn a_revocation_behind_many_dss_entries_is_not_reported_valid() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let t = |year| Time { year, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
let crl = self_crl(&id, t(2026), t(2027), Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 });
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let trust = TrustStore { certs: vec![anchor] };
// `/CRLs`: the revoking CRL after `benign` other entries; `repeated` lists one stream for all of them.
let status = |benign: usize, repeated: bool| {
let ltv = edit_after(&signed, |doc| {
let one = doc.add(stream_of(b"benign"));
let mut crls: Vec<Object> = (0..benign)
.map(|k| if repeated { Object::Ref(one) } else { Object::Ref(doc.add(stream_of(format!("benign {k}").as_bytes()))) })
.collect();
crls.push(Object::Ref(doc.add(stream_of(&crl))));
set_dss(doc, Object::Dict(dict_of(vec![("Type", Object::name("DSS")), ("CRLs", Object::Array(crls))])));
});
let s = signatures(&open(&ltv), &ltv, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
(s.status, s.details)
};
let (found, details) = status(1, false);
assert_eq!(found, Status::Invalid, "{details:?}");
// Repeats of one stream don't use up the entry cap, so the revocation is still found.
let (found, details) = status(2000, true);
assert_eq!(found, Status::Invalid, "{details:?}");
// Past the cap of distinct entries the check is incomplete, so the signature is not valid.
let (found, details) = status(1100, false);
assert_eq!(found, Status::Unknown, "{details:?}");
}
#[test]
fn an_untrusted_timestamp_does_not_set_the_validation_time() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
// The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own
// clock says 2040, after the certificate expired.
let tsa = TestTsa { id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 } };
let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() };
let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor.clone()] }).into_iter().find(|s| s.signed).unwrap();
assert_ne!(s.status, Status::Valid, "{:?}", s.details);
assert_eq!(s.timestamp_time, None, "an untrusted token's time is not a trusted time");
assert!(s.details.iter().any(|d| d.message.contains("not valid at the time of signing")), "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.message.contains("trusted time")), "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("unverified timestamp")), "{:?}", s.details);
// Trusting the TSA makes its time authoritative: the certificate was valid then.
let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] };
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.timestamp_time, Some(tsa.time));
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
}
#[test]
fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let imprint = pdfcraft_sign::DigestAlg::Sha1.digest(&[b"document"]);
let q = pdfcraft_sign::timestamp::TimestampQuery::new(pdfcraft_sign::DigestAlg::Sha1, imprint.clone()).unwrap();
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
let resp =
pdfcraft_sign::timestamp::respond(&tsa.key, &tsa.certificate, &tsa.chain, pdfcraft_sign::DigestAlg::Sha256, &q, "1.2.3.4", time, 9).unwrap();
let token = pdfcraft_sign::timestamp::parse_response(&resp, &q).unwrap();
assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time));
}
/// A token answers the request that asked for it: a requested nonce or policy must come back.
#[test]
fn a_timestamp_response_must_carry_the_requested_nonce_and_policy() {
use pdfcraft_sign::timestamp::{TimestampQuery, parse_response, respond};
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let alg = pdfcraft_sign::DigestAlg::Sha256;
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
let query = |nonce: Option<u64>, policy: Option<&str>| {
let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap();
(q.nonce, q.policy) = (nonce, policy.map(str::to_string));
q
};
let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap();
let with_nonce = answer(&query(Some(42), None));
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok());
assert!(parse_response(&with_nonce, &query(None, None)).is_ok());
assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce");
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy");
let without_nonce = answer(&query(None, None));
assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token");
}
#[cfg(windows)]
#[test]
fn windows_store_enumeration_and_missing_identity() {
let listing = pdfcraft_sign::windows::list().unwrap();
assert_eq!(listing.ids.len(), pdfcraft_sign::windows::identities().unwrap().len());
// Whatever this machine's store holds, every certificate left out is named and explained.
for u in &listing.unusable {
assert!(!u.subject.is_empty() && !u.reason.is_empty() && u.fingerprint.len() == 95, "{u:?}");
}
assert!(pdfcraft_sign::windows::find("windows:no such signer").is_err());
}
#[cfg(windows)]
fn powershell(script: &str) -> std::process::Output {
// Load the certificate provider explicitly in a profile-free Windows PowerShell child.
let script = format!(
r#"$ErrorActionPreference='Stop'; Import-Module "$PSHOME\Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1"; {script}"#
);
std::process::Command::new("powershell.exe").args(["-NoProfile", "-NonInteractive", "-Command", &script]).output().unwrap()
}
/// Thumbprints of temporary Current User Personal certificates, removed with their keys on drop.
#[cfg(windows)]
struct Certificates(Vec<String>);
#[cfg(windows)]
impl Drop for Certificates {
fn drop(&mut self) {
for thumbprint in &self.0 {
// New-SelfSignedCertificate also leaves a public copy of a self-signed or CA
// certificate in Intermediate Certification Authorities (CA). -DeleteKey fails on
// a certificate without a key; the plain removal then runs.
let out = powershell(&format!(
"$p='Cert:\\CurrentUser\\My\\{thumbprint}'; try {{ Remove-Item -LiteralPath $p -DeleteKey }} catch {{ Remove-Item -LiteralPath $p }}; $copy='Cert:\\CurrentUser\\CA\\{thumbprint}'; if (Test-Path -LiteralPath $copy) {{ Remove-Item -LiteralPath $copy }}"
));
if !out.status.success() {
eprintln!("test certificate cleanup failed: {}", String::from_utf8_lossy(&out.stderr));
}
}
}
}
/// Uses only newly created software-backed CNG keys, removed even when signing fails.
#[cfg(windows)]
#[test]
#[ignore = "creates temporary certificates in the Windows Current User Personal store"]
fn signing_with_windows_store_identities() {
let mut created = Certificates(Vec::new());
let mut random = [0u8; 16];
getrandom::fill(&mut random).unwrap();
let unique: String = random.iter().map(|b| format!("{b:02x}")).collect();
for algorithm in ["RSA", "ECDSA_nistP256", "ECDSA_nistP384"] {
let name = format!("PdfCraft Test {unique} {algorithm}");
let rsa = if algorithm == "RSA" { "-KeyLength 2048" } else { "" };
let out = powershell(&format!(
"$ErrorActionPreference='Stop'; $c=New-SelfSignedCertificate -Subject 'CN={name}' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Provider 'Microsoft Software Key Storage Provider' -KeyAlgorithm {algorithm} {rsa} -KeyUsage DigitalSignature -KeyExportPolicy NonExportable; $c.Thumbprint"
));
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let thumbprint = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert_eq!(thumbprint.len(), 40);
assert!(thumbprint.bytes().all(|b| b.is_ascii_hexdigit()));
created.0.push(thumbprint);
let listed = pdfcraft_sign::windows::identities().unwrap();
let listed_id = listed.iter().find(|id| id.certificate.subject.common_name() == Some(name.as_str())).expect("new identity listed");
let reference = pdfcraft_sign::windows::reference(&listed_id.certificate);
let id = pdfcraft_sign::windows::find(&reference).unwrap();
assert_eq!(pdfcraft_sign::windows::find(&format!("windows:{name}")).unwrap().certificate.raw, id.certificate.raw);
assert!(id.key.is_external());
let mut options = opts();
let now = powershell("(Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss')");
assert!(now.status.success());
options.date = format!("D:{}Z", String::from_utf8(now.stdout).unwrap().trim());
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &options).unwrap();
let validated = signatures(&open(&signed), &signed, &TrustStore { certs: vec![id.certificate.clone()] });
let signature = validated.iter().find(|s| s.signed).unwrap();
assert_eq!(signature.status, Status::Valid, "{:?}", signature.details);
assert_eq!(signature.modification, Modification::None);
assert_eq!(signature.signer.as_deref(), Some(name.as_str()));
}
// A certificate without a private key (issued to someone else, filed under Personal) is
// reported as such rather than listed or dropped silently (issue #179).
let ada = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap().certificate;
let cer = std::env::temp_dir().join(format!("pdfcraft-windows-store-{unique}.cer"));
std::fs::write(&cer, &ada.raw).unwrap();
let out = powershell(&format!("(Import-Certificate -FilePath '{}' -CertStoreLocation 'Cert:\\CurrentUser\\My').Thumbprint", cer.display()));
let _ = std::fs::remove_file(&cer);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
created.0.push(String::from_utf8(out.stdout).unwrap().trim().to_string());
let listing = pdfcraft_sign::windows::list().unwrap();
let reported = listing.unusable.iter().find(|u| u.fingerprint == ada.fingerprint()).expect("key-less certificate reported");
assert!(reported.no_private_key, "{reported:?}");
assert_eq!(reported.subject, ada.display_name());
assert!(listing.ids.iter().all(|id| id.certificate.raw != ada.raw));
let thumbprints = created.0.clone();
drop(created);
for thumbprint in thumbprints {
for store in ["My", "CA"] {
let out = powershell(&format!("if (Test-Path -LiteralPath 'Cert:\\CurrentUser\\{store}\\{thumbprint}') {{ exit 1 }}"));
assert!(out.status.success(), "test certificate remains in {store}: {thumbprint}");
}
}
}
/// An identity found for signing brings the issuers Windows chains it to (not the root), so the
/// signature validates against the root alone. Uses only temporary software-backed CNG keys.
#[cfg(windows)]
#[test]
#[ignore = "creates temporary certificates in the Windows Current User Personal store"]
fn a_windows_store_identity_signs_with_its_issuers() {
let mut created = Certificates(Vec::new());
let mut random = [0u8; 16];
getrandom::fill(&mut random).unwrap();
let unique: String = random.iter().map(|b| format!("{b:02x}")).collect();
let [root, issuer, signer] = ["Root", "Issuer", "Signer"].map(|role| format!("PdfCraft Test {unique} {role}"));
let mut create = |name: &str, signed_by: Option<&str>, extra: &str| {
let by = signed_by.map(|t| format!("-Signer (Get-Item -LiteralPath 'Cert:\\CurrentUser\\My\\{t}')")).unwrap_or_default();
let out = powershell(&format!(
"$c=New-SelfSignedCertificate -Subject 'CN={name}' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Provider 'Microsoft Software Key Storage Provider' -KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy NonExportable {by} {extra}; $c.Thumbprint"
));
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let thumbprint = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert_eq!(thumbprint.len(), 40);
created.0.push(thumbprint.clone());
thumbprint
};
let ca = "-KeyUsage CertSign -TextExtension @('2.5.29.19={text}CA=true')";
let root_thumbprint = create(&root, None, ca);
let issuer_thumbprint = create(&issuer, Some(&root_thumbprint), ca);
create(&signer, Some(&issuer_thumbprint), "-KeyUsage DigitalSignature");
let listed = pdfcraft_sign::windows::identities().unwrap();
let anchor = listed.iter().find(|id| id.certificate.subject.common_name() == Some(root.as_str())).expect("root listed").certificate.clone();
// Listing builds no chains; finding the identity to sign with does.
assert!(listed.iter().all(|id| id.chain.is_empty()));
let id = pdfcraft_sign::windows::find(&format!("windows:{signer}")).unwrap();
let chain: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect();
assert_eq!(chain, [Some(issuer.as_str())], "the issuer, without the signer's own certificate or the root");
let mut options = opts();
let now = powershell("(Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss')");
assert!(now.status.success());
options.date = format!("D:{}Z", String::from_utf8(now.stdout).unwrap().trim());
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &options).unwrap();
let validated = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] });
let signature = validated.iter().find(|s| s.signed).unwrap();
assert_eq!(signature.status, Status::Valid, "{:?}", signature.details);
assert_eq!(signature.signer.as_deref(), Some(signer.as_str()));
}
/// Documenso-style: an AcroForm signature field `Timestamp_1` whose `/V` is the document
/// timestamp dictionary (`/Type /DocTimeStamp`, `/SubFilter /ETSI.RFC3161`).
fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec<u8> {
let mut value_num = 0;
let base = edit_after(&fixture(), |doc| {
let field = doc.add(Object::Null);
// `timestamp_document` adds its dictionary next.
value_num = field.num + 1;
let at = |num| Object::Ref(pdfcraft_cos::ObjRef { num, generation: 0 });
let mut widget = pdfcraft_cos::Dict::new();
for (k, v) in [
("Type", Object::name("Annot")),
("Subtype", Object::name("Widget")),
("FT", Object::name("Sig")),
("T", Object::String(PdfString::text("Timestamp_1"))),
("Rect", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0), Object::Int(0)])),
("P", at(3)),
("F", Object::Int(132)),
("V", at(value_num)),
] {
widget.set(k.as_bytes().to_vec(), v);
}
doc.set(field, Object::Dict(widget));
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm")
&& let Some(Object::Array(fields)) = form.get_mut(b"Fields")
{
fields.push(Object::Ref(field));
}
})
.unwrap();
});
let stamped = pdfcraft_sign::timestamp_document(&open(&base), tsa, "D:20261006120000Z").unwrap();
let held = open(&stamped).get(pdfcraft_cos::ObjRef { num: value_num, generation: 0 });
assert_eq!(held.as_dict().and_then(|d| d.name(b"Type")), Some(&b"DocTimeStamp"[..]), "the field holds the timestamp");
stamped
}
#[test]
fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() {
let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } };
let stamped = fixture_timestamped_in_a_field(&tsa);
let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let all = signatures(&open(&stamped), &stamped, &trust);
let stamps: Vec<_> = all.iter().filter(|s| s.signed).collect();
assert_eq!(stamps.len(), 1, "listed once, not as field and as standalone: {:?}", all.iter().map(|s| &s.field).collect::<Vec<_>>());
let s = stamps[0];
assert_eq!(s.field, "Timestamp_1");
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);
// Without trusting the TSA the stamp is intact but unknown, never "altered".
let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.message.contains("altered or corrupted")), "{:?}", s.details);
// A changed byte in what it covers is still caught, and reported as the timestamp's.
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, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid);
assert!(s.details.iter().any(|d| d.message.contains("since the timestamp was applied")), "{:?}", s.details);
}
/// A trusted timestamp authority whose certificate was not yet valid when it stamped proves
/// nothing: the field timestamp stays Unknown, never Valid.
#[test]
fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() {
let tsa = TestTsa {
id: tsa_id(),
// Before the TSA certificate's validity (it starts in October 2026).
time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
};
let stamped = fixture_timestamped_in_a_field(&tsa);
let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.signed).unwrap();
assert!(s.doc_timestamp);
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("was not valid at the time of timestamping")), "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.message.contains("its authority is trusted")), "{:?}", s.details);
}
#[test]
fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() {
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();
let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
// The same bytes with `/Type /Sig` in place of `/Type /DocTimeStamp` (padded to the same
// length): the imprint no longer matches, but it is still found as a timestamp.
let mut old = stamped.clone();
let at = old.windows(19).position(|w| w == b"/Type /DocTimeStamp").unwrap();
old[at..at + 19].copy_from_slice(b"/Type /Sig ");
let s = signatures(&open(&old), &old, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).expect("found as a timestamp");
assert_eq!(s.status, Status::Invalid);
}
/// A legacy adbe.x509.rsa_sha1 signature whose certificate carries an RSA key the verifier
/// rejects (public exponent 1) is Invalid, not "can't check yet".
#[test]
fn a_legacy_x509_signature_with_a_malformed_rsa_key_is_invalid() {
let pdf = data("x509-rsa-sha1.pdf");
let at = pdf.windows(10).position(|w| w == b"0203010001").expect("the certificate's exponent 65537");
let mut bad = pdf.clone();
bad[at..at + 10].copy_from_slice(b"0203000001");
let s = signatures(&open(&bad), &bad, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.sub_filter.as_deref(), Some("adbe.x509.rsa_sha1"));
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.message.contains("can't check this signature yet")), "{:?}", s.details);
}
#[test]
fn validates_the_legacy_adbe_x509_rsa_sha1_format() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
// tests/data/x509-rsa-sha1.pdf: made with OpenSSL (README.md) as Acrobat 4-era signers wrote it.
let pdf = data("x509-rsa-sha1.pdf");
let s = signatures(&open(&pdf), &pdf, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.sub_filter.as_deref(), Some("adbe.x509.rsa_sha1"));
assert_eq!(s.status, Status::Unknown, "intact, identity not trusted: {:?}", s.details);
assert_eq!(s.signer.as_deref(), Some("Test Signer RSA"));
assert_eq!(s.algorithm.as_deref(), Some("RSA 2048-bit with SHA-1"));
assert_eq!(s.modification, Modification::None);
// SHA-1 still validates, but the panel says it is weak.
let weak = "This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on.";
assert_eq!(s.details.iter().filter(|d| d.message == weak).count(), 1, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("legacy adbe.x509.rsa_sha1 format")), "{:?}", s.details);
let trusted = TrustStore { certs: vec![id.certificate.clone()] };
let s = signatures(&open(&pdf), &pdf, &trusted).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
// One changed byte inside the signed range.
let mut bad = pdf.clone();
let i = bad.windows(8).position(|w| w == b"(Legacy)").unwrap() + 1;
bad[i] = b'l';
let s = signatures(&open(&bad), &bad, &trusted).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
}
/// `fixture()` protected with an empty user password (opens without asking, like most
/// "secured" forms) and an owner password, then saved.
fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec<u8> {
protected_with(alg, "", permissions)
}
/// `fixture()` protected with the user password `user` and an owner password, then saved.
fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec<u8> {
let mut doc = open(&fixture());
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: alg,
user_password: user,
owner_password: "owner",
permissions,
encrypt_metadata: true,
seed: [17; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
}
/// #1808: a document that asks for its user password, signed and then counter-signed. The
/// first signature no longer covers the whole file, so its revision is reopened to classify the
/// change; that revision is encrypted too, and used to be unreadable without the password,
/// which made the first signature Invalid. It opens with the document's own key.
#[test]
fn a_user_password_document_keeps_its_earlier_signature_valid_when_counter_signed() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone(), id2.certificate.clone()] };
for alg in ALL_ALGORITHMS {
let base = protected_with(alg, "secret", -1);
assert!(Document::open_with_password(Arc::new(base.clone()), None).is_err(), "{alg:?}: the file asks for its password");
let signed = pdfcraft_sign::sign(&open_pw(&base, Some("secret")), &id, &opts()).unwrap();
let both = pdfcraft_sign::sign(&open_pw(&signed, Some("secret")), &id2, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let sigs = signatures(&open_pw(&both, Some("secret")), &both, &trust);
let first = sigs.iter().find(|s| s.field == "Signature1").unwrap();
assert_eq!(first.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{alg:?}: {:?}", first.details);
assert_eq!(first.status, Status::Valid, "{alg:?}: {:?}", first.details);
let second = sigs.iter().find(|s| s.field == "Approval").unwrap();
assert_eq!((second.status, second.revision), (Status::Valid, 3), "{alg:?}: {:?}", second.details);
// Opened as owner, the same.
let sigs = signatures(&open_pw(&both, Some("owner")), &both, &trust);
assert!(sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Valid), "{alg:?}");
// Tampering inside the first signature's range still breaks both.
let mut tampered = both.clone();
let at = base.len() / 2;
tampered[at] ^= 1;
let tampered_sigs = signatures(&open_pw(&tampered, Some("secret")), &tampered, &trust);
assert!(tampered_sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Invalid), "{alg:?}");
}
}
fn open_pw(b: &[u8], pw: Option<&str>) -> Document {
Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap()
}
/// Filling in forms and signing existing fields only (bits 3, 7–10): what Acrobat's "Filling
/// in form fields, and signing existing signature fields" writes.
const FILL_AND_SIGN_ONLY: i32 = -3132;
const ALL_ALGORITHMS: [pdfcraft_cos::Algorithm; 4] =
[pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256];
#[test]
fn encrypted_documents_are_signed_with_an_unencrypted_contents() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone(), id2.certificate.clone()] };
for alg in ALL_ALGORITHMS {
let base = protected(alg, -1);
let doc = open_pw(&base, None);
assert_eq!(doc.security().unwrap().auth(), pdfcraft_cos::Auth::User);
let signed = pdfcraft_sign::sign(&doc, &id, &opts()).unwrap();
assert!(signed.starts_with(&base), "{alg:?}: an incremental update");
let added = String::from_utf8_lossy(&signed[base.len()..]);
assert!(added.contains("/Encrypt") && added.contains("/ID"), "{alg:?}: the update keeps the file's security");
assert!(!added.contains("I approve") && !added.contains("London"), "{alg:?}: the new strings are encrypted");
assert!(added.contains("/Contents <30"), "{alg:?}: the CMS is written in the clear");
let reopened = open_pw(&signed, None);
assert!(reopened.security().is_some());
let sigs = signatures(&reopened, &signed, &trust);
let s = sigs.iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{alg:?}: {:?}", s.details);
assert_eq!(s.modification, Modification::None);
assert_eq!((s.reason.as_deref(), s.location.as_deref()), (Some("I approve this document"), Some("London")), "{alg:?}");
assert!(s.visible && s.revision == 2 && s.signed_len == signed.len());
// The parsed /Contents is the CMS itself, not "decrypted" noise.
let cms = reopened
.scan_objects()
.filter_map(|(_, o)| o.as_dict().filter(|d| d.name(b"Type") == Some(b"Sig")).and_then(|d| d.get(b"Contents").cloned()))
.next()
.unwrap();
assert_eq!(cms.as_string().unwrap().bytes.first(), Some(&0x30), "{alg:?}");
// Counter-signing the existing field keeps the first signature valid.
let both = pdfcraft_sign::sign(&reopened, &id2, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let sigs = signatures(&open_pw(&both, None), &both, &trust);
let first = sigs.iter().find(|s| s.field == "Signature1").unwrap();
assert_eq!(first.status, Status::Valid, "{alg:?}: {:?}", first.details);
assert_eq!(first.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{alg:?}: {:?}", first.details);
let second = sigs.iter().find(|s| s.field == "Approval").unwrap();
assert_eq!((second.status, second.revision), (Status::Valid, 3), "{alg:?}: {:?}", second.details);
// The page text is still encrypted, and tampering with its ciphertext breaks both.
assert!(!String::from_utf8_lossy(&both).contains("Contract text"));
let mut tampered = both.clone();
let at = base.len() / 2;
tampered[at] ^= 1;
let tampered_sigs = signatures(&open_pw(&tampered, None), &tampered, &trust);
assert!(tampered_sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Invalid), "{alg:?}");
}
}
#[test]
fn an_encrypted_documents_signature_graphic_is_encrypted_with_it() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for alg in ALL_ALGORITHMS {
let base = protected(alg, -1);
let signed = pdfcraft_sign::sign(&open_pw(&base, None), &id, &SignOptions { graphic: Some(picture(true)), ..opts() }).unwrap();
let doc = open_pw(&signed, None);
let s = signatures(&doc, &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!((s.status, s.modification), (Status::Valid, Modification::None), "{alg:?}");
// Read back through the security handler, the pixels and their mask are exact; a stream
// written in the clear would be "decrypted" into noise and fail to inflate.
let (content, res) = appearance_in(&doc, "Signature1");
assert!(content.contains("/Im1 Do"), "{alg:?}: {content}");
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.decoded().unwrap()[..3], [10, 20, 30], "{alg:?}");
let Object::Stream(mask) = &*doc.resolve(im.dict.get(b"SMask").unwrap()) else { panic!() };
assert_eq!(mask.decoded().unwrap()[..2], [128, 255], "{alg:?}");
}
}
#[test]
fn encrypted_documents_are_signed_only_as_their_permissions_allow() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let base = protected(pdfcraft_cos::Algorithm::Aes256, FILL_AND_SIGN_ONLY);
let user = open_pw(&base, None);
let refused = |r: Result<Vec<u8>, SignError>, what: &str| match r {
Err(SignError::Pdf(m)) => assert!(m.contains(what) && m.contains("permissions password"), "{m}"),
other => panic!("expected a refusal, got {:?}", other.map(|b| b.len())),
};
// A new field is a form change the settings don't allow.
refused(pdfcraft_sign::sign(&user, &id, &opts()), "adding new signature fields");
// So is certifying, even in an existing field.
refused(pdfcraft_sign::sign(&user, &id, &SignOptions { field: Some("Approval".into()), certify: Some(2), ..opts() }), "certifying");
// Signing the existing field is allowed.
let signed = pdfcraft_sign::sign(&user, &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let s = signatures(&open_pw(&signed, None), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!((s.field.as_str(), s.status), ("Approval", Status::Valid), "{:?}", s.details);
// Settings that allow no form filling refuse even that (print only).
let print_only = open_pw(&protected(pdfcraft_cos::Algorithm::Aes256, -3900), None);
refused(pdfcraft_sign::sign(&print_only, &id, &SignOptions { field: Some("Approval".into()), ..opts() }), "signing");
// The owner may do anything: a new, certifying signature.
let owner = open_pw(&base, Some("owner"));
let certified = pdfcraft_sign::sign(&owner, &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
let s = signatures(&open_pw(&certified, None), &certified, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert_eq!(s.certify, Some(2), "{:?}", s.details);
}
/// A security change that hasn't been saved would turn the signing save into a full rewrite that
/// applies or removes protection: signing refuses until it is saved.
#[test]
fn signing_waits_for_a_pending_security_change_to_be_saved() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let mut owner = open_pw(&protected(pdfcraft_cos::Algorithm::Aes256, FILL_AND_SIGN_ONLY), Some("owner"));
owner.remove_encryption();
match pdfcraft_sign::sign(&owner, &id, &opts()) {
Err(SignError::Pdf(m)) => assert!(m.contains("save the document's new security settings"), "{m}"),
other => panic!("expected a refusal, got {:?}", other.map(|b| b.len())),
}
}
#[test]
fn encrypted_documents_take_a_document_timestamp() {
let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } };
let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY);
let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap();
assert!(stamped.starts_with(&base));
let s = signatures(&open_pw(&stamped, None), &stamped, &TrustStore { certs: vec![tsa.id.certificate.clone()] })
.into_iter()
.find(|s| s.doc_timestamp)
.unwrap();
assert_eq!((s.status, s.timestamp_time), (Status::Valid, Some(tsa.time)), "{:?}", s.details);
let print_only = open_pw(&protected(pdfcraft_cos::Algorithm::Aes128, -3900), None);
assert!(matches!(pdfcraft_sign::timestamp_document(&print_only, &tsa, "D:20261006120000Z"), Err(SignError::Pdf(_))));
}
/// A visible signature draws its details in Helvetica: a reason or location it can't draw is
/// refused with the character and what to do instead, rather than drawn as `?`. An
/// invisible signature stores the same reason as text and signs.
#[test]
fn visible_appearance_text_helvetica_cannot_draw_is_refused() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let shown = pdfcraft_sign::Appearance { reason: true, location: true, ..pdfcraft_sign::Appearance::default() };
let chinese = SignOptions { reason: Some("同意此文件".into()), appearance: shown.clone(), ..opts() };
let err = pdfcraft_sign::sign(&open(&fixture()), &id, &chinese).unwrap_err().to_string();
assert!(err.contains("\"同\" (U+540C)") && err.contains("the reason") && err.contains("sign without a visible appearance"), "{err}");
let located = SignOptions { location: Some("北京".into()), appearance: shown, ..opts() };
assert!(pdfcraft_sign::sign(&open(&fixture()), &id, &located).unwrap_err().to_string().contains("the location"));
// Left out of the appearance, or signed invisibly: it signs, and the reason is kept as text.
let hidden = SignOptions { reason: Some("同意此文件".into()), ..opts() };
assert!(!hidden.appearance.reason, "the default appearance doesn't show the reason");
pdfcraft_sign::sign(&open(&fixture()), &id, &hidden).unwrap();
let invisible = SignOptions { rect: None, ..chinese };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &invisible).unwrap();
let doc = open(&signed);
let s = signatures(&doc, &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.reason.as_deref(), Some("同意此文件"));
}
/// The labels are the caller's words (a translation), so they are checked as the details are: a
/// label Helvetica can't draw is refused with the label and the character, never drawn as `?`.
/// Only labels that are drawn count: one whose detail is hidden, or empty, or with the labels
/// turned off or the signature invisible, signs. Labels it can draw are what the stamp says.
#[test]
fn appearance_labels_helvetica_cannot_draw_are_refused() {
use pdfcraft_sign::{Appearance, AppearanceLabels};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
assert_eq!(AppearanceLabels::default().first_undrawable(), None, "the English labels are drawable");
let chinese = AppearanceLabels { signed_by: "数字签名者".into(), reason: "原因:".into(), ..AppearanceLabels::default() };
assert_eq!(chinese.first_undrawable(), Some(("signed_by", '数')));
let sign = |labels: AppearanceLabels, appearance: Appearance, o: SignOptions| {
pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { appearance_labels: labels, appearance, ..o })
};
// The name is shown with its label (the default appearance): refused, saying which and what.
let err = sign(chinese.clone(), Appearance::default(), opts()).unwrap_err();
let m = err.to_string();
assert!(matches!(err, SignError::Unsupported(_)), "{err:?}");
assert!(m.contains("\"数\" (U+6570)") && m.contains("the signed_by label") && m.contains("leave the labels out"), "{m}");
let russian_date = AppearanceLabels { date: "Дата: ".into(), ..AppearanceLabels::default() };
let m = sign(russian_date.clone(), Appearance::default(), opts()).unwrap_err().to_string();
assert!(m.contains("the date label") && m.contains("U+0414"), "{m}");
let reason_only = AppearanceLabels { reason: "原因:".into(), ..AppearanceLabels::default() };
let shown = Appearance { reason: true, ..Appearance::default() };
assert!(sign(reason_only.clone(), shown.clone(), opts()).unwrap_err().to_string().contains("the reason label"));
// Not drawn: the reason hidden, or empty; the labels off; the signature invisible.
sign(reason_only.clone(), Appearance::default(), opts()).unwrap();
sign(reason_only.clone(), shown.clone(), SignOptions { reason: None, ..opts() }).unwrap();
sign(reason_only, shown, SignOptions { reason: Some(String::new()), ..opts() }).unwrap();
sign(chinese.clone(), Appearance { labels: false, ..Appearance::default() }, opts()).unwrap();
sign(russian_date, Appearance { date: false, ..Appearance::default() }, opts()).unwrap();
sign(chinese, Appearance::default(), SignOptions { rect: None, ..opts() }).unwrap();
// Labels Helvetica can draw are stamped as given, in place of the English ones.
let german = AppearanceLabels {
signed_by: "Digital signiert von".into(),
distinguished_name: "DN: ".into(),
reason: "Grund: ".into(),
location: "Ort: ".into(),
date: "Datum: ".into(),
};
let all = Appearance { reason: true, location: true, ..Appearance::default() };
let signed = sign(german, all, opts()).unwrap();
let (content, _) = appearance_of(&signed, "Signature1");
for word in ["(Digital", "(Grund:", "(Ort:", "(Datum:"] {
assert!(content.contains(word), "{word}: {content}");
}
assert!(!content.contains("Reason") && !content.contains("Date:") && !content.contains('?'), "{content}");
}
/// A visible signature with Central European characters (č, ć, ž, š) embeds the bundled
/// Inter font and draws them cleanly instead of refusing or writing `?`.
#[test]
fn visible_appearance_draws_central_european_characters_with_embedded_font() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let name = Name::build("Luka Petravić", "Oddelek", "Podjetje d.o.o.", "luka@example.com", "SI");
let cert = Certificate::self_signed(&name, &id.key, Time::from_unix(1_700_000_000), 5, &[1]).unwrap();
let luka_id = DigitalId { key: id.key, certificate: cert, chain: Vec::new(), friendly_name: None };
let shown =
pdfcraft_sign::Appearance { name: true, distinguished_name: true, reason: true, location: true, ..pdfcraft_sign::Appearance::default() };
let sl = SignOptions {
rect: Some([150.0, 20.0, 280.0, 70.0]),
reason: Some("Podpisano s potrdilom".into()),
location: Some("Ljubljana, Slovenija".into()),
appearance: shown,
..opts()
};
let signed = pdfcraft_sign::sign(&open(&fixture()), &luka_id, &sl).unwrap();
let (content, res) = appearance_of(&signed, "Signature1");
// Verify that the appearance stream does not write `?` in place of non-WinAnsi characters.
assert!(!content.contains('?'), "appearance content should not write '?' for Central European characters: {content}");
// Verify an embedded TrueType font was added to the appearance resources.
let fonts = res.get(b"Font").and_then(|o| o.as_dict()).unwrap();
assert!(fonts.contains(b"PCFull0"), "appearance should reference the embedded font: {fonts:?}");
// Verify signature validation still passes.
let doc = open(&signed);
let trust = TrustStore { certs: vec![luka_id.certificate.clone()] };
let s = signatures(&doc, &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid);
assert_eq!(s.reason.as_deref(), Some("Podpisano s potrdilom"));
}
/// A label Helvetica can't draw is refused even when a detail already needs the embedded font: the
/// labels never choose the font, so a Japanese label beside a Latin Extended name is never written
/// as the wrong glyphs.
#[test]
fn appearance_label_outside_helvetica_is_refused_beside_an_embedded_font_name() {
use pdfcraft_sign::{Appearance, AppearanceLabels};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let name = Name::build("Luka Petravić", "Oddelek", "Podjetje d.o.o.", "luka@example.com", "SI");
let cert = Certificate::self_signed(&name, &id.key, Time::from_unix(1_700_000_000), 5, &[1]).unwrap();
let luka_id = DigitalId { key: id.key, certificate: cert, chain: Vec::new(), friendly_name: None };
let japanese = AppearanceLabels { signed_by: "署名者".into(), ..AppearanceLabels::default() };
let o = SignOptions { rect: Some([150.0, 20.0, 280.0, 70.0]), appearance: Appearance::default(), appearance_labels: japanese, ..opts() };
let err = pdfcraft_sign::sign(&open(&fixture()), &luka_id, &o).unwrap_err();
assert!(matches!(err, SignError::Unsupported(_)), "{err:?}");
assert!(err.to_string().contains("the signed_by label"), "{err}");
}
/// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on
/// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the
/// sentences the modules that read a signature word for themselves are pushed as notes and quirks,
/// and the summaries are the only words `summary` chooses between, so all of them can be read off
/// the files. An interface then has these to translate and only these.
#[test]
fn wordings_are_complete() {
let source = include_str!("../../src/pdf.rs");
// The modules below `pdf` word whole sentences too: how a signature value had to be read, and
// what a certificate does not allow.
let elsewhere = [
include_str!("../../src/x509.rs"),
include_str!("../../src/cms.rs"),
include_str!("../../src/keys.rs"),
include_str!("../../src/rsa_pad.rs"),
];
/// A sentence said to the user, as opposed to an OID, a name or a tag: it reads as English.
fn is_sentence(s: &str) -> bool {
s.len() > 24 && s.ends_with('.') && s.contains(' ') && s.starts_with(|c: char| c.is_ascii_uppercase())
}
// The literals of a stretch of the file, each as it is written there.
fn literals(text: &str) -> Vec<&str> {
let (mut out, mut rest) = (Vec::new(), text);
while let Some(at) = rest.find('"') {
rest = &rest[at + 1..];
let Some(end) = rest.find('"') else { break };
out.push(&rest[..end]);
rest = &rest[end + 1..];
}
out
}
/// The literals written right after each of `shapes`, wherever they appear in `text`.
fn after<'a>(text: &'a str, shapes: &[&str]) -> Vec<&'a str> {
let mut out = Vec::new();
for shape in shapes {
let mut rest = text;
while let Some(at) = rest.find(shape) {
rest = &rest[at + shape.len()..];
// A long sentence is written on the line below, and `plain` passes its own on.
let Some(text) = rest.trim_start().strip_prefix('"') else { continue };
let Some(end) = text.find('"') else { break };
out.push(&text[..end]);
rest = text;
}
}
out
}
let mut said = after(source, &["Detail::plain(", "Detail::of("]);
let at_hand = ["Detail::plain(", "Detail::of(", ".push(", "Some("];
said.extend(elsewhere.iter().flat_map(|s| after(s, &at_hand)).filter(|s| is_sentence(s)));
let summary = source.split("pub fn summary").nth(1).and_then(|s| s.split("\n }").next()).unwrap_or_default();
said.extend(literals(summary));
assert!(said.len() >= 40, "the sentences are not being read off the files: {said:#?}");
let missing: Vec<&&str> = said.iter().filter(|s| !WORDINGS.contains(s)).collect();
assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}");
let unsaid: Vec<&&str> = WORDINGS.iter().filter(|s| !said.contains(s)).collect();
assert!(unsaid.is_empty(), "WORDINGS lists sentences this crate no longer says: {unsaid:#?}");
// And every word it calls a change by is in `kind::ALL`, which is what an interface translates.
let vocabulary = source.split("pub mod kind {").nth(1).and_then(|s| s.split("\n}").next()).unwrap_or_default();
let words = literals(vocabulary);
assert!(words.len() >= 11, "the vocabulary is not being read off the file: {words:#?}");
let missing: Vec<&&str> = words.iter().filter(|w| !kind::ALL.contains(w)).collect();
assert!(missing.is_empty(), "add these to kind::ALL, and a translation of each to every interface: {missing:#?}");
let gone: Vec<&&str> = kind::ALL.iter().filter(|w| !words.contains(w)).collect();
assert!(gone.is_empty(), "kind::ALL lists words that are no longer there: {gone:#?}");
}
/// Each detail says itself: in English it comes to the sentence it carries, and its wording is one an
/// interface can look up. This is what the Signatures panel relies on to read in another language.
#[test]
fn details_say_themselves() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let sigs = signatures(&open(&signed), &signed, &TrustStore::default());
let details: Vec<&Detail> = sigs.iter().flat_map(|s| s.details.iter()).collect();
assert!(!details.is_empty());
for d in details {
assert_eq!(d.said(&in_english), d.message);
assert!(WORDINGS.contains(&d.pattern), "{:?} is not in WORDINGS", d.pattern);
// A wording has exactly as many holes as its fill has things to put in them.
let holes = d.pattern.matches("{}").count();
let wanted = match &d.fill {
pdfcraft_sign::Fill::Nothing => 0,
pdfcraft_sign::Fill::Names(..) => 2,
_ => 1,
};
assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern);
}
}
/// `signed` with one more revision appended by hand: a new definition of object 4 (the page's
/// content stream) that draws `text`, then a cross-reference table section whose subsections are
/// what `xref` returns for the offset of that definition. The section continues the signed chain.
fn append_content_revision(signed: &[u8], text: &str, xref: impl Fn(usize) -> String) -> Vec<u8> {
let at = signed.windows(9).rposition(|w| w == b"startxref").unwrap() + 9;
let prev: String = signed[at..].iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect();
let mut out = signed.to_vec();
let content = format!("BT /F1 14 Tf 20 250 Td ({text}) Tj ET");
let object = out.len();
out.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len()).as_bytes());
let section = out.len();
out.extend_from_slice(
format!("xref\n{}trailer\n<< /Size 60 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n", xref(object)).as_bytes(),
);
out
}
fn xref_entry(offset: usize) -> String {
format!("{offset:010} 00000 n \n")
}
/// The reader that judges changes and the renderer that draws the page resolve some
/// cross-reference entries differently. A revision appended after signing can redefine the page's
/// content and give the object an entry the reader sets aside (an in-use entry at offset 0, or
/// a second entry after one that still names the signed object): the reader then keeps comparing
/// the signed object with itself while the page shows the new one. Such an entry is never a
/// permitted change.
#[test]
fn a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
for certify in LEVELS {
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap();
// Where the signed revision keeps the page's content stream.
let signed_at = signed.windows(8).position(|w| w == b"\n4 0 obj").unwrap() + 1;
// Each writes the section's subsections, given where the new object 4 was put.
type Subsections = Box<dyn Fn(usize) -> String>;
let attacks: [(&str, Subsections); 3] = [
("an in-use entry at offset 0", Box::new(|_| format!("4 1\n{}", xref_entry(0)))),
("a second entry for the object", Box::new(move |new| format!("4 1\n{}4 1\n{}", xref_entry(signed_at), xref_entry(new)))),
// The plain case, which was always caught: one entry naming the new object.
("an ordinary entry", Box::new(|new| format!("4 1\n{}", xref_entry(new)))),
];
for (name, xref) in attacks {
let edited = append_content_revision(&signed, "Forged text", xref);
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{certify:?}, {name}: {:?}", s.details);
assert!(
matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")),
"{certify:?}, {name}: {:?}",
s.modification
);
}
}
}
/// An entry that does not point at the object it names leaves the reader to find the object by
/// searching the file. What it finds may equal the signed object, yet another reader may search
/// differently: the object cannot be called unchanged.
#[test]
fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
// A copy of the signed content, named by an entry two bytes into its `4 0 obj` header.
let edited = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new + 2)));
let doc = open(&edited);
assert!(!doc.xref_entry_is_exact(4) && doc.xref_entry_is_exact(3));
let s = signatures(&doc, &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification);
// The same copy under an entry that does point at it is the unchanged object it looks like.
let honest = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new)));
let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
}
/// `fixture()` with the page's resources indirect: object 7 is the resources dictionary, 8 its
/// `/Font` dictionary, 9 an `/ExtGState` the content uses, and 5 the font as before.
fn fixture_with_indirect_resources() -> Vec<u8> {
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 7 0 R /Annots [6 0 R] >>",
b"<< /Length 52 >>\nstream\n/GS1 gs BT /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 >>",
b"<< /Font 8 0 R /ExtGState << /GS1 9 0 R >> >>",
b"<< /F1 5 0 R >>",
b"<< /Type /ExtGState /ca 1 >>",
];
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
}
/// A signed page's resources are judged by the role they held when signed, not by the keys a
/// later revision writes into them (#1787): rewriting the page's resources dictionary, a
/// category dictionary, a font or a graphics state changes what the page shows, however the
/// new version is labelled (/FT, /DR, /T + /Kids, /Subtype /Widget, /Type /Annot + /Rect, which
/// otherwise read as form filling or a comment, permitted under P=2 and P=3). A category
/// dictionary that only gained a name changes nothing drawn and is judged as before.
#[test]
fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() {
/// Replace object `num` with a dictionary; each attack runs once per certification level,
/// so the entries are cloned into each.
fn set(num: u32, entries: Vec<(&'static str, Object)>) -> impl Fn(&mut Document) {
move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, Object::Dict(dict_of(entries.clone())))
}
/// A second font, so a name can be made to resolve elsewhere.
fn courier(doc: &mut Document) -> pdfcraft_cos::ObjRef {
doc.add(Object::Dict(dict_of(vec![
("Type", Object::name("Font")),
("Subtype", Object::name("Type1")),
("BaseFont", Object::name("Courier")),
])))
}
// The resources dictionary: another font under the same name, plus /FT.
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
let f = courier(doc);
set(
7,
vec![
("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))),
("ExtGState", Object::Dict(dict_of(vec![("GS1", oref(9))]))),
("FT", Object::name("Tx")),
],
)(doc);
});
// The /Font dictionary: the name now resolves elsewhere, plus /DR.
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
let f = courier(doc);
set(8, vec![("F1", Object::Ref(f)), ("DR", Object::Dict(dict_of(vec![])))])(doc);
});
// The graphics state: everything drawn through it becomes invisible, plus /T and /Kids.
assert_refused_at_every_level(
&fixture_with_indirect_resources(),
"page content",
set(
9,
vec![
("Type", Object::name("ExtGState")),
("ca", Object::Real(0.0)),
("T", Object::String(PdfString::literal("x"))),
("Kids", Object::Array(vec![])),
],
),
);
// The font itself, relabelled a widget.
assert_refused_at_every_level(
&fixture_with_indirect_resources(),
"page content",
set(5, vec![("Type", Object::name("Font")), ("BaseFont", Object::name("Courier")), ("Subtype", Object::name("Widget"))]),
);
// The resources dictionary relabelled a comment (permitted under P=3 and by an approval signature).
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
let f = courier(doc);
set(
7,
vec![
("Type", Object::name("Annot")),
("Subtype", Object::name("Square")),
("Rect", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)])),
("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))),
],
)(doc);
});
// The same page resources inline in the page keep being page content through the page itself.
assert_refused_at_every_level(&fixture(), "page content", |doc| {
let f = courier(doc);
doc.update_dict(pdfcraft_cos::ObjRef { num: 3, generation: 0 }, |page| {
page.set(b"Resources".to_vec(), Object::Dict(dict_of(vec![("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))])))])));
})
.unwrap();
});
// Positive control: a /Font dictionary that only gained a name changes nothing the signed
// content draws, so it is not page content (it is still an unclassified change, refused as
// "other", as before).
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture_with_indirect_resources()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap();
let grown = edit_after(&signed, |doc| {
let f = courier(doc);
set(8, vec![("F1", oref(5)), ("F2", Object::Ref(f))])(doc);
});
let s = signatures(&open(&grown), &grown, &trust).into_iter().find(|s| s.signed).unwrap();
assert!(
matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&"page content") && k.contains(&"other changes")),
"{:?}",
s.modification
);
}
/// A later revision that only points the trailer at another catalog already in the signed file
/// shows a different document while every signed object is unchanged. That is not permitted.
#[test]
fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() {
let base = fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
&[
b"<< /Type /Catalog /Pages 8 0 R /AcroForm << /Fields [6 0 R] >> >>",
b"<< /Type /Pages /Kids [9 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 8 0 R /MediaBox [0 0 300 300] /Contents 10 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>",
b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract void) Tj ET\nendstream",
],
);
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&base), &id, &SignOptions { field: Some("Approval".into()), rect: None, ..opts() }).unwrap();
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
let switched = edit_after(&signed, |doc| doc.trailer_mut().set(b"Root".to_vec(), oref(7)));
let s = signatures(&open(&switched), &switched, &trust).into_iter().find(|s| s.signed).expect("the signature is still listed");
assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
}
/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued
/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor
/// a signature's embedded one.
#[test]
fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() {
let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), 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();
let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details);
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] };
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert!(s.timestamp);
assert_eq!(s.timestamp_time, None, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details);
}
/// A signed revision's objects can't be freed or its signature widget rewritten without the
/// change being seen: the classifier used to look only at objects still present, and let a
/// signature widget's geometry and appearance change count as a signature.
#[test]
fn freeing_a_signed_object_or_rewriting_the_signed_widget_is_a_page_content_change() {
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 signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let freed = check(&edit_after(&signed, |doc| doc.free(pdfcraft_cos::ObjRef { num: 5, generation: 0 })));
assert_eq!(freed.status, Status::Invalid, "{:?}", freed.modification);
assert!(matches!(&freed.modification, Modification::Disallowed(k) if k.contains(&kind::PAGE_CONTENT)), "{:?}", freed.modification);
let widget = open(&signed)
.object_numbers()
.into_iter()
.map(|num| pdfcraft_cos::ObjRef { num, generation: 0 })
.find(|r| open(&signed).get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && d.contains(b"V")))
.unwrap();
let moved = check(&edit_after(&signed, |doc| {
doc.update_dict(widget, |d| d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)])))
.unwrap();
}));
assert_eq!(moved.status, Status::Invalid, "{:?}", moved.modification);
assert!(matches!(&moved.modification, Modification::Disallowed(k) if k.contains(&kind::PAGE_CONTENT)), "{:?}", moved.modification);
}
/// An existing signature field whose `/V` is null is unsigned: signing it rewrites its widget,
/// and that is a signature, not a change to what an earlier signature shows.
#[test]
fn signing_a_field_with_a_null_value_after_another_signature_keeps_the_first_valid() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let first = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let all = signatures(&open(&second), &second, &trust);
assert!(
all.iter().filter(|s| s.signed).all(|s| s.status == Status::Valid),
"{:?}",
all.iter().map(|s| (&s.status, &s.modification)).collect::<Vec<_>>()
);
}