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