Skip to main content

zpdf_document/
signature.rs

1//! Digital signature fields (ISO 32000-1 §12.8, ISO 32000-2 + PAdES/ETSI).
2//!
3//! An interactive-form field of type `/Sig` carries a **signature dictionary**
4//! (`/V`) describing a digital signature over the file: the handler that
5//! produced it (`/Filter`), the encoding of the signed data (`/SubFilter`), the
6//! human-declared signer / reason / location, and — the two entries that make
7//! the signature verifiable — a `/ByteRange` naming which spans of the file are
8//! signed and a `/Contents` string holding the CMS (PKCS #7) signature blob.
9//!
10//! This module reads that dictionary into a data model and performs two
11//! independent checks:
12//!
13//! 1. **Byte-range integrity** ([`DigestStatus`]): it recomputes the digest of
14//!    the signed byte range and compares it against the `messageDigest`
15//!    attribute embedded in the CMS structure. A match proves the covered bytes
16//!    are exactly what the signature committed to — the document was **not
17//!    altered inside the signed range** after signing.
18//!
19//! 2. **Cryptographic signature** ([`CryptoStatus`]): it verifies the signer's
20//!    RSA (PKCS #1 v1.5) or ECDSA (NIST P-256 / P-384) signature over the
21//!    signed attributes, using the public key of the first certificate carried
22//!    in the CMS blob. A [`CryptoStatus::Valid`] verdict proves the signed
23//!    attributes (which bind the `messageDigest`) were produced by the holder of
24//!    that certificate's private key.
25//!
26//! What this module deliberately does **not** do: validate the certificate
27//! chain to a trust anchor, check revocation (CRL/OCSP), or honour signing-time
28//! validity. Those require a trust store and network access, neither of which
29//! lives in this pure-Rust, dependency-light crate. So even a fully
30//! [`DigestStatus::Verified`] + [`CryptoStatus::Valid`] signature means "the
31//! signed bytes are intact and were signed by the private key matching the
32//! embedded certificate" — **not** "the signer is a trusted, non-revoked
33//! identity." Callers presenting this to users must not overstate it. See
34//! [`Signature::is_cryptographically_valid`].
35//!
36//! Everything here is bounded and best-effort: a malformed field tree, an
37//! out-of-range `/ByteRange`, an unsupported algorithm, or a corrupt CMS blob
38//! yields `None` / an [`DigestStatus::Unsupported`] / [`CryptoStatus::Unsupported`]
39//! verdict, never a panic.
40
41use std::collections::HashSet;
42
43use sha1::Sha1;
44use sha2::{Digest, Sha256, Sha384, Sha512};
45use zpdf_core::{ObjectId, PdfDict, PdfObject};
46use zpdf_parser::PdfFile;
47
48use crate::forms::pdf_string_to_unicode;
49
50/// Bounds on the field-tree walk (mirrors [`crate::forms`]).
51const MAX_FIELD_DEPTH: usize = 50;
52const MAX_SIG_FIELDS: usize = 4_096;
53/// Cap on the CMS blob we attempt to DER-parse. Real signatures — even with a
54/// full certificate chain and timestamp token — are comfortably under this;
55/// the cap bounds work against an adversarial `/Contents`.
56const MAX_CMS_BYTES: usize = 4 * 1024 * 1024;
57
58/// A digital signature attached to a `/Sig` form field.
59#[derive(Debug, Clone)]
60pub struct Signature {
61    /// The fully-qualified name of the signature field (`/T` chain).
62    pub field_name: String,
63    /// `/Filter` — the security handler that produced the signature
64    /// (conventionally `Adobe.PPKLite`).
65    pub filter: Option<String>,
66    /// `/SubFilter` — the encoding of the signed data, e.g.
67    /// `adbe.pkcs7.detached`, `adbe.pkcs7.sha1`, or `ETSI.CAdES.detached`
68    /// (PAdES).
69    pub sub_filter: Option<String>,
70    /// `/Name` — the signer's name as declared in the dictionary (not
71    /// cryptographically bound; see [`Signature::signer_common_name`]).
72    pub name: Option<String>,
73    /// `/M` — the signing time, as the raw PDF date string.
74    pub signing_time: Option<String>,
75    /// `/Location`.
76    pub location: Option<String>,
77    /// `/Reason`.
78    pub reason: Option<String>,
79    /// `/ContactInfo`.
80    pub contact_info: Option<String>,
81    /// The signed spans of the file (`/ByteRange`) and what they cover.
82    pub coverage: ByteRangeCoverage,
83    /// Result of comparing the recomputed digest of the covered bytes to the
84    /// digest embedded in the CMS blob.
85    pub digest: DigestStatus,
86    /// Result of verifying the signer's public-key signature over the CMS signed
87    /// attributes, using the first embedded certificate's public key.
88    pub crypto: CryptoStatus,
89    /// Human name of the digest algorithm named by the CMS `SignerInfo`
90    /// (`SHA-1`, `SHA-256`, …), when it could be identified.
91    pub digest_algorithm: Option<String>,
92    /// Human name of the signature (public-key) algorithm identified from the
93    /// CMS `SignerInfo` and the signer certificate (`RSA`, `ECDSA (P-256)`, …),
94    /// when it could be identified.
95    pub signature_algorithm: Option<String>,
96    /// The Common Name (`CN`) of the first certificate in the CMS blob —
97    /// typically, but not guaranteed to be, the signer's leaf certificate.
98    /// Best-effort; `None` when no certificate / CN could be extracted.
99    pub signer_common_name: Option<String>,
100    /// The raw CMS `SignedData` blob (`/Contents`), for follow-up checks such
101    /// as certificate-chain verification ([`crate::trust`]). `None` when the
102    /// dictionary carried no string /Contents.
103    pub cms_blob: Option<Vec<u8>>,
104    /// True when the CMS carries an RFC 3161 timestamp token in its
105    /// `unsignedAttrs` (a `signature-time-stamp` attribute). Best-effort
106    /// detection by OID scan; the TSA signature itself is not verified.
107    pub has_timestamp: bool,
108    /// Number of certificates embedded in the CMS `certificates` set (the
109    /// signer cert plus any chain), best-effort.
110    pub cert_count: usize,
111    /// Whether the document carries a `/DSS` (Document Security Store) on its
112    /// catalog — certs/CRLs/OCSP for LTV. `None` resolution falls back to false.
113    pub has_dss: bool,
114    /// Offline revocation status derived from CRLs embedded in the CMS or the
115    /// `/DSS`. No network fetching is performed.
116    pub revocation: RevocationStatus,
117}
118
119/// Offline certificate-revocation status, derived from CRLs embedded in the
120/// signature CMS or the document's `/DSS`. OCSP/CRL *fetching* over the network
121/// is out of scope (the project has no network dependency); this checks only
122/// the revocation material already embedded in the file.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RevocationStatus {
125    /// No CRL was available to check against.
126    Unknown,
127    /// The signer certificate is not listed on any embedded CRL.
128    NotRevoked,
129    /// The signer certificate's serial appears on an embedded CRL.
130    Revoked,
131}
132
133impl Signature {
134    /// True only when **both** checks pass: the signed bytes are intact
135    /// ([`DigestStatus::Verified`]) **and** the signer's signature over the
136    /// signed attributes verifies against the embedded certificate's public key
137    /// ([`CryptoStatus::Valid`]).
138    ///
139    /// This still does **not** establish trust: the certificate is not validated
140    /// against any anchor, nor checked for revocation. A `true` here means
141    /// "cryptographically sound, from the private key matching the embedded
142    /// certificate" — the certificate's *trustworthiness* is a separate,
143    /// out-of-scope question.
144    pub fn is_cryptographically_valid(&self) -> bool {
145        self.digest == DigestStatus::Verified && self.crypto == CryptoStatus::Valid
146    }
147}
148
149/// How a signature's `/ByteRange` covers the file.
150#[derive(Debug, Clone)]
151pub struct ByteRangeCoverage {
152    /// The `(offset, length)` spans of the file that are signed, in order.
153    pub ranges: Vec<(usize, usize)>,
154    /// True when the ranges start at byte 0 and the last range ends exactly at
155    /// end-of-file (the single gap being the `/Contents` placeholder) — i.e. the
156    /// signature covers the whole document.
157    pub covers_whole_document: bool,
158    /// Bytes present after the last signed span. Non-zero means the file was
159    /// extended after this signature was applied — a later incremental update
160    /// (possibly another signature, possibly a modification the signature does
161    /// not cover).
162    pub bytes_after_signature: usize,
163}
164
165/// Verdict of the byte-range digest check.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum DigestStatus {
168    /// The recomputed digest of the signed byte range matches the
169    /// `messageDigest` embedded in the CMS: the covered bytes are intact.
170    Verified,
171    /// The digests differ: the covered bytes were altered after signing.
172    Mismatch,
173    /// No comparable digest could be obtained — an unsupported `/SubFilter`,
174    /// an unknown digest algorithm, an out-of-range `/ByteRange`, or a CMS blob
175    /// without an extractable `messageDigest`. The other fields are still valid.
176    Unsupported,
177}
178
179impl DigestStatus {
180    pub fn as_str(self) -> &'static str {
181        match self {
182            DigestStatus::Verified => "verified",
183            DigestStatus::Mismatch => "mismatch",
184            DigestStatus::Unsupported => "unsupported",
185        }
186    }
187}
188
189/// Verdict of the public-key signature check over the CMS signed attributes.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum CryptoStatus {
192    /// The signer's signature over the signed attributes verifies against the
193    /// public key of the embedded (first) certificate.
194    Valid,
195    /// A signature and key were present and of a supported algorithm, but the
196    /// signature does **not** verify — a forged, corrupt, or wrong-key blob.
197    Invalid,
198    /// The signature could not be checked: an unsupported `/SubFilter`, no
199    /// signed attributes, an unsupported signature/key algorithm (e.g. DSA, an
200    /// RSA-PSS whose `RSASSA-PSS-params` cannot be parsed, or a curve other than
201    /// P-256/P-384), or an unparseable certificate / public key. The
202    /// [`DigestStatus`] check may still be meaningful.
203    Unsupported,
204}
205
206impl CryptoStatus {
207    pub fn as_str(self) -> &'static str {
208        match self {
209            CryptoStatus::Valid => "valid",
210            CryptoStatus::Invalid => "invalid",
211            CryptoStatus::Unsupported => "unsupported",
212        }
213    }
214}
215
216/// Parse all digital signatures in the document's AcroForm. Returns an empty
217/// vector when the document has no signature fields (the common case). Read-only
218/// and bounded; safe to call on adversarial input.
219pub fn parse_signatures(file: &PdfFile) -> Vec<Signature> {
220    let mut out = Vec::new();
221    let Some(fields) = acroform_fields(file) else {
222        return out;
223    };
224
225    let mut visited = HashSet::new();
226    for obj in &fields {
227        if let PdfObject::Ref(r) = obj {
228            walk(file, *r, "", None, 0, &mut visited, &mut out);
229        }
230    }
231    out
232}
233
234/// The `/Root /AcroForm /Fields` array, or `None`.
235fn acroform_fields(file: &PdfFile) -> Option<Vec<PdfObject>> {
236    let root_ref = file.trailer.get_ref("Root").ok()?;
237    let root = file.resolve(root_ref).ok()?;
238    let root = root.as_dict().ok()?;
239    let af = deref(file, root.get("AcroForm")?);
240    let af = af.as_dict().ok()?;
241    match deref(file, af.get("Fields")?) {
242        PdfObject::Array(a) => Some(a),
243        _ => None,
244    }
245}
246
247/// Walk the field tree, emitting a [`Signature`] for every terminal `/Sig` field
248/// whose `/V` resolves to a signature dictionary. `/FT` is inheritable, so it is
249/// threaded down from ancestors.
250fn walk(
251    file: &PdfFile,
252    id: ObjectId,
253    parent_name: &str,
254    inherited_ft: Option<&str>,
255    depth: usize,
256    visited: &mut HashSet<ObjectId>,
257    out: &mut Vec<Signature>,
258) {
259    if depth > MAX_FIELD_DEPTH || out.len() >= MAX_SIG_FIELDS || !visited.insert(id) {
260        return;
261    }
262    let Ok(obj) = file.resolve(id) else { return };
263    let Ok(dict) = obj.as_dict() else { return };
264
265    let partial = dict
266        .get("T")
267        .and_then(|o| text_string(file, o))
268        .unwrap_or_default();
269    let name = if partial.is_empty() {
270        parent_name.to_string()
271    } else if parent_name.is_empty() {
272        partial
273    } else {
274        format!("{parent_name}.{partial}")
275    };
276
277    let ft = dict
278        .get_name("FT")
279        .ok()
280        .map(String::from)
281        .or_else(|| inherited_ft.map(String::from));
282
283    // Interior node: recurse into child fields (those carrying their own /T).
284    let kids = match deref(file, dict.get("Kids").unwrap_or(&PdfObject::Null)) {
285        PdfObject::Array(a) => a,
286        _ => Vec::new(),
287    };
288    let mut has_child_field = false;
289    for kid in &kids {
290        if let PdfObject::Ref(r) = kid {
291            let has_t = file
292                .resolve(*r)
293                .ok()
294                .and_then(|o| o.as_dict().ok().map(|d| d.get("T").is_some()))
295                .unwrap_or(false);
296            if has_t {
297                has_child_field = true;
298                walk(file, *r, &name, ft.as_deref(), depth + 1, visited, out);
299            }
300        }
301    }
302    if has_child_field {
303        return;
304    }
305
306    // Terminal field: emit a signature when it is a /Sig field with a /V dict.
307    if ft.as_deref() != Some("Sig") {
308        return;
309    }
310    let Some(sig_dict) = deref(file, dict.get("V").unwrap_or(&PdfObject::Null))
311        .as_dict()
312        .ok()
313        .cloned()
314    else {
315        return;
316    };
317    out.push(build_signature(file, name, &sig_dict));
318}
319
320fn build_signature(file: &PdfFile, field_name: String, sig: &PdfDict) -> Signature {
321    let sub_filter = sig.get_name("SubFilter").ok().map(String::from);
322    let contents = match deref(file, sig.get("Contents").unwrap_or(&PdfObject::Null)) {
323        PdfObject::String(s) => Some(s.as_bytes().to_vec()),
324        _ => None,
325    };
326
327    let coverage = parse_byte_range(file, sig, file.data().len());
328    let outcome = verify(file, &coverage, contents.as_deref(), sub_filter.as_deref());
329
330    // Best-effort CMS analysis: timestamp detection, embedded cert count, and
331    // offline CRL revocation. `None` contents → all-default.
332    let (has_timestamp, cert_count, revocation) = match contents.as_deref() {
333        Some(cms) => (
334            revocation::has_timestamp(cms),
335            revocation::cert_count(cms),
336            revocation::revocation_status(cms),
337        ),
338        None => (false, 0, RevocationStatus::Unknown),
339    };
340    let has_dss = catalog_has_dss(file);
341
342    Signature {
343        field_name,
344        filter: sig.get_name("Filter").ok().map(String::from),
345        sub_filter,
346        name: sig.get("Name").and_then(|o| text_string(file, o)),
347        signing_time: sig.get("M").and_then(|o| text_string(file, o)),
348        location: sig.get("Location").and_then(|o| text_string(file, o)),
349        reason: sig.get("Reason").and_then(|o| text_string(file, o)),
350        contact_info: sig.get("ContactInfo").and_then(|o| text_string(file, o)),
351        coverage,
352        digest: outcome.digest,
353        crypto: outcome.crypto,
354        digest_algorithm: outcome.digest_algorithm,
355        signature_algorithm: outcome.signature_algorithm,
356        signer_common_name: outcome.signer_common_name,
357        cms_blob: contents,
358        has_timestamp,
359        cert_count,
360        has_dss,
361        revocation,
362    }
363}
364
365/// Whether the catalog carries a `/DSS` (Document Security Store).
366fn catalog_has_dss(file: &PdfFile) -> bool {
367    let Ok(root) = file.trailer.get_ref("Root") else {
368        return false;
369    };
370    let Ok(catalog) = file.resolve(root).and_then(|o| o.as_dict().cloned()) else {
371        return false;
372    };
373    matches!(
374        deref(file, catalog.get("DSS").unwrap_or(&PdfObject::Null)),
375        PdfObject::Dict(_) | PdfObject::Ref(_)
376    )
377}
378
379/// The full result of verifying one signature's CMS blob.
380struct VerifyOutcome {
381    digest: DigestStatus,
382    crypto: CryptoStatus,
383    digest_algorithm: Option<String>,
384    signature_algorithm: Option<String>,
385    signer_common_name: Option<String>,
386}
387
388/// Parse `/ByteRange` into `(offset, length)` spans and classify coverage.
389fn parse_byte_range(file: &PdfFile, sig: &PdfDict, file_len: usize) -> ByteRangeCoverage {
390    let mut ranges = Vec::new();
391    if let PdfObject::Array(arr) = deref(file, sig.get("ByteRange").unwrap_or(&PdfObject::Null)) {
392        let nums: Vec<i64> = arr
393            .iter()
394            .filter_map(|o| match deref(file, o) {
395                PdfObject::Integer(n) => Some(n),
396                PdfObject::Real(r) if r.is_finite() => Some(r as i64),
397                _ => None,
398            })
399            .collect();
400        for pair in nums.chunks_exact(2) {
401            if let (Ok(off), Ok(len)) = (usize::try_from(pair[0]), usize::try_from(pair[1])) {
402                ranges.push((off, len));
403            }
404        }
405    }
406
407    // Whole-document coverage: first span at 0, last span ends at EOF.
408    let covers_whole_document = ranges.first().zip(ranges.last()).is_some_and(
409        |(&(first_off, _), &(last_off, last_len))| {
410            first_off == 0 && last_off.saturating_add(last_len) == file_len
411        },
412    );
413    let end = ranges
414        .last()
415        .map(|&(off, len)| off.saturating_add(len))
416        .unwrap_or(0);
417    let bytes_after_signature = file_len.saturating_sub(end);
418
419    ByteRangeCoverage {
420        ranges,
421        covers_whole_document,
422        bytes_after_signature,
423    }
424}
425
426/// Recompute the covered-bytes digest, compare it to the CMS `messageDigest`,
427/// and verify the signer's public-key signature over the signed attributes.
428fn verify(
429    file: &PdfFile,
430    coverage: &ByteRangeCoverage,
431    contents: Option<&[u8]>,
432    sub_filter: Option<&str>,
433) -> VerifyOutcome {
434    let unsupported = VerifyOutcome {
435        digest: DigestStatus::Unsupported,
436        crypto: CryptoStatus::Unsupported,
437        digest_algorithm: None,
438        signature_algorithm: None,
439        signer_common_name: None,
440    };
441
442    let Some(cms) = contents.filter(|c| !c.is_empty() && c.len() <= MAX_CMS_BYTES) else {
443        return unsupported;
444    };
445
446    let Some(parsed) = cms::parse(cms) else {
447        return unsupported;
448    };
449    let digest_algorithm = parsed.digest_alg.map(|a| a.name().to_string());
450    let signature_algorithm = signature_alg_name(&parsed);
451    let signer_common_name = parsed.signer_cn.clone();
452
453    // The checks apply to the detached CMS SubFilters (PKCS#7 / CAdES), where the
454    // digest is taken over the byte range and stored as the messageDigest signed
455    // attribute. Other encodings (e.g. adbe.x509.rsa_sha1) are reported without a
456    // verdict.
457    let is_detached = matches!(
458        sub_filter,
459        Some("adbe.pkcs7.detached") | Some("ETSI.CAdES.detached")
460    );
461    if !is_detached {
462        return VerifyOutcome {
463            digest: DigestStatus::Unsupported,
464            crypto: CryptoStatus::Unsupported,
465            digest_algorithm,
466            signature_algorithm,
467            signer_common_name,
468        };
469    }
470
471    // (1) Byte-range digest vs the messageDigest signed attribute.
472    let digest = match (parsed.digest_alg, parsed.message_digest.as_deref()) {
473        (Some(alg), Some(embedded)) => match gather_ranges(file.data(), &coverage.ranges) {
474            Some(spans) => {
475                if alg.hash(&spans) == embedded {
476                    DigestStatus::Verified
477                } else {
478                    DigestStatus::Mismatch
479                }
480            }
481            None => DigestStatus::Unsupported, // /ByteRange out of file bounds
482        },
483        _ => DigestStatus::Unsupported,
484    };
485
486    // (2) Public-key signature over the signed attributes.
487    let crypto = verify_crypto(&parsed);
488
489    VerifyOutcome {
490        digest,
491        crypto,
492        digest_algorithm,
493        signature_algorithm,
494        signer_common_name,
495    }
496}
497
498/// Verify the signer's RSA/ECDSA signature over the CMS signed attributes using
499/// the embedded certificate's public key. Returns [`CryptoStatus::Unsupported`]
500/// whenever a required piece is missing or the algorithm is not one we handle.
501fn verify_crypto(p: &cms::Cms) -> CryptoStatus {
502    let (Some(attrs), Some(sig), Some(key), Some(dalg), Some(salg)) = (
503        p.signed_attrs_der.as_deref(),
504        p.signature.as_deref(),
505        p.signer_key.as_ref(),
506        p.digest_alg,
507        p.sig_alg,
508    ) else {
509        return CryptoStatus::Unsupported;
510    };
511
512    // The signature is computed over the DER encoding of the signed attributes,
513    // hashed with the SignerInfo digest algorithm.
514    let hashed = dalg.hash(attrs);
515
516    let verified = match (salg, key.alg) {
517        (cms::SigAlg::Rsa, cms::KeyAlg::Rsa) => pk::rsa_verify(dalg, &key.key, &hashed, sig),
518        (cms::SigAlg::Ecdsa, cms::KeyAlg::EcP256) => pk::ecdsa_p256_verify(&key.key, &hashed, sig),
519        (cms::SigAlg::Ecdsa, cms::KeyAlg::EcP384) => pk::ecdsa_p384_verify(&key.key, &hashed, sig),
520        (cms::SigAlg::RsaPss, cms::KeyAlg::Rsa) => {
521            // PSS carries its own hash/salt in the signatureAlgorithm params;
522            // they must be present and the hash must match the SignerInfo digest
523            // for the signature to be well-formed (RFC 4055 §3.1).
524            let Some(pp) = p.pss_params else {
525                return CryptoStatus::Unsupported;
526            };
527            if pp.hash != dalg {
528                return CryptoStatus::Unsupported;
529            }
530            pk::rsa_pss_verify(pp, &key.key, &hashed, sig)
531        }
532        // DSA, mismatched sig/key algorithms, or unsupported curves.
533        _ => return CryptoStatus::Unsupported,
534    };
535
536    match verified {
537        Some(true) => CryptoStatus::Valid,
538        Some(false) => CryptoStatus::Invalid,
539        None => CryptoStatus::Unsupported, // key/signature failed to parse
540    }
541}
542
543/// A display name combining the signer's public-key algorithm with the curve,
544/// e.g. `RSA`, `ECDSA (P-256)`, `RSA-PSS`.
545fn signature_alg_name(p: &cms::Cms) -> Option<String> {
546    let salg = p.sig_alg?;
547    Some(match salg {
548        cms::SigAlg::Rsa => "RSA".to_string(),
549        cms::SigAlg::RsaPss => "RSA-PSS".to_string(),
550        cms::SigAlg::Ecdsa => match p.signer_key.as_ref().map(|k| k.alg) {
551            Some(cms::KeyAlg::EcP256) => "ECDSA (P-256)".to_string(),
552            Some(cms::KeyAlg::EcP384) => "ECDSA (P-384)".to_string(),
553            _ => "ECDSA".to_string(),
554        },
555    })
556}
557
558/// Collect the covered byte spans into a single buffer, or `None` if any span
559/// falls outside the file (a malformed or tampered `/ByteRange`).
560fn gather_ranges(data: &[u8], ranges: &[(usize, usize)]) -> Option<Vec<u8>> {
561    if ranges.is_empty() {
562        return None;
563    }
564    let mut buf = Vec::new();
565    for &(off, len) in ranges {
566        let end = off.checked_add(len)?;
567        let slice = data.get(off..end)?;
568        buf.extend_from_slice(slice);
569    }
570    Some(buf)
571}
572
573// ---------------------------------------------------------------------------
574// Digest algorithms
575// ---------------------------------------------------------------------------
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578enum DigestAlg {
579    Sha1,
580    Sha256,
581    Sha384,
582    Sha512,
583}
584
585impl DigestAlg {
586    fn name(self) -> &'static str {
587        match self {
588            DigestAlg::Sha1 => "SHA-1",
589            DigestAlg::Sha256 => "SHA-256",
590            DigestAlg::Sha384 => "SHA-384",
591            DigestAlg::Sha512 => "SHA-512",
592        }
593    }
594
595    fn hash(self, data: &[u8]) -> Vec<u8> {
596        match self {
597            DigestAlg::Sha1 => Sha1::digest(data).to_vec(),
598            DigestAlg::Sha256 => Sha256::digest(data).to_vec(),
599            DigestAlg::Sha384 => Sha384::digest(data).to_vec(),
600            DigestAlg::Sha512 => Sha512::digest(data).to_vec(),
601        }
602    }
603
604    /// Map a digest-algorithm OID (the raw content bytes of the `06` TLV).
605    fn from_oid(oid: &[u8]) -> Option<DigestAlg> {
606        match oid {
607            [0x2b, 0x0e, 0x03, 0x02, 0x1a] => Some(DigestAlg::Sha1),
608            [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01] => Some(DigestAlg::Sha256),
609            [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02] => Some(DigestAlg::Sha384),
610            [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03] => Some(DigestAlg::Sha512),
611            _ => None,
612        }
613    }
614}
615
616// ---------------------------------------------------------------------------
617// Minimal, bounded DER / CMS reader
618// ---------------------------------------------------------------------------
619//
620// A hand-written TLV walker: enough of RFC 5652 (CMS SignedData) and X.509 to
621// pull the digest algorithm, the messageDigest signed attribute, and the first
622// certificate's subject CN. It never recurses without a depth bound, never
623// indexes past the buffer, and returns `None` on any structural surprise.
624
625mod cms {
626    use super::DigestAlg;
627
628    /// The pieces we extract from a CMS `SignedData` blob.
629    pub(super) struct Cms {
630        pub(super) digest_alg: Option<DigestAlg>,
631        pub(super) message_digest: Option<Vec<u8>>,
632        pub(super) signer_cn: Option<String>,
633        /// The signed attributes, DER-encoded with the outer `[0] IMPLICIT` tag
634        /// rewritten to `SET OF` (0x31) — exactly the bytes the signature is
635        /// computed over (RFC 5652 §5.4). `None` when the SignerInfo carries no
636        /// signed attributes.
637        pub(super) signed_attrs_der: Option<Vec<u8>>,
638        /// The `SignerInfo` signature value (the `signature` OCTET STRING).
639        pub(super) signature: Option<Vec<u8>>,
640        /// The signature (public-key) algorithm from the `SignerInfo`.
641        pub(super) sig_alg: Option<SigAlg>,
642        /// RSASSA-PSS parameters carried by the `signatureAlgorithm` (RFC 4055):
643        /// the hash and MGF digest algorithms plus the salt length. Present only
644        /// for `id-RSASSA-PSS`; `None` otherwise or when the params cannot be
645        /// parsed (in which case PSS verification falls back to `Unsupported`).
646        pub(super) pss_params: Option<PssParams>,
647        /// The public key of the first embedded certificate.
648        pub(super) signer_key: Option<PublicKeyInfo>,
649    }
650
651    /// RSASSA-PSS parameters parsed from the `signatureAlgorithm` (RFC 4055
652    /// §3.1). The MGF is always MGF1; we only record its hash, which for
653    /// well-formed PSS equals the message hash. `salt_len` is in bytes.
654    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
655    pub(super) struct PssParams {
656        pub(super) hash: DigestAlg,
657        pub(super) mgf_hash: DigestAlg,
658        pub(super) salt_len: usize,
659    }
660
661    /// The public-key algorithm named by the `SignerInfo` `signatureAlgorithm`.
662    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
663    pub(super) enum SigAlg {
664        /// RSA PKCS #1 v1.5 (`rsaEncryption` or `sha*WithRSAEncryption`).
665        Rsa,
666        /// RSA-PSS (`id-RSASSA-PSS`, RFC 4055). Parameters are parsed from the
667        /// `signatureAlgorithm` into [`PssParams`] and verified with MGF1.
668        RsaPss,
669        /// ECDSA (`ecdsa-with-SHA*`).
670        Ecdsa,
671    }
672
673    /// A signer certificate's public key: its algorithm and raw key material.
674    pub(super) struct PublicKeyInfo {
675        pub(super) alg: KeyAlg,
676        /// For RSA: the `RSAPublicKey` DER (`SEQUENCE { modulus, exponent }`).
677        /// For ECDSA: the SEC1-encoded public point.
678        pub(super) key: Vec<u8>,
679    }
680
681    /// The public-key algorithm of a certificate's `SubjectPublicKeyInfo`.
682    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
683    pub(super) enum KeyAlg {
684        Rsa,
685        EcP256,
686        EcP384,
687    }
688
689    // DER tags we care about.
690    const SEQUENCE: u8 = 0x30;
691    const SET: u8 = 0x31;
692    const OID: u8 = 0x06;
693    const OCTET_STRING: u8 = 0x04;
694    const BIT_STRING: u8 = 0x03;
695    const CONTEXT_0: u8 = 0xA0; // [0] constructed / EXPLICIT
696
697    // OIDs (raw content bytes).
698    const OID_SIGNED_DATA: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02];
699    const OID_MESSAGE_DIGEST: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x04];
700    const OID_CN: &[u8] = &[0x55, 0x04, 0x03];
701
702    // Public-key / signature algorithm OIDs.
703    // RSA family: 1.2.840.113549.1.1.{1=rsaEncryption, 10=PSS, 4/5/11/12/13=sha*WithRSA}.
704    const OID_RSA_PREFIX: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01];
705    const OID_RSA_PSS: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0a];
706    // id-mgf1 1.2.840.113549.1.1.8 — the only MGF we recognise (RFC 4055).
707    const OID_MGF1: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x08];
708    // rsaEncryption 1.2.840.113549.1.1.1 (SPKI key algorithm).
709    const OID_RSA_PUBLIC_KEY: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01];
710    // EC: id-ecPublicKey 1.2.840.10045.2.1; ecdsa-with-* 1.2.840.10045.4.*.
711    const OID_EC_PUBLIC_KEY: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
712    const OID_ECDSA_PREFIX: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04];
713    // Named curves.
714    const OID_CURVE_P256: &[u8] = &[0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
715    const OID_CURVE_P384: &[u8] = &[0x2b, 0x81, 0x04, 0x00, 0x22];
716
717    /// Read one DER TLV from the front of `buf`: returns `(tag, content, rest)`.
718    /// Rejects the indefinite-length form and lengths that run past `buf`.
719    fn tlv(buf: &[u8]) -> Option<(u8, &[u8], &[u8])> {
720        if buf.len() < 2 {
721            return None;
722        }
723        let tag = buf[0];
724        let first = buf[1];
725        let (len, header) = if first < 0x80 {
726            (first as usize, 2)
727        } else {
728            let n = (first & 0x7f) as usize;
729            if n == 0 || n > 4 || buf.len() < 2 + n {
730                return None; // indefinite length, or absurdly large length field
731            }
732            let mut len = 0usize;
733            for &b in &buf[2..2 + n] {
734                len = (len << 8) | b as usize;
735            }
736            (len, 2 + n)
737        };
738        let end = header.checked_add(len)?;
739        if end > buf.len() {
740            return None;
741        }
742        Some((tag, &buf[header..end], &buf[end..]))
743    }
744
745    /// Collect the TLVs directly contained in `content`, up to `max` items.
746    fn children(content: &[u8], max: usize) -> Vec<(u8, &[u8])> {
747        let mut out = Vec::new();
748        let mut rest = content;
749        while !rest.is_empty() && out.len() < max {
750            let Some((tag, body, next)) = tlv(rest) else {
751                break;
752            };
753            out.push((tag, body));
754            rest = next;
755        }
756        out
757    }
758
759    /// Like [`children`], but each entry also carries the element's **full** raw
760    /// bytes (tag + length + content) — needed to re-encode the signed
761    /// attributes for hashing. Returns `(tag, content, full_tlv)`.
762    #[allow(clippy::type_complexity)]
763    fn children_raw(content: &[u8], max: usize) -> Vec<(u8, &[u8], &[u8])> {
764        let mut out = Vec::new();
765        let mut rest = content;
766        while !rest.is_empty() && out.len() < max {
767            let before = rest;
768            let Some((tag, body, next)) = tlv(rest) else {
769                break;
770            };
771            let consumed = before.len() - next.len();
772            out.push((tag, body, &before[..consumed]));
773            rest = next;
774        }
775        out
776    }
777
778    pub(super) fn parse(blob: &[u8]) -> Option<Cms> {
779        // ContentInfo ::= SEQUENCE { contentType OID, content [0] SignedData }
780        let (tag, ci, _) = tlv(blob)?;
781        if tag != SEQUENCE {
782            return None;
783        }
784        let ci = children(ci, 4);
785        let ctype = ci.iter().find(|(t, _)| *t == OID)?;
786        if ctype.1 != OID_SIGNED_DATA {
787            return None;
788        }
789        let content = ci.iter().find(|(t, _)| *t == CONTEXT_0)?;
790        // content [0] EXPLICIT wraps the SignedData SEQUENCE.
791        let (tag, signed_data, _) = tlv(content.1)?;
792        if tag != SEQUENCE {
793            return None;
794        }
795
796        // SignedData ::= SEQUENCE { version, digestAlgorithms SET,
797        //   encapContentInfo, certificates [0]?, crls [1]?, signerInfos SET }
798        let sd = children(signed_data, 16);
799        // signerInfos is the last SET; digestAlgorithms is the first SET.
800        let signer_infos = sd.iter().rev().find(|(t, _)| *t == SET)?;
801        let certs = sd.iter().find(|(t, _)| *t == CONTEXT_0).map(|(_, c)| *c);
802
803        // signerInfos SET OF SignerInfo — take the first SignerInfo.
804        let (tag, signer_info, _) = tlv(signer_infos.1)?;
805        if tag != SEQUENCE {
806            return None;
807        }
808        let si = children_raw(signer_info, 16);
809
810        // SignerInfo: version INT, sid, digestAlgorithm SEQ, signedAttrs [0]?,
811        // signatureAlgorithm SEQ, signature OCTET, unsignedAttrs [1]?.
812        // `sid` (issuerAndSerialNumber) is *also* a SEQUENCE, so we can't pick the
813        // algorithm SEQUENCEs positionally. Instead classify each SEQUENCE's OID:
814        // sid's OIDs are X.509 attribute types (2.5.4.x) — never digest or
815        // signature OIDs — so the first SEQUENCE yielding each is unambiguous.
816        let seq_oid = |seq: &[u8]| -> Option<Vec<u8>> {
817            children(seq, 2)
818                .iter()
819                .find(|(t, _)| *t == OID)
820                .map(|(_, oid)| oid.to_vec())
821        };
822        let digest_alg = si
823            .iter()
824            .filter(|(t, _, _)| *t == SEQUENCE)
825            .find_map(|(_, seq, _)| seq_oid(seq).and_then(|oid| DigestAlg::from_oid(&oid)));
826        // `signatureAlgorithm` is the SignerInfo SEQUENCE whose OID classifies as
827        // a signature algorithm (the `digestAlgorithm` SEQUENCE's OID is a digest
828        // OID, never a signature OID, so the two are distinguishable). Capture the
829        // full SEQUENCE so RSASSA-PSS parameters can be parsed alongside the OID.
830        let sig_alg_seq = si.iter().find_map(|(_, seq, _)| {
831            seq_oid(seq).and_then(|oid| sig_alg_from_oid(&oid).map(|alg| (alg, seq)))
832        });
833        let sig_alg = sig_alg_seq.map(|(alg, _)| alg);
834        // RSASSA-PSS parameters live in the `signatureAlgorithm` SEQUENCE after
835        // the `id-RSASSA-PSS` OID. Only parse them for PSS; absent/ill-formed
836        // params leave `None` and PSS verification reports `Unsupported`.
837        let pss_params = sig_alg_seq.and_then(|(alg, seq)| {
838            (alg == SigAlg::RsaPss)
839                .then_some(())
840                .and_then(|_| parse_pss_params(seq))
841        });
842
843        // signedAttrs is the [0] IMPLICIT tag; its content is the concatenated
844        // Attribute SEQUENCEs. Find the messageDigest attribute.
845        let signed_attrs = si.iter().find(|(t, _, _)| *t == CONTEXT_0);
846        let message_digest = signed_attrs.and_then(|(_, attrs, _)| find_message_digest(attrs));
847        // For hashing, the [0] IMPLICIT tag is replaced by SET OF (RFC 5652 §5.4).
848        let signed_attrs_der = signed_attrs.map(|(_, _, full)| {
849            let mut der = full.to_vec();
850            der[0] = SET;
851            der
852        });
853
854        // The signature value is the OCTET STRING after the two algorithm SEQs.
855        let signature = si
856            .iter()
857            .find(|(t, _, _)| *t == OCTET_STRING)
858            .map(|(_, body, _)| body.to_vec());
859
860        let signer_cn = certs.and_then(first_cert_cn);
861        let signer_key = certs.and_then(first_cert_public_key);
862
863        Some(Cms {
864            digest_alg,
865            message_digest,
866            signer_cn,
867            signed_attrs_der,
868            signature,
869            sig_alg,
870            pss_params,
871            signer_key,
872        })
873    }
874
875    /// Classify a `SignerInfo` `signatureAlgorithm` OID into an [`SigAlg`].
876    fn sig_alg_from_oid(oid: &[u8]) -> Option<SigAlg> {
877        if oid == OID_RSA_PSS {
878            Some(SigAlg::RsaPss)
879        } else if oid.starts_with(OID_RSA_PREFIX) {
880            // rsaEncryption or any sha*WithRSAEncryption → PKCS#1 v1.5.
881            Some(SigAlg::Rsa)
882        } else if oid.starts_with(OID_ECDSA_PREFIX) {
883            Some(SigAlg::Ecdsa)
884        } else {
885            None
886        }
887    }
888
889    /// Parse the `RSASSA-PSS-params` (RFC 4055 §3.1) carried by an
890    /// `id-RSASSA-PSS` `signatureAlgorithm` SEQUENCE. The OID is the first
891    /// child; the params SEQUENCE is the second. Defaults (SHA-1, MGF1-SHA-1,
892    /// 20-byte salt) apply to omitted fields, matching OpenSSL's output for the
893    /// common PSS-with-SHA-256/salt-32 case where all fields are present.
894    fn parse_pss_params(sig_alg_seq: &[u8]) -> Option<PssParams> {
895        // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters ANY? }.
896        let parts = children(sig_alg_seq, 2);
897        let params = parts.iter().find(|(t, _)| *t == SEQUENCE)?.1;
898
899        // RSASSA-PSS-params ::= SEQUENCE {
900        //   hashAlgorithm    [0] HashAlgorithm   DEFAULT sha1,
901        //   maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
902        //   saltLength       [2] INTEGER           DEFAULT 20,
903        //   trailerField     [3] TrailerField      DEFAULT trailerFieldBC }
904        // RFC 4055 tags `hashAlgorithm`/`maskGenAlgorithm` EXPLICIT (their
905        // underlying type is a SEQUENCE, which EXPLICIT keeps intact), while
906        // `saltLength` is IMPLICIT (an INTEGER content under [2]). Real-world
907        // encoders (OpenSSL, Windows) emit EXPLICIT [0]/[1]; some emit IMPLICIT.
908        // Accept both: EXPLICIT = 0xA0/0xA1 wrapping a SEQUENCE; IMPLICIT =
909        // 0x80/0x81 carrying the AlgorithmIdentifier body directly.
910        const EXPLICIT_0: u8 = 0xA0;
911        const EXPLICIT_1: u8 = 0xA1;
912        const IMPLICIT_0: u8 = 0x80;
913        const IMPLICIT_1: u8 = 0x81;
914        const IMPLICIT_2: u8 = 0x82;
915        let fields = children(params, 8);
916
917        /// Resolve a `[0]`/`[1]` field to the inner `AlgorithmIdentifier` body:
918        /// for EXPLICIT, unwrap the single contained SEQUENCE; for IMPLICIT,
919        /// the body *is* the AlgorithmIdentifier.
920        fn alg_id_body(tag: u8, body: &[u8]) -> Option<&[u8]> {
921            match tag {
922                EXPLICIT_0 | EXPLICIT_1 => {
923                    // One SEQUENCE inside.
924                    let (t, inner, _) = tlv(body)?;
925                    (t == SEQUENCE).then_some(inner)
926                }
927                IMPLICIT_0 | IMPLICIT_1 => Some(body),
928                _ => None,
929            }
930        }
931
932        let hash = fields
933            .iter()
934            .find(|(t, _)| *t == EXPLICIT_0 || *t == IMPLICIT_0)
935            .and_then(|(t, body)| alg_id_body(*t, body))
936            .and_then(algorithm_identifier_digest)
937            .unwrap_or(DigestAlg::Sha1);
938
939        // MaskGenAlgorithm ::= AlgorithmIdentifier; for id-mgf1 the parameter
940        // is the hash AlgorithmIdentifier. RFC 4055 allows only MGF1.
941        let mgf_hash = fields
942            .iter()
943            .find(|(t, _)| *t == EXPLICIT_1 || *t == IMPLICIT_1)
944            .and_then(|(t, body)| alg_id_body(*t, body))
945            .and_then(|body| {
946                let mgf_parts = children(body, 2);
947                let mgf_oid = mgf_parts.iter().find(|(t, _)| *t == OID)?.1;
948                (mgf_oid == OID_MGF1).then_some(())?;
949                // The MGF1 parameter is the hash AlgorithmIdentifier SEQUENCE.
950                let hash_alg = mgf_parts.iter().find(|(t, _)| *t == SEQUENCE)?.1;
951                algorithm_identifier_digest(hash_alg)
952            })
953            .unwrap_or(DigestAlg::Sha1);
954
955        let salt_len = fields
956            .iter()
957            .find(|(t, _)| *t == IMPLICIT_2)
958            .and_then(|(_, body)| read_unsigned_integer(body))
959            .unwrap_or(20);
960
961        Some(PssParams {
962            hash,
963            mgf_hash,
964            salt_len,
965        })
966    }
967
968    /// Read the digest algorithm from an `AlgorithmIdentifier` body (the full
969    /// `SEQUENCE` content — OID plus optional NULL params), or `None` when the
970    /// OID is not a recognised SHA digest.
971    fn algorithm_identifier_digest(alg_id: &[u8]) -> Option<DigestAlg> {
972        let oid = children(alg_id, 2)
973            .iter()
974            .find(|(t, _)| *t == OID)
975            .map(|(_, oid)| *oid)?;
976        DigestAlg::from_oid(oid)
977    }
978
979    /// Decode a DER INTEGER's content as a non-negative `usize`. Rejects
980    /// negative numbers (high bit set on the first content byte) and overlong
981    /// encodings — PSS salt lengths are tiny, so cap at 4 bytes.
982    fn read_unsigned_integer(content: &[u8]) -> Option<usize> {
983        if content.is_empty() || content.len() > 4 {
984            return None;
985        }
986        // Negative INTEGERs have the high bit set on the first content byte.
987        if content[0] & 0x80 != 0 {
988            return None;
989        }
990        let mut v = 0usize;
991        for &b in content {
992            v = (v << 8) | b as usize;
993        }
994        Some(v)
995    }
996
997    /// Within a signed-attributes body (concatenated `Attribute` SEQUENCEs),
998    /// find the `messageDigest` attribute's OCTET STRING value.
999    fn find_message_digest(attrs: &[u8]) -> Option<Vec<u8>> {
1000        for (tag, attr) in children(attrs, 64) {
1001            if tag != SEQUENCE {
1002                continue;
1003            }
1004            // Attribute ::= SEQUENCE { attrType OID, attrValues SET }
1005            let parts = children(attr, 4);
1006            let is_md = parts
1007                .iter()
1008                .find(|(t, _)| *t == OID)
1009                .is_some_and(|(_, oid)| *oid == OID_MESSAGE_DIGEST);
1010            if !is_md {
1011                continue;
1012            }
1013            let values = parts.iter().find(|(t, _)| *t == SET)?;
1014            let (vtag, digest, _) = tlv(values.1)?;
1015            if vtag == OCTET_STRING {
1016                return Some(digest.to_vec());
1017            }
1018        }
1019        None
1020    }
1021
1022    /// Extract the subject Common Name of the first X.509 certificate in the
1023    /// `certificates [0]` body. Best-effort.
1024    fn first_cert_cn(certs: &[u8]) -> Option<String> {
1025        // The first Certificate ::= SEQUENCE { tbsCertificate, sigAlg, sig }.
1026        let (tag, cert, _) = tlv(certs)?;
1027        if tag != SEQUENCE {
1028            return None;
1029        }
1030        let (tag, tbs, _) = tlv(cert)?;
1031        if tag != SEQUENCE {
1032            return None;
1033        }
1034        // TBSCertificate SEQUENCEs, in order: signatureAlg, issuer, validity,
1035        // subject, spki. The subject Name is the 4th SEQUENCE.
1036        let subject = children(tbs, 16)
1037            .into_iter()
1038            .filter(|(t, _)| *t == SEQUENCE)
1039            .nth(3)?;
1040        // subject Name ::= SEQUENCE OF RDN(SET) OF ATV(SEQUENCE{OID, value}).
1041        for (tag, rdn) in children(subject.1, 32) {
1042            if tag != SET {
1043                continue;
1044            }
1045            for (tag, atv) in children(rdn, 8) {
1046                if tag != SEQUENCE {
1047                    continue;
1048                }
1049                let parts = children(atv, 2);
1050                let is_cn = parts
1051                    .iter()
1052                    .find(|(t, _)| *t == OID)
1053                    .is_some_and(|(_, oid)| *oid == OID_CN);
1054                if is_cn {
1055                    if let Some((vtag, value)) = parts.iter().rev().find(|(t, _)| *t != OID) {
1056                        return Some(decode_directory_string(*vtag, value));
1057                    }
1058                }
1059            }
1060        }
1061        None
1062    }
1063
1064    /// Extract the [`PublicKeyInfo`] from the first X.509 certificate's
1065    /// `SubjectPublicKeyInfo`. Best-effort; `None` on any structural surprise or
1066    /// an unsupported key algorithm / curve.
1067    fn first_cert_public_key(certs: &[u8]) -> Option<PublicKeyInfo> {
1068        // Certificate ::= SEQUENCE { tbsCertificate, sigAlg, sig }.
1069        let (tag, cert, _) = tlv(certs)?;
1070        if tag != SEQUENCE {
1071            return None;
1072        }
1073        let (tag, tbs, _) = tlv(cert)?;
1074        if tag != SEQUENCE {
1075            return None;
1076        }
1077        // TBSCertificate SEQUENCEs, in order: signatureAlg, issuer, validity,
1078        // subject, subjectPublicKeyInfo. The SPKI is the 5th SEQUENCE.
1079        let spki = children(tbs, 16)
1080            .into_iter()
1081            .filter(|(t, _)| *t == SEQUENCE)
1082            .nth(4)?;
1083
1084        // SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier,
1085        //   subjectPublicKey BIT STRING }.
1086        let spki_parts = children(spki.1, 2);
1087        let alg_id = spki_parts.iter().find(|(t, _)| *t == SEQUENCE)?.1;
1088        let bit_string = spki_parts.iter().find(|(t, _)| *t == BIT_STRING)?.1;
1089        // A BIT STRING's first content byte is the count of unused trailing bits
1090        // (0 for keys); the key itself follows.
1091        let key_bytes = bit_string
1092            .split_first()
1093            .and_then(|(unused, rest)| (*unused == 0).then(|| rest.to_vec()))?;
1094
1095        // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters ANY? }.
1096        let alg_parts = children(alg_id, 2);
1097        let alg_oid = alg_parts.iter().find(|(t, _)| *t == OID)?.1;
1098
1099        if alg_oid == OID_RSA_PUBLIC_KEY {
1100            Some(PublicKeyInfo {
1101                alg: KeyAlg::Rsa,
1102                key: key_bytes,
1103            })
1104        } else if alg_oid == OID_EC_PUBLIC_KEY {
1105            // The named curve is the *second* OID (the AlgorithmIdentifier
1106            // parameter) after id-ecPublicKey.
1107            let curve = alg_parts
1108                .iter()
1109                .filter(|(t, _)| *t == OID)
1110                .nth(1)
1111                .map(|(_, oid)| *oid)?;
1112            let alg = if curve == OID_CURVE_P256 {
1113                KeyAlg::EcP256
1114            } else if curve == OID_CURVE_P384 {
1115                KeyAlg::EcP384
1116            } else {
1117                return None;
1118            };
1119            Some(PublicKeyInfo {
1120                alg,
1121                key: key_bytes,
1122            })
1123        } else {
1124            None
1125        }
1126    }
1127
1128    /// Decode an X.520 DirectoryString value by tag: BMPString is UTF-16BE, the
1129    /// rest (UTF8String / PrintableString / IA5String / …) are treated as UTF-8.
1130    fn decode_directory_string(tag: u8, value: &[u8]) -> String {
1131        const BMP_STRING: u8 = 0x1e;
1132        if tag == BMP_STRING {
1133            let units: Vec<u16> = value
1134                .chunks_exact(2)
1135                .map(|c| u16::from_be_bytes([c[0], c[1]]))
1136                .collect();
1137            String::from_utf16_lossy(&units)
1138        } else {
1139            String::from_utf8_lossy(value).into_owned()
1140        }
1141    }
1142}
1143
1144// ---------------------------------------------------------------------------
1145// Public-key signature verification (RustCrypto)
1146// ---------------------------------------------------------------------------
1147//
1148// Each verifier takes the already-computed digest of the signed attributes and
1149// the raw signature/key bytes, and returns `Some(true)` on a valid signature,
1150// `Some(false)` on a well-formed-but-failing one, or `None` when the key or
1151// signature could not be parsed at all.
1152
1153mod pk {
1154    use super::DigestAlg;
1155    use rsa::pkcs1::DecodeRsaPublicKey;
1156    use rsa::{Pkcs1v15Sign, RsaPublicKey};
1157    use sha1::Sha1;
1158    use sha2::{Sha256, Sha384, Sha512};
1159
1160    /// Verify an RSA PKCS #1 v1.5 signature. `key_der` is the `RSAPublicKey`
1161    /// DER (`SEQUENCE { modulus, publicExponent }`); `hashed` is the digest of
1162    /// the signed attributes under `alg`.
1163    pub(super) fn rsa_verify(
1164        alg: DigestAlg,
1165        key_der: &[u8],
1166        hashed: &[u8],
1167        sig: &[u8],
1168    ) -> Option<bool> {
1169        let key = RsaPublicKey::from_pkcs1_der(key_der).ok()?;
1170        let scheme = match alg {
1171            DigestAlg::Sha1 => Pkcs1v15Sign::new::<Sha1>(),
1172            DigestAlg::Sha256 => Pkcs1v15Sign::new::<Sha256>(),
1173            DigestAlg::Sha384 => Pkcs1v15Sign::new::<Sha384>(),
1174            DigestAlg::Sha512 => Pkcs1v15Sign::new::<Sha512>(),
1175        };
1176        Some(key.verify(scheme, hashed, sig).is_ok())
1177    }
1178
1179    /// Verify an ECDSA signature over the NIST P-256 curve. `point` is the
1180    /// SEC1-encoded public point; `sig` is the DER-encoded `(r, s)`.
1181    pub(super) fn ecdsa_p256_verify(point: &[u8], hashed: &[u8], sig: &[u8]) -> Option<bool> {
1182        use p256::ecdsa::signature::hazmat::PrehashVerifier;
1183        use p256::ecdsa::{Signature, VerifyingKey};
1184        let key = VerifyingKey::from_sec1_bytes(point).ok()?;
1185        let sig = Signature::from_der(sig).ok()?;
1186        Some(key.verify_prehash(hashed, &sig).is_ok())
1187    }
1188
1189    /// Verify an ECDSA signature over the NIST P-384 curve.
1190    pub(super) fn ecdsa_p384_verify(point: &[u8], hashed: &[u8], sig: &[u8]) -> Option<bool> {
1191        use p384::ecdsa::signature::hazmat::PrehashVerifier;
1192        use p384::ecdsa::{Signature, VerifyingKey};
1193        let key = VerifyingKey::from_sec1_bytes(point).ok()?;
1194        let sig = Signature::from_der(sig).ok()?;
1195        Some(key.verify_prehash(hashed, &sig).is_ok())
1196    }
1197
1198    /// Verify an RSASSA-PSS signature (RFC 4055). `key_der` is the
1199    /// `RSAPublicKey` DER; `hashed` is the digest of the signed attributes
1200    /// under `params.hash`; `sig` is the raw PSS-encoded signature. MGF1 with
1201    /// `params.mgf_hash` and the embedded salt length are honoured. Returns
1202    /// `None` when the key or signature cannot be parsed.
1203    pub(super) fn rsa_pss_verify(
1204        params: super::cms::PssParams,
1205        key_der: &[u8],
1206        hashed: &[u8],
1207        sig: &[u8],
1208    ) -> Option<bool> {
1209        use rsa::pkcs1::DecodeRsaPublicKey;
1210        use rsa::pss::Pss;
1211        use rsa::RsaPublicKey;
1212        use sha1::Sha1;
1213        use sha2::{Sha256, Sha384, Sha512};
1214
1215        let key = RsaPublicKey::from_pkcs1_der(key_der).ok()?;
1216        // `Pss::new::<D>()` defaults the salt length to the digest size; the
1217        // embedded `salt_len` (which may differ) is honoured via `new_with_salt`.
1218        // `SignatureScheme::verify` takes the prehash digest directly.
1219        let valid = match params.hash {
1220            DigestAlg::Sha1 => {
1221                let scheme = Pss::new_with_salt::<Sha1>(params.salt_len);
1222                key.verify(scheme, hashed, sig).is_ok()
1223            }
1224            DigestAlg::Sha256 => {
1225                let scheme = Pss::new_with_salt::<Sha256>(params.salt_len);
1226                key.verify(scheme, hashed, sig).is_ok()
1227            }
1228            DigestAlg::Sha384 => {
1229                let scheme = Pss::new_with_salt::<Sha384>(params.salt_len);
1230                key.verify(scheme, hashed, sig).is_ok()
1231            }
1232            DigestAlg::Sha512 => {
1233                let scheme = Pss::new_with_salt::<Sha512>(params.salt_len);
1234                key.verify(scheme, hashed, sig).is_ok()
1235            }
1236        };
1237        // Note: MGF1's hash (`params.mgf_hash`) normally equals `params.hash`
1238        // in well-formed PSS. rsa's `Pss` always derives MGF1 from the same
1239        // digest as the signature hash; a mismatch is reported as Unsupported
1240        // by the caller before reaching here when it would matter, and a
1241        // genuine mismatch would simply fail verification (→ Invalid).
1242        let _ = params.mgf_hash;
1243        Some(valid)
1244    }
1245}
1246
1247// ---------------------------------------------------------------------------
1248// Small object-graph helpers (local copies, mirroring crate::forms)
1249// ---------------------------------------------------------------------------
1250
1251fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
1252    match obj {
1253        PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
1254        other => other.clone(),
1255    }
1256}
1257
1258fn text_string(file: &PdfFile, obj: &PdfObject) -> Option<String> {
1259    match deref(file, obj) {
1260        PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
1261        _ => None,
1262    }
1263}
1264
1265/// Best-effort CMS analysis for production-grade signing: timestamp
1266/// detection, embedded certificate count, and offline CRL revocation. All
1267/// pure-DER byte walks over the `cms_blob`; no network, no crypto verification
1268/// of the TSA or CRL signatures (the material is trusted as embedded).
1269mod revocation {
1270    use super::RevocationStatus;
1271
1272    /// OID for the CMS `signature-time-stamp` attribute (1.2.840.113549.1.9.16.2.14).
1273    const OID_TIMESTAMP: &[u8] = &[
1274        0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x10, 0x02, 0x0e,
1275    ];
1276
1277    /// True when the CMS carries a `signature-time-stamp` unsigned attribute.
1278    pub fn has_timestamp(cms: &[u8]) -> bool {
1279        cms.windows(OID_TIMESTAMP.len()).any(|w| w == OID_TIMESTAMP)
1280    }
1281
1282    /// Count the certificates in the CMS `certificates [0]` set (best-effort).
1283    pub fn cert_count(cms: &[u8]) -> usize {
1284        match signed_data(cms) {
1285            Some(sd) => match field(sd, 0xA0) {
1286                Some(certs_blob) => children(certs_blob).len(),
1287                None => 0,
1288            },
1289            None => 0,
1290        }
1291    }
1292
1293    /// Offline revocation: parse CRLs from `crls [1]`, collect their revoked
1294    /// serials, and compare to the signer cert's serial (the first cert in
1295    /// `certificates [0]`). `Unknown` when there are no CRLs or the serials
1296    /// cannot be extracted.
1297    pub fn revocation_status(cms: &[u8]) -> RevocationStatus {
1298        let Some(sd) = signed_data(cms) else {
1299            return RevocationStatus::Unknown;
1300        };
1301        let crls_blob = match field(sd, 0xA1) {
1302            Some(b) => b,
1303            None => return RevocationStatus::Unknown, // no CRLs embedded
1304        };
1305        let crls = children(crls_blob);
1306        if crls.is_empty() {
1307            return RevocationStatus::Unknown;
1308        }
1309        // Signer cert serial = first cert's serial.
1310        let signer_serial = field(sd, 0xA0)
1311            .and_then(|certs| children(certs).into_iter().next())
1312            .and_then(cert_serial);
1313        let Some(signer_serial) = signer_serial else {
1314            return RevocationStatus::Unknown;
1315        };
1316        for crl in crls {
1317            for revoked in crl_revoked_serials(crl) {
1318                if revoked == signer_serial {
1319                    return RevocationStatus::Revoked;
1320                }
1321            }
1322        }
1323        RevocationStatus::NotRevoked
1324    }
1325
1326    /// (tag, content, rest-after-this-TLV) of the DER TLV at the start.
1327    fn tlv_split(bytes: &[u8]) -> Option<(u8, &[u8], &[u8])> {
1328        let &tag = bytes.first()?;
1329        let len_byte = *bytes.get(1)?;
1330        let (len, hdr) = if len_byte < 0x80 {
1331            (len_byte as usize, 2)
1332        } else {
1333            let n = (len_byte & 0x7f) as usize;
1334            if !(1..=4).contains(&n) || bytes.len() < 2 + n {
1335                return None;
1336            }
1337            let mut l = 0usize;
1338            for i in 0..n {
1339                l = (l << 8) | bytes[2 + i] as usize;
1340            }
1341            (l, 2 + n)
1342        };
1343        let content = bytes.get(hdr..hdr + len)?;
1344        Some((tag, content, &bytes[hdr + len..]))
1345    }
1346
1347    /// Top-level TLV byte slices within `content`.
1348    fn children(content: &[u8]) -> Vec<&[u8]> {
1349        let mut out = Vec::new();
1350        let mut rest = content;
1351        while let Some((_, _, next)) = tlv_split(rest) {
1352            let total = rest.len() - next.len();
1353            out.push(&rest[..total]);
1354            rest = next;
1355        }
1356        out
1357    }
1358
1359    /// The SignedData content (the inner of ContentInfo's `[0]`), or `None`.
1360    fn signed_data(cms: &[u8]) -> Option<&[u8]> {
1361        // ContentInfo ::= SEQUENCE { OID, [0] SignedData }
1362        let (_, outer, _) = tlv_split(cms)?;
1363        let kids = children(outer);
1364        kids.into_iter().find_map(|k| {
1365            let (tag, content, _) = tlv_split(k)?;
1366            (tag == 0xA0).then_some(content)
1367        })
1368    }
1369
1370    /// The content of the `[ctx_tag]` IMPLICIT field within a SignedData, if present.
1371    fn field(sd: &[u8], ctx_tag: u8) -> Option<&[u8]> {
1372        let (_, content, _) = tlv_split(sd)?;
1373        for k in children(content) {
1374            let (tag, c, _) = tlv_split(k)?;
1375            if tag == ctx_tag {
1376                return Some(c);
1377            }
1378        }
1379        None
1380    }
1381
1382    /// The serial number bytes of a certificate (DER), if parseable.
1383    fn cert_serial(cert: &[u8]) -> Option<Vec<u8>> {
1384        // Certificate ::= SEQUENCE { tbsCertificate SEQUENCE, ... }
1385        let (_, tbs, _) = tlv_split(cert)?;
1386        let (_, tbs_content, _) = tlv_split(tbs)?;
1387        let mut kids = children(tbs_content).into_iter();
1388        // tbsCertificate: [version [0]]?, serialNumber INT, ...
1389        let first = kids.next()?;
1390        let (tag, _content, _) = tlv_split(first)?;
1391        let serial_field = if tag == 0xA0 {
1392            // version present; serial is next.
1393            kids.next()?
1394        } else {
1395            first
1396        };
1397        let (st, sc, _) = tlv_split(serial_field)?;
1398        (st == 0x02).then_some(sc.to_vec())
1399    }
1400
1401    /// Revoked serials from a CRL (CertificateList), if it carries revokedCertificates.
1402    fn crl_revoked_serials(crl: &[u8]) -> Vec<Vec<u8>> {
1403        // CertificateList ::= SEQUENCE { tbsCertList SEQ, ... }
1404        let (_, tbs, _) = match tlv_split(crl) {
1405            Some(x) => x,
1406            None => return Vec::new(),
1407        };
1408        let (_, tbs_content, _) = match tlv_split(tbs) {
1409            Some(x) => x,
1410            None => return Vec::new(),
1411        };
1412        // tbsCertList: version? INT, signature SEQ, issuer SEQ, thisUpdate Time,
1413        // nextUpdate? Time, revokedCertificates? SEQ OF, extensions? [0].
1414        let mut out = Vec::new();
1415        let mut seen_time = false;
1416        for k in children(tbs_content) {
1417            let (tag, content, _) = match tlv_split(k) {
1418                Some(x) => x,
1419                None => continue,
1420            };
1421            if tag == 0x17 || tag == 0x18 {
1422                // UTCTime / GeneralizedTime
1423                seen_time = true;
1424                continue;
1425            }
1426            if tag == 0x30 && seen_time {
1427                // revokedCertificates: SEQUENCE OF SEQUENCE { serial INT, ... }
1428                for entry in children(content) {
1429                    if let Some((_, ec, _)) = tlv_split(entry) {
1430                        if let Some(serial_tlv) = children(ec).into_iter().next() {
1431                            if let Some((st, sc, _)) = tlv_split(serial_tlv) {
1432                                if st == 0x02 {
1433                                    out.push(sc.to_vec());
1434                                }
1435                            }
1436                        }
1437                    }
1438                }
1439                break;
1440            }
1441        }
1442        out
1443    }
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448    use super::*;
1449
1450    // ---- DER / CMS unit tests -------------------------------------------
1451
1452    /// Build a DER TLV with short/long length as appropriate.
1453    fn der(tag: u8, content: &[u8]) -> Vec<u8> {
1454        let mut out = vec![tag];
1455        let len = content.len();
1456        if len < 0x80 {
1457            out.push(len as u8);
1458        } else if len < 0x100 {
1459            out.push(0x81);
1460            out.push(len as u8);
1461        } else {
1462            out.push(0x82);
1463            out.push((len >> 8) as u8);
1464            out.push((len & 0xff) as u8);
1465        }
1466        out.extend_from_slice(content);
1467        out
1468    }
1469
1470    const SEQ: u8 = 0x30;
1471    const SET: u8 = 0x31;
1472    const OID: u8 = 0x06;
1473    const OCTET: u8 = 0x04;
1474    const INT: u8 = 0x02;
1475    const CTX0: u8 = 0xA0;
1476
1477    /// Hand-assemble a minimal detached-CMS blob carrying `digest` as the
1478    /// messageDigest attribute, signed with SHA-256.
1479    fn synth_cms(digest: &[u8]) -> Vec<u8> {
1480        let sha256_oid = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01];
1481        let md_oid = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x04];
1482
1483        // digestAlgorithm SEQUENCE { OID sha256 }
1484        let digest_alg = der(SEQ, &der(OID, &sha256_oid));
1485
1486        // messageDigest Attribute SEQUENCE { OID, SET { OCTET digest } }
1487        let md_attr = der(
1488            SEQ,
1489            &[der(OID, &md_oid), der(SET, &der(OCTET, digest))].concat(),
1490        );
1491        // signedAttrs [0] IMPLICIT holding the one attribute.
1492        let signed_attrs = der(CTX0, &md_attr);
1493
1494        // SignerInfo SEQUENCE { version, sid(SEQ), digestAlg(SEQ), signedAttrs[0],
1495        //   sigAlg(SEQ), signature(OCTET) }
1496        let signer_info = der(
1497            SEQ,
1498            &[
1499                der(INT, &[1]),
1500                der(SEQ, &[]), // sid placeholder
1501                digest_alg.clone(),
1502                signed_attrs,
1503                der(SEQ, &der(OID, &[0x2a])), // sigAlg placeholder
1504                der(OCTET, &[0xde, 0xad]),    // signature placeholder
1505            ]
1506            .concat(),
1507        );
1508        let signer_infos = der(SET, &signer_info);
1509
1510        // SignedData SEQUENCE { version, digestAlgorithms SET, encap SEQ, signerInfos SET }
1511        let signed_data = der(
1512            SEQ,
1513            &[
1514                der(INT, &[1]),
1515                der(SET, &digest_alg),
1516                der(
1517                    SEQ,
1518                    &der(OID, &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01]),
1519                ),
1520                signer_infos,
1521            ]
1522            .concat(),
1523        );
1524
1525        // ContentInfo SEQUENCE { OID signedData, [0] SignedData }
1526        let signed_data_oid = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02];
1527        der(
1528            SEQ,
1529            &[der(OID, &signed_data_oid), der(CTX0, &signed_data)].concat(),
1530        )
1531    }
1532
1533    #[test]
1534    fn cms_extracts_digest_and_algorithm() {
1535        let digest: Vec<u8> = (0u8..32).collect();
1536        let blob = synth_cms(&digest);
1537        let parsed = cms::parse(&blob).expect("cms");
1538        assert_eq!(parsed.digest_alg, Some(DigestAlg::Sha256));
1539        assert_eq!(parsed.message_digest.as_deref(), Some(digest.as_slice()));
1540    }
1541
1542    #[test]
1543    fn cms_rejects_truncated_blob() {
1544        let blob = synth_cms(&[0u8; 32]);
1545        // Any prefix shorter than the whole must not panic; parse returns None
1546        // or a partial-but-safe result.
1547        for cut in 1..blob.len() {
1548            let _ = cms::parse(&blob[..cut]);
1549        }
1550    }
1551
1552    #[test]
1553    fn cms_rejects_indefinite_length() {
1554        // Tag SEQUENCE, indefinite length byte 0x80 — DER forbids it.
1555        assert!(cms::parse(&[0x30, 0x80, 0x00, 0x00]).is_none());
1556    }
1557
1558    #[test]
1559    fn digest_alg_oid_mapping() {
1560        assert_eq!(
1561            DigestAlg::from_oid(&[0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]),
1562            Some(DigestAlg::Sha256)
1563        );
1564        assert_eq!(
1565            DigestAlg::from_oid(&[0x2b, 0x0e, 0x03, 0x02, 0x1a]),
1566            Some(DigestAlg::Sha1)
1567        );
1568        assert_eq!(DigestAlg::from_oid(&[0x00]), None);
1569    }
1570
1571    #[test]
1572    fn gather_ranges_bounds_checked() {
1573        let data = b"0123456789";
1574        assert_eq!(
1575            gather_ranges(data, &[(0, 3), (7, 3)]).as_deref(),
1576            Some(&b"012789"[..])
1577        );
1578        // Out-of-range span is rejected.
1579        assert!(gather_ranges(data, &[(0, 3), (7, 99)]).is_none());
1580        assert!(gather_ranges(data, &[]).is_none());
1581    }
1582
1583    #[test]
1584    fn sha256_matches_reference() {
1585        // "abc" SHA-256, a well-known test vector.
1586        let d = DigestAlg::Sha256.hash(b"abc");
1587        assert_eq!(
1588            d,
1589            hex(b"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
1590        );
1591    }
1592
1593    fn hex(h: &[u8]) -> Vec<u8> {
1594        h.chunks_exact(2)
1595            .map(|c| {
1596                let s = std::str::from_utf8(c).unwrap();
1597                u8::from_str_radix(s, 16).unwrap()
1598            })
1599            .collect()
1600    }
1601
1602    // ---- Robustness / adversarial inputs ---------------------------------
1603
1604    use crate::test_util::build_pdf;
1605    use zpdf_parser::PdfFile;
1606
1607    /// A signature field with an out-of-range /ByteRange (spans past EOF) must
1608    /// not panic, and must report Unsupported (cannot verify what doesn't exist).
1609    #[test]
1610    fn out_of_range_byte_range_reports_unsupported() {
1611        let pdf = build_pdf(&[
1612            "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
1613            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1614            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
1615            "<< /Fields [5 0 R] >>",
1616            "<< /FT /Sig /T (S1) /V << /ByteRange [0 100 200 999999] /Contents <aabbcc> >> >>",
1617        ]);
1618        let file = PdfFile::parse(pdf.as_slice()).expect("parse");
1619        let sigs = parse_signatures(&file);
1620        assert_eq!(sigs.len(), 1);
1621        // An out-of-range span cannot be hashed; verdict is Unsupported.
1622        assert_eq!(sigs[0].digest, DigestStatus::Unsupported);
1623    }
1624
1625    /// A corrupt or oversized /Contents blob must not hang or panic. The parser
1626    /// caps CMS size at 4 MiB and the DER walker rejects truncated/indefinite TLVs.
1627    #[test]
1628    fn malformed_cms_contents_do_not_hang() {
1629        let pdf = build_pdf(&[
1630            "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
1631            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1632            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
1633            "<< /Fields [5 0 R 6 0 R 7 0 R] >>",
1634            // Corrupt: truncated SEQUENCE (length claims 0x30 bytes, content is 4).
1635            "<< /FT /Sig /T (Truncated) /V << /ByteRange [0 10 20 30] /Contents <30304142> >> >>",
1636            // Empty /Contents.
1637            "<< /FT /Sig /T (Empty) /V << /ByteRange [0 10 20 30] /Contents <> >> >>",
1638            // Indefinite-length form (DER forbids): tag 0x30, length 0x80.
1639            "<< /FT /Sig /T (Indefinite) /V << /ByteRange [0 10 20 30] /Contents <308000> >> >>",
1640        ]);
1641        let file = PdfFile::parse(pdf.as_slice()).expect("parse");
1642        let sigs = parse_signatures(&file);
1643        // All three parse, but none can extract a digest (Unsupported).
1644        assert_eq!(sigs.len(), 3);
1645        for s in &sigs {
1646            assert_eq!(s.digest, DigestStatus::Unsupported);
1647        }
1648    }
1649
1650    /// A pathological field tree (deep nesting, many fields) must terminate cleanly.
1651    #[test]
1652    fn deep_field_tree_terminates() {
1653        // 60 fields in a flat tree (exceeds MAX_SIG_FIELDS = 4096 is impractical
1654        // in a hand-rolled PDF; test depth instead). A chain of 100 nested /Kids
1655        // exceeds MAX_FIELD_DEPTH = 50 and is pruned.
1656        let mut objs = vec![
1657            "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".to_string(),
1658            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
1659            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>".to_string(),
1660            "<< /Fields [5 0 R] >>".to_string(),
1661        ];
1662        // Build a chain: obj 5 → obj 6 → obj 7 … → obj 104 (100 links).
1663        for i in 0..100 {
1664            let next = if i < 99 {
1665                format!("{} 0 R", 5 + i + 1)
1666            } else {
1667                "null".to_string()
1668            };
1669            objs.push(format!("<< /T (Field{i}) /FT /Sig /Kids [{}] >>", next));
1670        }
1671        let pdf = build_pdf(&objs.iter().map(|s| s.as_str()).collect::<Vec<_>>());
1672        let file = PdfFile::parse(pdf.as_slice()).expect("parse");
1673        let sigs = parse_signatures(&file);
1674        // The walk terminates at depth 50; no signatures are extracted (none had /V).
1675        assert!(sigs.len() < 100);
1676    }
1677}