1981 lines
109 KiB
Rust
1981 lines
109 KiB
Rust
//! Signing and validating whole documents.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental};
|
||
use pdfcraft_sign::{
|
||
Detail, Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures,
|
||
};
|
||
|
||
fn data(name: &str) -> Vec<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> {
|
||
let objs: Vec<&[u8]> = vec![
|
||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
|
||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>",
|
||
b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream",
|
||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>",
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||
out.extend_from_slice(o);
|
||
out.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
let xref = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
fn open(b: &[u8]) -> Document {
|
||
Document::open(Arc::new(b.to_vec())).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn standalone_timestamp_scan_survives_cache_batches_and_object_streams() {
|
||
let mut doc = open(&fixture());
|
||
let filler: Vec<_> = (0..300).map(|i| Object::Ref(doc.add(Object::Int(i)))).collect();
|
||
let mut stamp = pdfcraft_cos::Dict::new();
|
||
stamp.set(b"Type".to_vec(), Object::name("Sig"));
|
||
stamp.set(b"SubFilter".to_vec(), Object::name("ETSI.RFC3161"));
|
||
stamp.set(b"ByteRange".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(2), Object::Int(1)]));
|
||
stamp.set(b"Contents".to_vec(), Object::String(PdfString::literal("invalid-token")));
|
||
let stamp = doc.add(Object::Dict(stamp));
|
||
doc.update_dict(doc.root().unwrap(), |catalog| {
|
||
catalog.set(b"UninspectedObjects".to_vec(), Object::Array(filler));
|
||
catalog.set(b"UninspectedTimestamp".to_vec(), Object::Ref(stamp));
|
||
})
|
||
.unwrap();
|
||
let bytes = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
|
||
let doc = open(&bytes);
|
||
assert!(doc.object_numbers().len() > 300);
|
||
let listed = signatures(&doc, &bytes, &TrustStore::default());
|
||
assert!(listed.iter().any(|s| s.doc_timestamp && s.status != Status::Valid));
|
||
assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed));
|
||
}
|
||
|
||
fn opts() -> SignOptions {
|
||
SignOptions {
|
||
page: 0,
|
||
rect: Some([20.0, 100.0, 220.0, 150.0]),
|
||
reason: Some("I approve this document".into()),
|
||
location: Some("London".into()),
|
||
date: "D:20261002120000+01'00'".into(),
|
||
..SignOptions::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn signing_then_validating_with_and_without_trust() {
|
||
for file in ["rsa-aes.p12", "ec-p256.p12", "ec-p384.p12", "chain.p12"] {
|
||
let id = pkcs12::open(&data(file), "test").unwrap();
|
||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||
assert!(signed.starts_with(&fixture()), "{file}: an incremental update");
|
||
let doc = open(&signed);
|
||
let sigs = signatures(&doc, &signed, &TrustStore::default());
|
||
let s = sigs.iter().find(|s| s.signed).expect("a signed field");
|
||
assert_eq!(s.field, "Signature1");
|
||
assert_eq!(s.status, Status::Unknown, "{file}: {:?}", s.details);
|
||
assert_eq!(s.modification, Modification::None);
|
||
assert_eq!(s.signer.as_deref(), id.certificate.subject.common_name());
|
||
assert_eq!((s.reason.as_deref(), s.location.as_deref(), s.page), (Some("I approve this document"), Some("London"), Some(0)));
|
||
assert!(s.visible && s.signed_len == signed.len() && s.revision == 2);
|
||
assert!(s.details.iter().any(|d| d.message.contains("identity is unknown")));
|
||
// The unsigned field is listed too.
|
||
assert!(sigs.iter().any(|s| s.field == "Approval" && !s.signed));
|
||
// Trusting the signer (or its root) makes it valid.
|
||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||
let trusted = signatures(&doc, &signed, &TrustStore { certs: vec![anchor] });
|
||
let s = trusted.iter().find(|s| s.signed).unwrap();
|
||
assert_eq!(s.status, Status::Valid, "{file}: {:?}", s.details);
|
||
assert_eq!(s.chain.len(), if file == "chain.p12" { 2 } else { 1 });
|
||
// Changing one signed byte breaks it.
|
||
let mut tampered = signed.clone();
|
||
let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
|
||
tampered[i] = b'K';
|
||
let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||
assert_eq!(s.status, Status::Invalid);
|
||
assert!(s.details[0].message.contains("altered or corrupted"), "{:?}", s.details);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn signing_an_existing_field_and_counter_signing() {
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let first = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
|
||
let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||
let second = pdfcraft_sign::sign(&open(&first), &id2, &opts()).unwrap();
|
||
let doc = open(&second);
|
||
let sigs = signatures(&doc, &second, &TrustStore::default());
|
||
let a = sigs.iter().find(|s| s.field == "Approval").unwrap();
|
||
assert!(a.signed);
|
||
assert_eq!(a.rect, Some([150.0, 20.0, 280.0, 70.0]));
|
||
assert_eq!(a.revision, 2);
|
||
assert_eq!(a.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", a.details);
|
||
assert_eq!(a.status, Status::Unknown);
|
||
let b = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||
assert_eq!((b.revision, b.modification.clone()), (3, Modification::None));
|
||
assert!(matches!(
|
||
pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }),
|
||
Err(pdfcraft_sign::SignError::Pdf(_))
|
||
));
|
||
}
|
||
|
||
/// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned:
|
||
/// it is listed as unsigned and can be signed by name.
|
||
#[test]
|
||
fn a_field_with_a_null_value_is_unsigned_and_can_be_signed() {
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 };
|
||
let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Null)).unwrap());
|
||
let indirect = edit_after(&fixture(), |doc| {
|
||
let null = doc.add(Object::Null);
|
||
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(null))).unwrap();
|
||
});
|
||
for (what, unsigned) in [("direct null", direct), ("indirect null", indirect)] {
|
||
let doc = open(&unsigned);
|
||
assert!(doc.get(field).as_dict().is_some_and(|d| d.contains(b"V")), "{what}: the fixture keeps its /V");
|
||
let listed = signatures(&doc, &unsigned, &TrustStore::default());
|
||
assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{what}");
|
||
let signed =
|
||
pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap_or_else(|e| panic!("{what}: {e:?}"));
|
||
let listed = signatures(&open(&signed), &signed, &TrustStore::default());
|
||
assert!(listed.iter().any(|s| s.field == "Approval" && s.signed), "{what}");
|
||
assert!(!listed.iter().any(|s| s.field == "Signature1"), "{what}: no new field");
|
||
}
|
||
}
|
||
|
||
/// Follow-up to #1374: a signature field whose /V is neither null nor a signature dictionary is
|
||
/// refused by name with an error saying so, instead of being overwritten or called signed.
|
||
#[test]
|
||
fn a_field_whose_value_is_not_a_signature_is_refused_by_name() {
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 };
|
||
let values = [
|
||
("empty string", Object::String(PdfString::literal(""))),
|
||
("number", Object::Int(5)),
|
||
("name", Object::name("Off")),
|
||
("array", Object::Array(Vec::new())),
|
||
];
|
||
for (what, value) in values {
|
||
let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), value.clone())).unwrap());
|
||
let indirect = edit_after(&fixture(), |doc| {
|
||
let v = doc.add(value.clone());
|
||
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(v))).unwrap();
|
||
});
|
||
for (how, bytes) in [("direct", direct), ("indirect", indirect)] {
|
||
let doc = open(&bytes);
|
||
let listed = signatures(&doc, &bytes, &TrustStore::default());
|
||
assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{how} {what}");
|
||
match pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }) {
|
||
Err(SignError::Pdf(e)) => assert_eq!(e, "the value of the field \"Approval\" is not a signature", "{how} {what}"),
|
||
other => panic!("{how} {what}: {:?}", other.map(|b| b.len())),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Append a revision that changes object `num` with `f`.
|
||
fn edit_after(signed: &[u8], f: impl FnOnce(&mut Document)) -> Vec<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);
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||
};
|
||
let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap();
|
||
assert!(signed.starts_with(&fixture()), "still an incremental update");
|
||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||
let trust = TrustStore { certs: vec![anchor.clone(), tsa.id.certificate.clone()] };
|
||
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
|
||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||
assert!(s.timestamp, "{:?}", s.details);
|
||
assert_eq!(s.timestamp_time, Some(tsa.time), "the token's generation time, verified over the signature value");
|
||
assert!(s.details.iter().any(|d| d.message.contains("trusted time")), "{:?}", s.details);
|
||
// A trusted signer with an untrusted TSA: still valid, but the token's time is unverified.
|
||
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap();
|
||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||
assert!(s.timestamp);
|
||
assert_eq!(s.timestamp_time, None);
|
||
assert!(s.details.iter().any(|d| d.message.contains("unverified timestamp")), "{:?}", s.details);
|
||
// Untrusted, the signature stays intact-but-unknown; the timestamp is still noted.
|
||
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||
assert_eq!(s.status, Status::Unknown);
|
||
assert!(s.timestamp && s.timestamp_time.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_malformed_timestamp_response_fails_signing_without_a_file() {
|
||
struct Bad;
|
||
impl TimestampAuthority for Bad {
|
||
fn timestamp(&self, _request: &[u8]) -> Result<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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||
};
|
||
let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap();
|
||
assert!(stamped.starts_with(&fixture()), "an incremental update");
|
||
let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] };
|
||
let s = signatures(&open(&stamped), &stamped, &trust_tsa).into_iter().find(|s| s.doc_timestamp).unwrap();
|
||
assert!(s.doc_timestamp && s.timestamp);
|
||
assert_eq!(s.sub_filter.as_deref(), Some("ETSI.RFC3161"));
|
||
assert_eq!(s.timestamp_time, Some(tsa.time));
|
||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||
assert_eq!(s.modification, Modification::None);
|
||
assert_eq!(s.signed_len, stamped.len());
|
||
// A later byte change breaks the timestamp's imprint.
|
||
let mut tampered = stamped.clone();
|
||
let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
|
||
tampered[i] = b'K';
|
||
let s = signatures(&open(&tampered), &tampered, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap();
|
||
assert_eq!(s.status, Status::Invalid);
|
||
// Combined with a field signature: both are listed, the stamp covers both revisions.
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||
let both = pdfcraft_sign::timestamp_document(&open(&signed), &tsa, "D:20261006130000Z").unwrap();
|
||
let all = signatures(&open(&both), &both, &TrustStore { certs: vec![tsa.id.certificate.clone()] });
|
||
let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||
let allowed = match &field.modification {
|
||
Modification::Allowed(k) => k,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
assert!(allowed.contains(&kind::SIGNATURE), "{allowed:?}");
|
||
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
|
||
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
|
||
}
|
||
|
||
#[test]
|
||
fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() {
|
||
let tsa = TestTsa {
|
||
id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||
};
|
||
let mut source = open(&fixture());
|
||
source
|
||
.update_dict(source.root().unwrap(), |d| {
|
||
d.remove(b"AcroForm");
|
||
})
|
||
.unwrap();
|
||
let bytes = pdfcraft_sign::timestamp_document(&source, &tsa, "D:20261006120000Z").unwrap();
|
||
let doc = open(&bytes);
|
||
let cache = pdfcraft_sign::DigestCache::default();
|
||
let trusted = TrustStore { certs: vec![tsa.id.certificate.clone()] };
|
||
let check = |doc: &Document, bytes: &[u8], trust: &TrustStore| {
|
||
let listed = pdfcraft_sign::pdf::list_cached(doc, bytes, trust, &cache);
|
||
assert_eq!(listed.len(), 1);
|
||
assert!(listed[0].doc_timestamp);
|
||
listed.into_iter().next().unwrap()
|
||
};
|
||
assert_eq!(check(&doc, &bytes, &TrustStore::default()).status, Status::Unknown);
|
||
assert_eq!(check(&doc, &bytes, &trusted).status, Status::Valid);
|
||
assert_eq!(check(&doc, &bytes, &TrustStore::default()).status, Status::Unknown);
|
||
let mut tampered = bytes.clone();
|
||
let at = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
|
||
tampered[at] = b'K';
|
||
assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid);
|
||
assert_eq!(check(&doc, &[], &trusted).status, Status::Invalid);
|
||
let stamp = doc.scan_objects().find(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"SubFilter") == Some(b"ETSI.RFC3161"))).unwrap().0;
|
||
let mut edited = doc.clone();
|
||
edited.update_dict(stamp, |d| d.set(b"ByteRange".to_vec(), Object::Array(vec![Object::Int(-1)]))).unwrap();
|
||
assert_eq!(check(&edited, &bytes, &trusted).status, Status::Invalid);
|
||
assert_eq!(check(&doc, &bytes, &trusted).status, Status::Valid);
|
||
// Even a malformed token in the same byte ranges must be read again after discovery.
|
||
let br = doc.get(stamp).as_dict().unwrap().get(b"ByteRange").unwrap().as_array().unwrap().clone();
|
||
let gap_start = br[1].as_int().unwrap() as usize;
|
||
tampered = bytes.clone();
|
||
tampered[gap_start + 1] = b'F';
|
||
tampered[gap_start + 2] = b'F';
|
||
assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid);
|
||
}
|
||
|
||
#[test]
|
||
fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() {
|
||
use pdfcraft_sign::dss::{self, Evidence};
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||
let evidence =
|
||
Evidence { certs: vec![id.certificate.raw.clone()], ocsps: vec![b"synthetic ocsp".to_vec()], crls: vec![b"synthetic crl".to_vec()] };
|
||
let ltv = dss::embed(&open(&signed), &evidence).unwrap();
|
||
assert!(ltv.starts_with(&signed), "incremental");
|
||
let doc = open(<v);
|
||
// The store is in the catalog with one certificate and the /VRI entry for the signature.
|
||
let root = doc.root().unwrap();
|
||
let dss_dict: pdfcraft_cos::Dict =
|
||
doc.get(root).as_dict().unwrap().get(b"DSS").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap();
|
||
assert_eq!(dss_dict.name(b"Type"), Some(b"DSS".as_slice()));
|
||
assert!(dss_dict.contains(b"Certs") && dss_dict.contains(b"OCSPs") && dss_dict.contains(b"CRLs") && dss_dict.contains(b"VRI"));
|
||
// The earlier signature still validates; the DSS counts as a permitted change.
|
||
let s = signatures(&doc, <v, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||
assert_eq!(s.status, Status::Unknown);
|
||
let allowed = match &s.modification {
|
||
Modification::Allowed(k) => k,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
assert!(allowed.contains(&kind::SECURITY_STORE), "{allowed:?}");
|
||
// Embedding twice keeps one certificate (byte-identical dedup) and stays valid.
|
||
let twice = dss::embed(&doc, &evidence).unwrap();
|
||
let doc2 = open(&twice);
|
||
let dss2: pdfcraft_cos::Dict =
|
||
doc2.get(doc2.root().unwrap()).as_dict().unwrap().get(b"DSS").map(|d| doc2.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap();
|
||
let certs = dss2.get(b"Certs").map(|c| doc2.resolve(c)).and_then(|c| c.as_array().cloned()).unwrap();
|
||
assert_eq!(certs.len(), 1, "byte-identical evidence is not duplicated");
|
||
}
|
||
|
||
#[test]
|
||
fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() {
|
||
use pdfcraft_sign::dss::{self, Evidence};
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let tsa = TestTsa {
|
||
id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||
};
|
||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||
let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap();
|
||
let lta = pdfcraft_sign::timestamp_document(&open(<v), &tsa, "D:20261006120000Z").unwrap();
|
||
let all = signatures(&open(<a), <a, &TrustStore { certs: vec![tsa.id.certificate.clone()] });
|
||
assert_eq!(all.iter().filter(|s| s.signed).count(), 2);
|
||
let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||
assert_eq!(field.status, Status::Unknown);
|
||
let allowed = match &field.modification {
|
||
Modification::Allowed(k) => k,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
assert!(allowed.contains(&kind::SECURITY_STORE) && allowed.contains(&kind::SIGNATURE), "{allowed:?}");
|
||
assert!(!allowed.contains(&kind::PAGE_CONTENT), "{allowed:?}");
|
||
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
|
||
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
|
||
}
|
||
|
||
#[test]
|
||
fn an_embedded_verified_revocation_invalidates_the_signature() {
|
||
use pdfcraft_sign::dss::{self, Evidence};
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||
// The signer's own CRL (self-signed test identity) revokes its certificate, in a window
|
||
// that covers the signing date (October 2026).
|
||
let (this, next) =
|
||
(Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
|
||
let alg = id.key.signature_algorithm(pdfcraft_sign::DigestAlg::Sha256);
|
||
let tbs = der::seq(&[
|
||
&der::int(1),
|
||
&alg,
|
||
&id.certificate.subject.raw,
|
||
&this.encode(),
|
||
&next.encode(),
|
||
&der::seq(&[&der::seq(&[
|
||
&der::uint(&id.certificate.serial),
|
||
&Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 }.encode(),
|
||
])]),
|
||
]);
|
||
let sig = id.key.sign(pdfcraft_sign::DigestAlg::Sha256, &tbs).unwrap();
|
||
let crl = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]);
|
||
let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap();
|
||
let s = signatures(&open(<v), <v, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||
assert!(s.details.iter().any(|d| d.message.contains("revoked") && d.message.contains("CRL")), "{:?}", s.details);
|
||
}
|
||
|
||
#[test]
|
||
fn validates_a_signature_made_by_openssl() {
|
||
let bytes = data("openssl-signed.pdf");
|
||
let doc = open(&bytes);
|
||
let s = signatures(&doc, &bytes, &TrustStore::default()).into_iter().next().unwrap();
|
||
assert_eq!((s.field.as_str(), s.sub_filter.as_deref()), ("OpenSSL", Some("adbe.pkcs7.detached")));
|
||
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
|
||
assert_eq!(s.signer.as_deref(), Some("Test Signer RSA"));
|
||
assert!(s.signing_time.is_some_and(|t| t.year == 2026), "from the CMS signing-time attribute");
|
||
assert!(!s.visible);
|
||
let rsa = pdfcraft_sign::Certificate::parse(&pkcs12::open(&data("rsa-aes.p12"), "test").unwrap().certificate.raw).unwrap();
|
||
let s = signatures(&doc, &bytes, &TrustStore { certs: vec![rsa] }).into_iter().next().unwrap();
|
||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||
// A later comment is allowed for this approval signature.
|
||
let edited = edit_after(&bytes, add_comment);
|
||
let s = signatures(&open(&edited), &edited, &TrustStore::default()).into_iter().next().unwrap();
|
||
assert_eq!(s.modification, Modification::Allowed(vec![kind::COMMENTS]));
|
||
}
|
||
|
||
#[test]
|
||
fn files_without_a_cross_reference_table_are_signed_with_a_full_write() {
|
||
// No xref: the document is reconstructed, so saving rewrites (and renumbers) everything.
|
||
let bytes = b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF"
|
||
.to_vec();
|
||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||
for certify in [None, Some(2)] {
|
||
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { certify, rect: None, ..opts() }).unwrap();
|
||
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
|
||
assert_eq!((s.status, s.certify, s.modification.clone()), (Status::Unknown, certify, Modification::None), "{:?}", s.details);
|
||
}
|
||
}
|
||
|
||
/// Signing with Keychain identities, in a throwaway keychain file. Ignored by default: creating
|
||
/// a keychain touches the user's keychain search list (restored afterwards).
|
||
#[cfg(target_os = "macos")]
|
||
#[test]
|
||
#[ignore = "creates a temporary macOS keychain; run with --ignored"]
|
||
fn signing_with_keychain_identities() {
|
||
use std::process::Command;
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let kc = dir.join("test.keychain-db");
|
||
let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap();
|
||
let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap();
|
||
let saved: Vec<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);
|
||
}
|
||
}));
|
||
sec(&["delete-keychain", k]);
|
||
restore();
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
result.unwrap();
|
||
}
|
||
|
||
/// `fixture()` plus a Form XObject (object 7) that the page draws.
|
||
fn fixture_with_xobject() -> Vec<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);
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
|
||
#[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.
|
||
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(), "other changes", |doc| {
|
||
rewrite_font(doc, Some(("Type", Object::name("DSS"))));
|
||
});
|
||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||
set_dss(doc, oref(5));
|
||
rewrite_font(doc, None);
|
||
});
|
||
assert_refused_at_every_level(&fixture(), "other changes", |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(), "other changes", |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).
|
||
assert_refused_at_every_level(&fixture(), "other changes", |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.
|
||
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, "other changes", |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);
|
||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", 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(<v), <v, &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 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
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));
|
||
}
|
||
|
||
#[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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
// 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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||
};
|
||
let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap();
|
||
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> {
|
||
let mut doc = open(&fixture());
|
||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||
algorithm: alg,
|
||
user_password: "",
|
||
owner_password: "owner",
|
||
permissions,
|
||
encrypt_metadata: true,
|
||
seed: [17; 32],
|
||
})
|
||
.unwrap();
|
||
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
||
}
|
||
|
||
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: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||
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("同意此文件"));
|
||
}
|
||
|
||
/// 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);
|
||
}
|