Signature validation takes the certification from the catalog (#6453)

This commit is contained in:
oracola91 authored and GitHub committed 2026-11-09 08:50:39 +08:00
1 parent 38ba2e7495
commit 72311bc101
3 files changed
+118 -12

No files matched your search

+3 -1
View File
@@ -41,7 +41,7 @@ pub mod kind {
pub const PAGES: &str = "pages added or removed";
pub const OTHER: &str = "other changes";
/// Not a change but the reason there is nothing to compare: the signed revision itself could not
/// be read, so what came after it cannot be allowed.
/// be read, or does not hold the signature, so what came after it cannot be allowed.
pub const UNREADABLE: &str = "the signed version could not be read";
/// Every word above, for an interface to translate.
@@ -177,6 +177,8 @@ pub const WORDINGS: &[&str] = &[
"The signature value does not match the signer's certificate: the signature is corrupt.",
"The signature could not be checked ({}).",
"The signature could not be verified ({}).",
// Whether the signature certifies the document.
"This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.",
// What came after the signature.
"This document has not been modified since this signature was applied.",
"The document has been modified since this signature was applied, but the changes are permitted ({}).",
+42 -11
View File
@@ -38,9 +38,9 @@ pub(super) fn is_doc_timestamp(d: &Dict) -> bool {
d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig"))
}
/// The DocMDP permission level (`/P`, 1–3) of signature value `v`, or `None` for an approval
/// signature.
fn docmdp(doc: &Document, v: &Dict) -> Option<u8> {
/// The DocMDP permission level (`/P`, 1 to 3) that signature value `v` asks for in its `/Reference`,
/// or `None` when it asks for none.
fn docmdp_reference(doc: &Document, v: &Dict) -> Option<u8> {
v.get(b"Reference")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
@@ -51,6 +51,31 @@ fn docmdp(doc: &Document, v: &Dict) -> Option<u8> {
.map(|r| r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8)
}
/// Whether the catalog's `/Perms /DocMDP` in `doc` is the signature dictionary `v`.
fn names_certification(doc: &Document, v: &Dict) -> bool {
let Some(root) = doc.root() else { return false };
let catalog = doc.get(root);
let Some(perms) = catalog.as_dict().and_then(|c| c.get(b"Perms")).map(|p| doc.resolve(p)) else { return false };
let Some(named) = perms.as_dict().and_then(|p| p.get(b"DocMDP")).map(|d| doc.resolve(d)) else { return false };
named.as_dict() == Some(v)
}
/// Set `info.certify` from `doc`, the revision signature value `v` covers. A document has at most
/// one certification, the signature its catalog's `/Perms /DocMDP` names (ISO 32000-2, 12.8.2.2),
/// so a DocMDP reference in any other signature dictionary does not choose the permissions later
/// changes are judged by. Such a signature is checked as an approval signature, with a note.
fn set_certification(doc: &Document, v: &Dict, info: &mut SignatureInfo) {
let asked = docmdp_reference(doc, v);
info.certify = asked.filter(|_| names_certification(doc, v));
let note = Detail::plain(
"This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.",
);
info.details.retain(|d| *d != note);
if asked.is_some() && info.certify.is_none() {
info.details.push(note);
}
}
pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) {
// A document timestamp can also be the value of a signature field (Documenso writes
// `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the
@@ -68,7 +93,7 @@ pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v:
info.contact = text(doc, v, b"ContactInfo");
info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned());
info.signer = text(doc, v, b"Name");
info.certify = docmdp(doc, v);
set_certification(doc, v, info);
let invalid = |info: &mut SignatureInfo, why: Detail| {
info.status = Status::Invalid;
info.details.push(why);
@@ -243,18 +268,24 @@ fn finish_validation(context: SignatureValidation<'_>, verified: VerifiedSignatu
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
} else {
// The permissions are the signed revision's: a later one can rewrite `/P` (#1788). This
// signature is the one whose byte range ends that revision.
// The permissions are the signed revision's: a later one can rewrite `/P` (#1788) or the
// catalog's `/Perms`. This signature is the one whose byte range ends that revision. A
// revision that holds no such signature does not say which permissions apply, so what
// came after it is judged as when the revision cannot be read: not permitted.
let old = cache.revision(doc, bytes, covered);
if let Some(old) = &old
&& let Some(v) = sig_fields(old)
let signed = old.as_ref().and_then(|old| {
sig_fields(old)
.iter()
.filter_map(|f| f.signature(old))
.find(|v| nums(old, v, b"ByteRange").is_some_and(|b| b.len() == 4 && b[2] + b[3] == covered as f64))
{
info.certify = docmdp(old, &v);
});
match (old, signed) {
(Some(old), Some(v)) => {
set_certification(&old, &v, info);
classify_changes(doc, Some(old), info.certify)
}
_ => classify_changes(doc, None, info.certify),
}
classify_changes(doc, old, info.certify)
};
let mut problems = false;
// The changes are handed over as the words they are, not stitched into the sentence, so an
+73
View File
@@ -538,6 +538,79 @@ fn a_document_timestamp_keeps_a_no_changes_certification_valid() {
assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SIGNATURE)), "{:?}", s.modification);
}
/// Signature field "Approval" whose value, object 4, asks for DocMDP P 1 in its `/Reference`;
/// object 5 is another signature dictionary. `perms` goes into the catalog. The signature itself
/// is a dummy: which signature certifies is decided before its bytes are checked.
fn certification_of(perms: &str) -> pdfcraft_sign::SignatureInfo {
let catalog = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [3 0 R] >> {perms} >>");
let bytes = write_pdf(&[
catalog.as_bytes(),
b"<< /Type /Pages /Kids [] /Count 0 >>",
b"<< /FT /Sig /T (Approval) /V 4 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>] >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached >>",
]);
signatures(&open(&bytes), &bytes, &TrustStore { certs: Vec::new() }).into_iter().find(|s| s.field == "Approval").unwrap()
}
/// Only the signature the catalog's `/Perms /DocMDP` names certifies the document: a DocMDP
/// reference in any other signature dictionary leaves it an approval signature, with a note.
#[test]
fn only_the_signature_the_catalog_names_certifies() {
let note = "This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.";
let noted = |s: &pdfcraft_sign::SignatureInfo| s.details.iter().filter(|d| d.message == note).count();
let named = certification_of("/Perms << /DocMDP 4 0 R >>");
assert_eq!((named.certify, noted(&named)), (Some(1), 0), "{:?}", named.details);
for perms in ["", "/Perms << /DocMDP 5 0 R >>", "/Perms << /UR3 4 0 R >>"] {
let s = certification_of(perms);
assert_eq!((s.certify, noted(&s)), (None, 1), "{perms:?}: {:?}", s.details);
}
}
/// [`certified_p1`] with a fraction after its last `/ByteRange` number, signed again over the
/// edited bytes. The ranges still cover whole bytes, so the signature verifies, but no signature
/// in the signed revision has a byte range that ends exactly where that revision ends.
fn certified_p1_with_a_fractional_range() -> (TrustStore, Vec<u8>) {
let (id, trust, mut signed) = certified_p1();
let prefix = b"/ByteRange [";
let at = signed.windows(prefix.len()).position(|w| w == prefix).unwrap() + prefix.len();
let end = at + signed[at..].iter().position(|b| *b == b']').unwrap();
let text = std::str::from_utf8(&signed[at..end]).unwrap().to_owned();
let range: Vec<usize> = text.split_ascii_whitespace().map(|n| n.parse().unwrap()).collect();
let mut fractional = format!("{}.5", text.trim_end()).into_bytes();
assert!(fractional.len() <= end - at, "no room for the fraction in {text:?}");
fractional.resize(end - at, b' ');
signed[at..end].copy_from_slice(&fractional);
let (start, stop) = (range[1], range[2]);
signed[start + 1..stop - 1].fill(b'0');
let alg = id.key.preferred_digest();
let digest = alg.digest(&[&signed[..start], &signed[stop..]]);
let cms = pdfcraft_sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap();
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
signed[start + 1..start + 1 + hex.len()].copy_from_slice(&hex);
(trust, signed)
}
/// When the signed revision holds no signature whose byte range ends it, nothing signed says
/// which permissions apply: later changes are not permitted, whatever the latest catalog says.
#[test]
fn changes_after_an_unmatched_signed_revision_are_not_permitted() {
let (trust, signed) = certified_p1_with_a_fractional_range();
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
let s = check(&signed);
assert_eq!((s.status, &s.modification), (Status::Valid, &Modification::None), "{:?}", s.details);
let edited = edit_after(&signed, |doc| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.remove(b"Perms");
})
.unwrap();
add_comment(doc);
});
let s = check(&edited);
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::UNREADABLE])));
}
#[test]
fn invisible_signatures_and_refusals() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();