Merge PR #4133: fix(sign): let an LTV update declare the ESIC extension in the catalog

This commit is contained in:
aiongg
2026-10-24 06:22:03 +00:00
2 changed files with 46 additions and 1 deletions
+17 -1
View File
@@ -1817,6 +1817,18 @@ fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Di
}
}
/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each
/// with the same value: it may only declare more.
fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool {
let now = d.get(b"Extensions").map(|e| doc.resolve(e)).and_then(|e| e.as_dict().cloned());
let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned());
match (was, now) {
(None, _) => true,
(Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)),
(Some(_), None) => false,
}
}
fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
let ty = d.name(b"Type");
// A later revision cannot make an existing object a signature by relabelling it: a signature
@@ -1835,9 +1847,13 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
// Only a new signature value or policy: rewriting a signed one is not a signature (#1788).
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE,
Some(b"Catalog") => {
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"];
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"];
return match before {
Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE,
// Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A
// writer adding a /DSS also declares the ESIC extension it belongs to, so a
// declaration may be added, but none that was signed may change or go.
Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE,
Some(b) => {
if d.get(b"AcroForm") != b.get(b"AcroForm") {
// Compare the forms themselves, inline or not.
+29
View File
@@ -731,6 +731,35 @@ fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() {
assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid);
}
#[test]
fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
let ext = |base: &[u8], level: i64| {
let mut esic = pdfcraft_cos::Dict::new();
esic.set(b"BaseVersion".to_vec(), Object::name("1.7"));
esic.set(b"ExtensionLevel".to_vec(), Object::Int(level));
let mut e = pdfcraft_cos::Dict::new();
e.set(b"ESIC".to_vec(), Object::Dict(esic));
edit_after(base, |doc| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap();
})
};
// As PAdES LTV writers save it: the store and the ESIC declaration in one revision.
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() };
let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap();
let s = check(&ltv);
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification);
// A declaration that was signed may not change afterwards.
let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap();
let s = check(&ext(&signed_with, 3));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE])));
}
#[test]
fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() {
use pdfcraft_sign::dss::{self, Evidence};