From 67be9ea2aafe8f3e64d588bcd8067b2825e9db58 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:44:23 +0000 Subject: [PATCH] refactor(sign): split pdf.rs into pdf/ New modules: listing, types, validate, changes, signing. --- crates/sign/src/pdf.rs | 2444 +------------------------------ crates/sign/src/pdf/changes.rs | 579 ++++++++ crates/sign/src/pdf/listing.rs | 375 +++++ crates/sign/src/pdf/signing.rs | 610 ++++++++ crates/sign/src/pdf/types.rs | 225 +++ crates/sign/src/pdf/validate.rs | 649 ++++++++ 6 files changed, 2457 insertions(+), 2425 deletions(-) create mode 100644 crates/sign/src/pdf/changes.rs create mode 100644 crates/sign/src/pdf/listing.rs create mode 100644 crates/sign/src/pdf/signing.rs create mode 100644 crates/sign/src/pdf/types.rs create mode 100644 crates/sign/src/pdf/validate.rs diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 279feee09..75b42f990 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -23,397 +23,29 @@ use crate::keys::DigestAlg; use crate::pkcs12::DigitalId; use crate::x509::{Certificate, build_chain, build_chain_noted}; +mod changes; +mod listing; +mod signing; +mod types; +mod validate; + +#[cfg(test)] +use changes::only_grew; +#[cfg(test)] +use listing::{DISCOVERY_CACHE_LIMIT, is_document_timestamp}; +pub use listing::{DigestCache, list, list_cached}; +pub(crate) use signing::fmt; +pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, sign, sign_with_timestamp, timestamp_document}; +pub use types::{Detail, Fill, Modification, Status, TrustStore, WORDINGS, Words, in_english, kind}; +pub(crate) use validate::signature_contents; +pub use validate::validate; +#[cfg(test)] +use validate::{DssEvidence, MAX_DSS_BYTES, MAX_DSS_ENTRIES}; + #[cfg(test)] #[path = "pdf_discovery_tests.rs"] mod discovery_tests; -/// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of -/// one document: incremental edits only append to the file, so the signed bytes and their -/// digest don't change, and rehashing a large file on every edit was the expensive part of -/// validation (120 MB: 84 ms per comment edit before, 6 ms after). Keyed by the ranges, the -/// algorithm and a sampled fingerprint of the covered bytes; use one cache per document, whose -/// bytes only ever grow. -#[derive(Debug, Default)] -pub struct DigestCache { - map: std::sync::Mutex>>, - /// Signed revisions opened for change classification, by length and fingerprint. - revisions: std::sync::Mutex>, - /// Candidate identities only: validation still uses current bytes and current trust. - discovery: std::sync::Mutex>, - #[cfg(test)] - discovery_scans: std::sync::atomic::AtomicUsize, -} - -/// Keep only the most recently visited source/overlay. In particular, this must not retain -/// a Document, its bytes, or parsed signature dictionaries across edits and saves. -#[derive(Debug)] -struct TimestampCandidates { - source: pdfcraft_cos::SourceIdentity, - modified: Vec, - candidates: Vec, -} - -const DISCOVERY_CACHE_LIMIT: usize = 4096; - -fn is_document_timestamp(object: &Object) -> bool { - object.as_dict().is_some_and(|d| is_doc_timestamp(d) && d.contains(b"ByteRange")) -} - -/// Only dictionary/null edits have a local effect on candidate membership. Changes to an -/// object stream or a scalar used as an indirect /Length can affect unedited objects too. -/// Checking both the original and current objects also covers freeing those dependencies. -/// Unsupported or unreadable edits deliberately fall back to the complete bounded scan. -fn changed_timestamp_candidates(doc: &Document, numbers: &[u32]) -> Option> { - for (reference, object) in doc.scan_original_objects_subset(numbers.to_vec()) { - match object.ok()?.as_ref() { - Object::Dict(_) => {} - Object::Null if matches!(doc.xref_entry(reference.num), None | Some(pdfcraft_cos::XrefEntry::Free { .. })) => {} - _ => return None, - } - } - let mut candidates = Vec::new(); - for (reference, object) in doc.scan_objects_subset(numbers.to_vec()) { - let object = object.ok()?; - if !matches!(object.as_ref(), Object::Dict(_) | Object::Null) { - return None; - } - if is_document_timestamp(&object) { - candidates.push(reference); - } - } - Some(candidates) -} - -/// (end of first range, start of second, end of second, algorithm, fingerprint). -type RangeKey = (usize, usize, usize, DigestAlg, [u8; 32]); - -/// 64 KiB sampled from across `covered`, plus its length. -fn fingerprint(covered: &[u8]) -> [u8; 32] { - let mut parts: Vec<&[u8]> = (0..64) - .map(|i| { - let at = covered.len().saturating_sub(1024) * i / 63; - &covered[at..(at + 1024).min(covered.len())] - }) - .collect(); - let len = (covered.len() as u64).to_be_bytes(); - parts.push(&len); - DigestAlg::Sha256.digest(&parts).try_into().unwrap_or([0; 32]) -} - -impl DigestCache { - fn timestamp_candidates(&self, doc: &Document) -> Vec { - let mut slot = self.discovery.lock().ok(); - let previous = slot.as_mut().and_then(|slot| slot.take()); - let source = doc.source_identity(); - let modified = doc.modified_objects(); - // Reconstructed/unknown input and output-security changes take the original path. - let eligible = slot.is_some() - && doc.repair_log().is_empty() - && !doc.revisions().is_empty() - && !doc.encryption_changed() - && modified.len() <= DISCOVERY_CACHE_LIMIT; - if eligible && let Some(previous) = previous.filter(|previous| previous.source == source) { - // Undo/redo can remove overlays as well as add them. Revisit the union so an - // original candidate replaced or freed in the last snapshot can reappear. - let mut changed = previous.modified; - changed.extend_from_slice(&modified); - changed.sort_unstable(); - changed.dedup(); - if changed.len() <= DISCOVERY_CACHE_LIMIT - && let Some(mut candidates) = changed_timestamp_candidates(doc, &changed) - { - candidates.extend(previous.candidates.into_iter().filter(|r| changed.binary_search(&r.num).is_err())); - candidates.sort_unstable_by_key(|r| r.num); - if candidates.len() <= DISCOVERY_CACHE_LIMIT - && let Some(slot) = slot.as_mut() - { - **slot = Some(TimestampCandidates { source, modified, candidates: candidates.clone() }); - } - return candidates; - } - } - #[cfg(test)] - self.discovery_scans.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut complete = true; - let candidates: Vec<_> = doc - .scan_objects_checked() - .filter_map(|(reference, object)| match object { - Ok(object) => is_document_timestamp(&object).then_some(reference), - Err(_) => { - complete = false; - None - } - }) - .collect(); - if eligible - && complete - && candidates.len() <= DISCOVERY_CACHE_LIMIT - && changed_timestamp_candidates(doc, &modified).is_some() - && let Some(slot) = slot.as_mut() - { - **slot = Some(TimestampCandidates { source, modified, candidates: candidates.clone() }); - } - candidates - } - - /// The signed revision `bytes[..end]`, parsed (and kept). An encrypted one is opened with - /// `doc`'s own key (the same `/Encrypt` and `/ID`), so a user password the document was - /// opened with is neither needed again nor kept here (#1808). - fn revision(&self, doc: &Document, bytes: &[u8], end: usize) -> Option { - let key = (end, fingerprint(&bytes[..end])); - if let Some(d) = self.revisions.lock().ok().and_then(|m| m.get(&key).cloned()) { - return Some(d); - } - let d = doc.open_with_same_credentials(Arc::new(bytes[..end].to_vec())).ok()?; - if let Ok(mut m) = self.revisions.lock() { - if m.len() > 8 { - m.clear(); - } - m.insert(key, d.clone()); - } - Some(d) - } - - fn digest(&self, alg: DigestAlg, bytes: &[u8], l0: usize, o1: usize, end: usize) -> Vec { - let key = (l0, o1, end, alg, fingerprint(&bytes[..end])); - if let Some(d) = self.map.lock().ok().and_then(|m| m.get(&key).cloned()) { - return d; - } - let d = alg.digest(&[&bytes[..l0], &bytes[o1..end]]); - if let Ok(mut m) = self.map.lock() { - if m.len() > 64 { - m.clear(); - } - m.insert(key, d.clone()); - } - d - } -} - -/// Certificates the user trusts for signing (Acrobat: Trusted Certificates). -#[derive(Clone, Debug, Default)] -pub struct TrustStore { - pub certs: Vec, -} - -impl TrustStore { - fn trusts(&self, c: &Certificate) -> bool { - self.certs.iter().any(|t| t.raw == c.raw) - } -} - -/// Acrobat's three verdicts. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Status { - /// Intact, signed by a trusted identity, and any later changes are permitted. - Valid, - /// Intact, but the signer's identity can't be established (not trusted), or validity - /// couldn't be determined. - Unknown, - /// The signed bytes changed, the signature doesn't verify, or later changes aren't permitted. - Invalid, -} - -/// What a change made after signing is called: the words [`Modification`] lists it by, and the -/// vocabulary a signature's details speak of changes in. -/// -/// They are words of ours, not the document's, so an interface reading in another language -/// translates each of them; [`ALL`](kind::ALL) is the whole vocabulary. -pub mod kind { - pub const PAGE_CONTENT: &str = "page content"; - pub const FORM_FILL: &str = "form fill"; - pub const COMMENTS: &str = "comments"; - pub const SIGNATURE: &str = "signature"; - pub const SECURITY_STORE: &str = "document security store"; - pub const METADATA: &str = "metadata"; - pub const LINKS: &str = "links"; - pub const STRUCTURE: &str = "document structure"; - 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. - pub const UNREADABLE: &str = "the signed version could not be read"; - - /// Every word above, for an interface to translate. - pub const ALL: &[&str] = &[PAGE_CONTENT, FORM_FILL, COMMENTS, SIGNATURE, SECURITY_STORE, METADATA, LINKS, STRUCTURE, PAGES, OTHER, UNREADABLE]; -} - -/// Changes made after signing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Modification { - /// The signature covers the whole file. - None, - /// Later revisions only make changes the signature permits, listed in the words of [`kind`]. - Allowed(Vec<&'static str>), - /// Later revisions make changes it doesn't permit, listed in the words of [`kind`]. - Disallowed(Vec<&'static str>), -} - -/// One of the sentences that explain a signature's verdict. -/// -/// [`message`](Detail::message) is the sentence in English, ready to read. An interface that reads in -/// another language says it with [`Detail::said`] instead, out of [`pattern`](Detail::pattern) — -/// wording of ours, listed in [`WORDINGS`] — and [`fill`](Detail::fill), whatever goes in it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Detail { - /// The sentence in English. - pub message: String, - /// The sentence as it is worded here, with `{}` where the fill goes. - pub pattern: &'static str, - pub fill: Fill, -} - -/// What goes where a [`Detail`]'s pattern has `{}`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Fill { - /// Nothing: the pattern is the whole sentence. - Nothing, - /// The changes made after signing, as [`Modification`] lists them: words of ours, each one in - /// [`kind::ALL`], said one after another. - Changes(Vec<&'static str>), - /// What a reader could not make of the signature, in its own words. It goes in as it stands: it - /// names the structure that is wrong, which is a developer's business and reads the same - /// everywhere. - Reason(String), - /// A value out of the signature itself — a time, the evidence a revocation came from — written - /// as it stands, the way a certificate's name or date is. - Value(String), - /// Two certificates' own names, for the wording that says one was not used to vouch for the - /// other. They go in the order the wording puts its two holes in, which a language may reverse. - Names(String, String), -} - -impl Detail { - /// The sentence said in the language `words` translates into. - /// - /// The wording is translated, and what fills it goes in afterwards — the changes translated too — - /// so nothing put in can be mistaken for a hole of its own. With [`in_english`] this is - /// [`message`](Detail::message). - pub fn said(&self, words: Words) -> String { - let said = words(self.pattern); - match &self.fill { - Fill::Nothing => said, - Fill::Changes(c) => said.replacen("{}", &c.iter().map(|k| words(k)).collect::>().join(", "), 1), - Fill::Reason(r) | Fill::Value(r) => said.replacen("{}", r, 1), - // Both holes are cut out of the wording first, so neither name can be taken for the other's. - Fill::Names(a, b) => { - let mut parts = said.splitn(3, "{}"); - match (parts.next(), parts.next(), parts.next()) { - (Some(p0), Some(p1), Some(p2)) => format!("{p0}{a}{p1}{b}{p2}"), - // A wording with one hole, or none: say it as it is rather than guess. - _ => said, - } - } - } - } - - /// The whole sentence, with no hole in it. - pub(crate) fn plain(pattern: &'static str) -> Detail { - Detail::of(pattern, Fill::Nothing) - } - - /// The sentence and what fills its hole. The English is said once, here, so it and - /// [`Detail::said`] cannot drift apart. - pub(crate) fn of(pattern: &'static str, fill: Fill) -> Detail { - let mut d = Detail { message: String::new(), pattern, fill }; - d.message = d.said(&in_english); - d - } -} - -/// How an interface says one of this crate's wordings in its own language: it is handed the English -/// and gives back what it reads. [`in_english`] is the one that keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// [`Words`] that leaves the English as it is. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// Every sentence this crate says about a signature: what [`SignatureInfo::details`] can hold, and -/// the summaries [`SignatureInfo::summary`] chooses between. -/// -/// An interface reading in another language translates these and the words in [`kind::ALL`], which -/// go into the two sentences that list changes. The rest of what it shows comes from elsewhere and -/// goes through as it stands: the certificate's names and dates, and a reader's own detail in -/// [`Fill::Reason`]. A test in this crate keeps the list in step with the code. -pub const WORDINGS: &[&str] = &[ - // What the signature's own structure says, before anything is verified. - "The signature has no valid byte range.", - "The signature's byte range does not match the file.", - "The signature's byte range does not exclude exactly its contents.", - "The signature contents are not hexadecimal.", - "The signature could not be read ({}).", - "PdfCraft can't check this signature yet: {}.", - "The signature uses the legacy adbe.x509.rsa_sha1 format.", - // What was read leniently on the way, each sentence worded where the reading happens. - "The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently.", - "The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway.", - "The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway.", - "The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway.", - "The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway.", - "The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway.", - "The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's.", - "The signature's signed attributes have no content type, which CMS requires; it was read anyway.", - // What a signature that validates with an old hash or a small key still does not prove. - "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.", - "This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on.", - "This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.", - // Whether the signed bytes and the signature still agree. - "The signature's signed attributes have no message digest, so the signature does not cover the document.", - "The signature's signed attributes name a content type other than data, so it is not a signature of the document.", - "The document has been altered or corrupted since the signature was applied.", - "The signer's certificate is not in the signature.", - "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 ({}).", - // 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 ({}).", - "The document has been altered since this signature was applied in ways it does not permit ({}).", - // Who signed, and when. - "The signer's certificate was not valid at the time of signing.", - "The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation).", - "The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).", - "The signer's certificate has critical extensions PdfCraft does not recognize ({}).", - // Why a certificate that matched the issuer was not used to build the chain. - "The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}.", - "The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}.", - "The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}.", - "The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}.", - "The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates.", - "The signer's identity is valid.", - "The signature includes an embedded timestamp.", - "Signing time is from the clock on the signer's computer.", - // What the signature's own timestamp token proves, if it carries one. - "The signature's timestamp token does not cover the signature value.", - "The signature's timestamp token could not be verified ({}).", - "The embedded timestamp token is valid and its authority is trusted; trusted time is {}.", - "The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead.", - // What the document's own revocation evidence says about the signer. - "The signer's certificate has been revoked ({}).", - "The document's embedded revocation information shows that the signer's certificate has not been revoked.", - // A standalone document timestamp, which is checked on its own. - "The timestamp has no valid byte range.", - "The timestamp's byte range does not match the file.", - "The timestamp's byte range does not exclude exactly its contents.", - "The timestamp contents are not hexadecimal.", - "The timestamp token could not be read ({}).", - "The document has been altered or corrupted since the timestamp was applied.", - "The timestamp authority's certificate was not valid at the time of timestamping.", - "The timestamp authority's certificate is not issued for time stamping.", - "This document has not been modified since this timestamp was applied.", - "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", - "The document has been altered since this timestamp was applied in ways it does not permit ({}).", - "The timestamp token is valid and its authority is trusted.", - "The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates.", - // The one line that stands for all of it. - "Unsigned signature field", - "Signature is valid", - "Signature validity is unknown", - "Signature is invalid", -]; - /// One signature field. #[derive(Clone, Debug)] pub struct SignatureInfo { @@ -473,2041 +105,3 @@ impl SignatureInfo { } } } - -pub(crate) fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option { - let o = doc.resolve(d.get(key)?); - let s = match &*o { - Object::String(s) => s.to_text(), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) -} - -struct Field { - name: String, - /// The field dictionary (and its widget(s)). - r: Option, - dict: Dict, - widgets: Vec<(Option, Dict)>, -} - -impl Field { - /// The signature dictionary in /V. A field without one is unsigned, whether /V is missing, - /// null or not a dictionary. - fn signature(&self, doc: &Document) -> Option { - self.dict.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) - } - - /// True when /V holds something that is neither null nor a signature dictionary: signing the - /// field would overwrite a value we can't read as a signature. - fn has_foreign_value(&self, doc: &Document) -> bool { - self.dict.get(b"V").map(|v| doc.resolve(v)).is_some_and(|v| !matches!(*v, Object::Null) && v.as_dict().is_none()) - } -} - -/// Every signature field, depth first in `/Fields` order. -fn sig_fields(doc: &Document) -> Vec { - let mut out = Vec::new(); - let Some(root) = doc.root() else { return out }; - let Some(af) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()) else { - return out; - }; - let Some(fields) = af.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { return out }; - let mut seen = HashSet::new(); - let mut stack: Vec<(Object, String, Option>)> = fields.into_iter().rev().map(|f| (f, String::new(), None)).collect(); - while let Some((o, prefix, inherited_ft)) = stack.pop() { - if let Some(r) = o.as_ref() - && !seen.insert(r) - { - continue; - } - let Some(d) = doc.resolve(&o).as_dict().cloned() else { continue }; - let part = text(doc, &d, b"T"); - let name = match (&part, prefix.is_empty()) { - (Some(p), true) => p.clone(), - (Some(p), false) => format!("{prefix}.{p}"), - (None, _) => prefix.clone(), - }; - let ft = d.name(b"FT").map(<[u8]>::to_vec).or(inherited_ft); - let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - // Kids with a /T are fields; others are this field's widgets. - let (sub, widgets): (Vec, Vec) = kids.into_iter().partition(|k| doc.resolve(k).as_dict().is_some_and(|kd| kd.contains(b"T"))); - for k in sub.into_iter().rev() { - stack.push((k, name.clone(), ft.clone())); - } - if ft.as_deref() == Some(b"Sig") && part.is_some() { - let mut ws: Vec<(Option, Dict)> = - widgets.iter().filter_map(|w| doc.resolve(w).as_dict().cloned().map(|wd| (w.as_ref(), wd))).collect(); - if d.name(b"Subtype") == Some(b"Widget") { - ws.insert(0, (o.as_ref(), d.clone())); - } - out.push(Field { name, r: o.as_ref(), dict: d, widgets: ws }); - } - } - out -} - -/// Which page holds each annotation. -fn annot_pages(doc: &Document) -> HashMap { - let mut map = HashMap::new(); - for (i, p) in pdfcraft_annot::page_refs(doc).unwrap_or_default().into_iter().enumerate() { - if let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a))).and_then(|a| a.as_array().cloned()) { - for r in a.iter().filter_map(Object::as_ref) { - map.insert(r, i); - } - } - } - map -} - -fn nums(doc: &Document, d: &Dict, key: &[u8]) -> Option> { - doc.resolve(d.get(key)?).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) -} - -/// List and validate every signature field. `bytes` is the file as stored (the ranges index it). -pub fn list(doc: &Document, bytes: &[u8], trust: &TrustStore) -> Vec { - list_cached(doc, bytes, trust, &DigestCache::default()) -} - -/// [`list`], reusing digests and standalone-timestamp discovery from `cache`. Field/widget -/// structure and validation against `bytes` and `trust` are read again on every call. -pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &DigestCache) -> Vec { - let pages = annot_pages(doc); - let mut out = Vec::new(); - let mut field_values: HashSet = HashSet::new(); - for f in sig_fields(doc) { - if let Some(v_ref) = f.dict.get(b"V").and_then(Object::as_ref) { - field_values.insert(v_ref); - } - let widget = f.widgets.first(); - let page = widget.and_then(|(r, w)| r.and_then(|r| pages.get(&r).copied()).or_else(|| w.reference(b"P").and_then(|p| page_index(doc, p)))); - let rect = widget - .and_then(|(_, w)| nums(doc, w, b"Rect")) - .filter(|r| r.len() == 4) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); - // Shown when it has a box, and its widget isn't Hidden (2) or NoView (32), as the forms - // crate reads a widget's flags. - let flags = widget.and_then(|(_, w)| w.get(b"F")).and_then(|o| doc.resolve(o).as_int()).unwrap_or(0); - let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5) && flags & (2 | 32) == 0; - let v = f.signature(doc); - let mut info = SignatureInfo { - field: f.name.clone(), - signed: v.is_some(), - page, - rect, - visible, - signer: None, - certificate: None, - chain: Vec::new(), - date: None, - signing_time: None, - reason: None, - location: None, - contact: None, - sub_filter: None, - doc_timestamp: false, - certify: None, - revision: 0, - signed_len: 0, - digest: None, - algorithm: None, - timestamp: false, - timestamp_time: None, - status: Status::Unknown, - modification: Modification::None, - details: Vec::new(), - }; - if let Some(v) = v { - validate_into(doc, bytes, trust, &v, &mut info, cache); - } - out.push(info); - } - // Standalone document timestamps (ISO 32000-2 §12.8.2.2) live outside AcroForm fields. - let candidates = cache.timestamp_candidates(doc).into_iter().map(|r| r.num).collect(); - for (r, o) in doc.scan_objects_subset(candidates) { - if field_values.contains(&r) { - continue; - } - let Ok(o) = o else { continue }; - let Some(d) = o.as_dict() else { continue }; - if !is_document_timestamp(&o) { - continue; - } - let mut info = SignatureInfo { - field: "DocumentTimestamp".to_string(), - signed: true, - page: None, - rect: None, - visible: false, - signer: None, - certificate: None, - chain: Vec::new(), - date: None, - signing_time: None, - reason: None, - location: None, - contact: None, - sub_filter: Some("ETSI.RFC3161".to_string()), - doc_timestamp: true, - certify: None, - revision: 0, - signed_len: 0, - digest: None, - algorithm: None, - timestamp: true, - timestamp_time: None, - status: Status::Unknown, - modification: Modification::None, - details: Vec::new(), - }; - validate_doc_timestamp(doc, bytes, d, &mut info, cache, trust); - out.push(info); - } - out -} - -fn page_index(doc: &Document, p: ObjRef) -> Option { - pdfcraft_annot::page_refs(doc).ok()?.iter().position(|r| *r == p) -} - -/// A widget's `/MK /R` as a quarter turn (0, 90, 180 or 270); missing, malformed or not a -/// multiple of 90 counts as 0, as `pdfcraft-forms` reads it for the fields it draws. -fn mk_rotation(doc: &Document, widget: &Dict) -> i64 { - let r = widget - .get(b"MK") - .map(|mk| doc.resolve(mk)) - .and_then(|mk| mk.as_dict().and_then(|mk| mk.get(b"R")).map(|r| doc.resolve(r))) - .and_then(|r| r.as_int().or_else(|| r.as_f64().filter(|v| v.is_finite() && v.abs() <= 1.0e6).map(|v| v.round() as i64))); - r.map(|r| r.rem_euclid(360)).filter(|q| matches!(q, 90 | 180 | 270)).unwrap_or(0) -} - -/// Validate one signature dictionary. -pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option { - list(doc, bytes, trust).into_iter().find(|s| s.field == field) -} - -fn unhex(s: &[u8]) -> Option> { - let digits: Vec = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect(); - let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8); - digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect() -} - -/// A document timestamp dictionary: `/SubFilter /ETSI.RFC3161`, typed `/DocTimeStamp` -/// (ISO 32000-2 §12.8.5) or, as older writers have it, `/Sig`. -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 { - v.get(b"Reference") - .map(|r| doc.resolve(r)) - .and_then(|r| r.as_array().cloned()) - .into_iter() - .flatten() - .filter_map(|r| doc.resolve(&r).as_dict().cloned()) - .find(|r| r.name(b"TransformMethod") == Some(b"DocMDP")) - .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) -} - -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 - // document, so it must not be read as one. - if is_doc_timestamp(v) { - info.sub_filter = Some("ETSI.RFC3161".to_string()); - info.doc_timestamp = true; - info.timestamp = true; - validate_doc_timestamp(doc, bytes, v, info, cache, trust); - return; - } - info.date = text(doc, v, b"M"); - info.reason = text(doc, v, b"Reason"); - info.location = text(doc, v, b"Location"); - 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); - let invalid = |info: &mut SignatureInfo, why: Detail| { - info.status = Status::Invalid; - info.details.push(why); - }; - // Byte ranges: [0 a b c] with the gap holding exactly the hex /Contents. - let Some(br) = nums(doc, v, b"ByteRange").filter(|b| b.len() == 4 && b.iter().all(|x| *x >= 0.0)) else { - return invalid(info, Detail::plain("The signature has no valid byte range.")); - }; - let [o0, l0, o1, l1] = [br[0] as usize, br[1] as usize, br[2] as usize, br[3] as usize]; - if o0 != 0 || o1 < l0 || o1.checked_add(l1).is_none_or(|end| end > bytes.len()) || o1 - l0 < 2 { - return invalid(info, Detail::plain("The signature's byte range does not match the file.")); - } - let gap = &bytes[l0..o1]; - if gap.first() != Some(&b'<') || gap.last() != Some(&b'>') { - return invalid(info, Detail::plain("The signature's byte range does not exclude exactly its contents.")); - } - let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, Detail::plain("The signature contents are not hexadecimal.")) }; - let covered = o1 + l1; - info.signed_len = covered; - // The signed revision's number: its cross-reference sections (1 for a reconstructed file). - info.revision = cache.revision(doc, bytes, covered).map_or(1, |d| d.revisions().len().max(1)); - // What can't be checked is unknown, not invalid: only data that is wrong or fails its check - // is "invalid". - let unsupported = |info: &mut SignatureInfo, why: &str| { - info.status = Status::Unknown; - // What is not supported yet is named by the reader, in its own words. - info.details.push(Detail::of("PdfCraft can't check this signature yet: {}.", Fill::Reason(why.to_owned()))); - }; - if info.sub_filter.as_deref() == Some("adbe.x509.rsa_sha1") { - return validate_x509_rsa_sha1(doc, bytes, trust, v, info, cache, (l0, o1, covered), &contents); - } - let sd = match SignedData::parse(&contents) { - Ok(sd) => sd, - Err(SignError::Unsupported(e)) => return unsupported(info, &e), - // What the reader made of it goes in as it stands: it is a developer's detail, not a sentence. - Err(e) => return invalid(info, Detail::of("The signature could not be read ({}).", Fill::Reason(e.to_string()))), - }; - let s = &sd.signer; - // A quirk is a whole sentence the reader words for itself, so it goes on the list in `WORDINGS`. - info.details.extend(sd.quirks.iter().copied().map(Detail::plain)); - info.digest = Some(s.digest); - info.timestamp = s.timestamp; - let mut unverified_time = None; - if let Some(raw) = &s.timestamp_token { - match crate::timestamp::parse_token(raw) { - Ok(t) => { - let imprint = t.digest.digest(&[&s.signature]); - if imprint != t.imprint { - info.details.push(Detail::plain("The signature's timestamp token does not cover the signature value.")); - } else { - // Only a token from a trusted authority (valid when it stamped) is trusted - // time; otherwise its time is reported but validation uses the signer's - // own claimed time, as without a timestamp. - let mut pool = crate::timestamp::token_certs(raw); - pool.extend(trust.certs.iter().cloned()); - let tsa = t.signer_certificate(); - let trusted_tsa = tsa - .as_ref() - .is_some_and(|c| c.valid_at(t.gen_time) && build_chain(c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if !trusted_tsa { - unverified_time = Some(t.gen_time); - } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { - info.timestamp_time = Some(t.gen_time); - } else { - info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); - } - } - } - Err(e) => info.details.push(Detail::of("The signature's timestamp token could not be verified ({}).", Fill::Reason(e.to_string()))), - } - } - // The digest the signature commits to. - let content_digest = match &sd.content { - // adbe.pkcs7.sha1: the document's SHA-1 digest is the signed content. - Some(c) => { - if *c != cache.digest(DigestAlg::Sha1, bytes, l0, o1, covered) { - return invalid(info, Detail::plain("The document has been altered or corrupted since the signature was applied.")); - } - s.digest.digest(&[c]) - } - None => cache.digest(s.digest, bytes, l0, o1, covered), - }; - // With signed attributes the signature is made over them, not over the document: their - // message digest is what ties it to the signed bytes, and their content type says what those - // bytes are (data, for a document). RFC 5652 §5.3 requires both. - if s.signed_attrs.is_some() { - match s.content_type.as_deref() { - Some(crate::cms::DATA) => {} - Some(_) => { - return invalid( - info, - Detail::plain("The signature's signed attributes name a content type other than data, so it is not a signature of the document."), - ); - } - None => { - info.details.push(Detail::plain("The signature's signed attributes have no content type, which CMS requires; it was read anyway.")) - } - } - if s.message_digest.is_none() { - return invalid( - info, - Detail::plain("The signature's signed attributes have no message digest, so the signature does not cover the document."), - ); - } - } - // A PDF signature signs data: the document, or (adbe.pkcs7.sha1) its digest. A content-type - // attribute was checked above, and `SignedData::parse` refuses one that differs from - // `eContentType`; without that attribute, `eContentType` is the only place that says so. - if sd.econtent_type != crate::cms::DATA { - let why = SignError::Malformed("CMS: the encapsulated content is not data".into()); - return invalid(info, Detail::of("The signature could not be read ({}).", Fill::Reason(why.to_string()))); - } - if let Some(md) = &s.message_digest - && *md != content_digest - { - return invalid(info, Detail::plain("The document has been altered or corrupted since the signature was applied.")); - } - let Some(cert) = sd.signer_certificate().cloned() else { - return invalid(info, Detail::plain("The signer's certificate is not in the signature.")); - }; - info.algorithm = Some(format!("{} with {}", cert.public_key.describe(), s.scheme_digest.unwrap_or(s.digest).name())); - info.signer = Some(cert.display_name()); - let mut notes = Vec::new(); - match sd.verify_signature_noting(&cert, &content_digest, &mut notes) { - // A note is a whole sentence the reader words for itself, like a quirk. - Ok(true) => info.details.extend(notes.into_iter().map(Detail::plain)), - Ok(false) => { - info.certificate = Some(cert); - return invalid(info, Detail::plain("The signature value does not match the signer's certificate: the signature is corrupt.")); - } - Err(SignError::Unsupported(e)) => { - info.certificate = Some(cert); - return unsupported(info, &e); - } - Err(e) => { - info.certificate = Some(cert); - return invalid(info, Detail::of("The signature could not be checked ({}).", Fill::Reason(e.to_string()))); - } - } - finish_validation(doc, bytes, trust, info, cache, covered, cert, &sd.certificates, s.signing_time, unverified_time); -} - -/// The verdict once the signature value has been checked: chain and trust, the signing time -/// against the certificate's validity, and what later revisions changed. -#[allow(clippy::too_many_arguments)] -fn finish_validation( - doc: &Document, - bytes: &[u8], - trust: &TrustStore, - info: &mut SignatureInfo, - cache: &DigestCache, - covered: usize, - cert: Certificate, - embedded: &[Certificate], - cms_time: Option