Skip to main content

oxideav_pdf/reader/
sig.rs

1//! Round-21 — PDF `/Sig` annotation reader.
2//!
3//! Surfaces the digital-signature dictionaries embedded in a PDF as
4//! [`PdfSignature`] values, ready to be handed to the round-20
5//! [`crate::pubsec::verify::verify_signature`] CMS verifier.
6//!
7//! # ISO 32000 references
8//!
9//! * **§12.7.4.5 (Signature fields)** — a signature is embedded as an
10//!   interactive form field with `/FT /Sig`. The field's `/V` entry is
11//!   an indirect reference to a *signature dictionary*.
12//! * **§12.8.1 (Signature dictionaries)** — the signature dict carries:
13//!   * `/Type /Sig` (or `/DocTimeStamp` — both share the same shape).
14//!   * `/Filter /Adobe.PPKLite` (handler-specific, but always a Name).
15//!   * `/SubFilter /adbe.pkcs7.detached` (or `/adbe.pkcs7.sha1`,
16//!     `/ETSI.CAdES.detached`, `/ETSI.RFC3161` — the SubFilter names
17//!     the encoding of `/Contents`).
18//!   * `/Contents <hex-encoded CMS blob>` — for `*.detached` SubFilters
19//!     this is a complete CMS `SignedData` ContentInfo (RFC 5652 §5)
20//!     whose eContent is **omitted** (the signed bytes are the PDF
21//!     byte ranges named in `/ByteRange`).
22//!   * `/ByteRange [a b c d]` — two byte ranges of the PDF file that
23//!     together cover everything *except* the `<…hex…>` literal of
24//!     `/Contents`. The signed message is `pdf[a..a+b] ‖ pdf[c..c+d]`.
25//!   * Optional metadata: `/Name`, `/Reason`, `/Location`, `/ContactInfo`,
26//!     `/M` (signing time, PDF date string).
27//! * **§12.7.3.1 (Field hierarchy — terminal vs non-terminal fields)**
28//!   — a field tree may be flat (every leaf carries `/FT`) or nested
29//!   (parents carry `/FT`, kids inherit). The walker below recurses
30//!   through `/Kids` to find every terminal /Sig field.
31//!
32//! # Surface
33//!
34//! [`DocumentReader::signatures`] returns one [`PdfSignature`] per
35//! Sig form field whose `/V` resolves to a parseable signature dict.
36//! [`signed_bytes`] concatenates the two `/ByteRange`-named slices so
37//! the caller can pass them as `AttachedContent::External(&signed)` to
38//! [`crate::pubsec::verify::verify_signature`].
39//!
40//! Field walking is best-effort — a malformed Sig field (missing `/V`,
41//! unparseable `/Contents`, …) is skipped rather than aborting the whole
42//! document. The verifier itself stays strict: a parsed but invalid
43//! signature returns `Ok(false)`, a structural problem returns `Err`.
44
45use crate::error::PdfError;
46use crate::objects::{Dict, Object, ObjectId};
47use crate::pubsec::der;
48use crate::pubsec::signed_data::{parse_signed_data, SignedData};
49use crate::reader::document::DocumentReader;
50
51/// One PDF `/Sig` form field's signature dictionary, fully parsed and
52/// ready to verify.
53#[derive(Debug, Clone)]
54pub struct PdfSignature {
55    /// `/ByteRange [a b c d]` — exactly four signed integers per
56    /// ISO 32000-1 §12.8.1. The signed bytes are
57    /// `pdf[a..a+b] ‖ pdf[c..c+d]`. Stored as `i64` (the spec says
58    /// "integer", and Adobe-encoded files routinely overflow `u32` for
59    /// large PDFs — keeping `i64` matches the on-wire shape).
60    pub byte_range: [i64; 4],
61    /// `/Contents` hex-decoded — the raw CMS `SignedData` ContentInfo
62    /// blob (DER) for `adbe.pkcs7.detached` / `ETSI.CAdES.detached`, or
63    /// the raw RFC 3161 TimeStampToken for `ETSI.RFC3161`.
64    pub contents: Vec<u8>,
65    /// `/SubFilter` name — `adbe.pkcs7.detached`, `adbe.pkcs7.sha1`,
66    /// `ETSI.CAdES.detached`, `ETSI.RFC3161`, or any other handler-
67    /// specific name. `None` only when the dict omits it (extremely
68    /// non-conformant; we still surface the rest of the dict).
69    pub sub_filter: Option<String>,
70    /// `/Filter` name — typically `Adobe.PPKLite` or `Adobe.PPKMS`.
71    /// `None` when the dict omits it.
72    pub filter: Option<String>,
73    /// `/Type` name — `Sig` (default), `DocTimeStamp`, or absent.
74    pub sig_type: Option<String>,
75    /// Optional `/Name` — the human-readable signer name embedded by
76    /// the signing application (PDF text string).
77    pub name: Option<String>,
78    /// Optional `/Reason`.
79    pub reason: Option<String>,
80    /// Optional `/Location`.
81    pub location: Option<String>,
82    /// Optional `/ContactInfo`.
83    pub contact_info: Option<String>,
84    /// Optional `/M` — signing-time, PDF date format `D:YYYYMMDDHHmmSS`.
85    pub signing_time: Option<String>,
86    /// CMS `SignedData` parsed from [`Self::contents`]. Surfaced as
87    /// `Some` only when [`Self::sub_filter`] is one of the SubFilters
88    /// whose `/Contents` is a CMS `ContentInfo` blob — the round-21
89    /// reader does not parse RFC 3161 `TimeStampToken`s (those carry a
90    /// nested CMS as well, but the outer wrapper is different and the
91    /// signed message is the digest in `MessageImprint`, not the
92    /// `/ByteRange` body).
93    pub signed_data: Option<SignedData>,
94    /// The byte offset (in the original PDF) at which the signature
95    /// dictionary's `/Contents` `<…>` hex literal starts. Useful for
96    /// diagnostics and for round-trip rewriting (replace the placeholder
97    /// hex with a real signature, leaving everything else byte-stable).
98    /// Stored as `u64` so it can address arbitrarily large PDFs.
99    pub contents_offset: Option<u64>,
100}
101
102impl PdfSignature {
103    /// Compute the bytes the `/ByteRange` entry says were signed:
104    /// `pdf[a..a+b] ‖ pdf[c..c+d]`. Returns an error when any range
105    /// falls outside the input or when the byte-range integers are
106    /// negative.
107    pub fn signed_message(&self, pdf: &[u8]) -> Result<Vec<u8>, PdfError> {
108        signed_bytes(pdf, &self.byte_range)
109    }
110
111    /// `true` when this signature's `/SubFilter` names one of the CMS-
112    /// based detached forms whose `/Contents` is a complete CMS
113    /// `ContentInfo` blob. The verifier dispatch in
114    /// [`crate::pubsec::verify::verify_signature`] applies to these.
115    pub fn is_cms_detached(&self) -> bool {
116        matches!(
117            self.sub_filter.as_deref(),
118            Some("adbe.pkcs7.detached") | Some("ETSI.CAdES.detached")
119        )
120    }
121
122    /// `true` when this entry is a *document time-stamp* signature per
123    /// ISO 32000-1 §12.8.5 — i.e. the dict's `/Type` is `DocTimeStamp`
124    /// or its `/SubFilter` is `ETSI.RFC3161`. Either marker
125    /// independently identifies a DocTimeStamp (the spec allows both
126    /// `/Type /DocTimeStamp` *and* `/SubFilter /ETSI.RFC3161` —
127    /// real-world files frequently set both, but only one is required).
128    pub fn is_doc_timestamp(&self) -> bool {
129        self.sig_type.as_deref() == Some("DocTimeStamp")
130            || self.sub_filter.as_deref() == Some("ETSI.RFC3161")
131    }
132}
133
134/// One PDF `/DocTimeStamp` signature, surfaced separately from regular
135/// signatures so callers don't have to filter the [`PdfSignature`] list.
136///
137/// A DocTimeStamp's `/Contents` is an RFC 3161 `TimeStampToken` — a DER
138/// `ContentInfo` of type `id-signedData` whose `eContentType` is
139/// `id-ct-TSTInfo` (1.2.840.113549.1.9.16.1.4). The TST embeds the
140/// hash of the byte-ranged PDF content; callers who want to verify the
141/// stamp re-hash `pdf[a..a+b] ‖ pdf[c..c+d]` with the imprint's
142/// algorithm and compare with the `messageImprint.hashedMessage` field
143/// of the inner TSTInfo.
144///
145/// Round 34 surfaces the timestamp structurally; full RFC 3161
146/// verification dispatch (cert chain + GenTime ordering) lives in a
147/// follow-up round.
148#[derive(Debug, Clone)]
149pub struct PdfDocTimestamp {
150    /// `/ByteRange [a b c d]` — same shape as [`PdfSignature::byte_range`].
151    pub byte_range: [i64; 4],
152    /// `/Contents` hex-decoded — the raw RFC 3161 TimeStampToken bytes.
153    pub contents: Vec<u8>,
154    /// `/SubFilter` — `ETSI.RFC3161` for a conformant DocTimeStamp.
155    pub sub_filter: Option<String>,
156    /// `/Filter` — typically `Adobe.PPKLite`.
157    pub filter: Option<String>,
158}
159
160impl PdfDocTimestamp {
161    /// The bytes the time-stamp covers: `pdf[a..a+b] ‖ pdf[c..c+d]`.
162    pub fn signed_message(&self, pdf: &[u8]) -> Result<Vec<u8>, PdfError> {
163        signed_bytes(pdf, &self.byte_range)
164    }
165}
166
167/// Promote a [`PdfSignature`] to a [`PdfDocTimestamp`] when the entry's
168/// `/SubFilter` is `ETSI.RFC3161` (or the `/Type` is `DocTimeStamp`).
169/// Returns `None` for entries that aren't a doc-timestamp.
170fn promote_doc_timestamp(sig: &PdfSignature) -> Option<PdfDocTimestamp> {
171    if !sig.is_doc_timestamp() {
172        return None;
173    }
174    Some(PdfDocTimestamp {
175        byte_range: sig.byte_range,
176        contents: sig.contents.clone(),
177        sub_filter: sig.sub_filter.clone(),
178        filter: sig.filter.clone(),
179    })
180}
181
182/// Walk a [`DocumentReader`] and return only the document time-stamp
183/// signatures — the entries whose `/SubFilter` is `ETSI.RFC3161` or
184/// whose `/Type` is `DocTimeStamp` (ISO 32000-1 §12.8.5).
185///
186/// This is sugar over [`signatures`] + [`PdfSignature::is_doc_timestamp`].
187/// Callers that want both regular signatures and timestamps in one walk
188/// should call [`signatures`] directly and filter via `is_doc_timestamp`
189/// themselves.
190pub fn doc_timestamps(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfDocTimestamp>, PdfError> {
191    let sigs = signatures(reader)?;
192    Ok(sigs.iter().filter_map(promote_doc_timestamp).collect())
193}
194
195/// Concatenate the two byte ranges `[a b c d]` describes, returning
196/// `pdf[a..a+b] ‖ pdf[c..c+d]`.
197///
198/// Per ISO 32000-1 §12.8.1.1, `/ByteRange` covers the entire PDF *except*
199/// the `<…hex…>` literal of the signature's own `/Contents` entry — so
200/// the concatenation here is exactly the byte string the signing tool
201/// hashed.
202pub fn signed_bytes(pdf: &[u8], byte_range: &[i64; 4]) -> Result<Vec<u8>, PdfError> {
203    let [a, b, c, d] = *byte_range;
204    if a < 0 || b < 0 || c < 0 || d < 0 {
205        return Err(PdfError::other(format!(
206            "PDF /Sig: /ByteRange contains a negative integer ({byte_range:?})"
207        )));
208    }
209    let total = pdf.len() as u64;
210    let (a, b, c, d) = (a as u64, b as u64, c as u64, d as u64);
211    let end1 = a
212        .checked_add(b)
213        .ok_or_else(|| PdfError::other("PDF /Sig: /ByteRange overflow on first range"))?;
214    let end2 = c
215        .checked_add(d)
216        .ok_or_else(|| PdfError::other("PDF /Sig: /ByteRange overflow on second range"))?;
217    if end1 > total || end2 > total {
218        return Err(PdfError::other(format!(
219            "PDF /Sig: /ByteRange {byte_range:?} extends past file length {total}"
220        )));
221    }
222    if c < end1 {
223        return Err(PdfError::other(format!(
224            "PDF /Sig: /ByteRange {byte_range:?} second range starts ({c}) before first range ends ({end1})"
225        )));
226    }
227    let mut out = Vec::with_capacity((b + d) as usize);
228    out.extend_from_slice(&pdf[a as usize..end1 as usize]);
229    out.extend_from_slice(&pdf[c as usize..end2 as usize]);
230    Ok(out)
231}
232
233/// Walk a [`DocumentReader`] for every terminal `/FT /Sig` form field,
234/// returning one [`PdfSignature`] per field that has a parseable `/V`
235/// signature dictionary.
236///
237/// Field nesting (`/Kids`) is honoured: a non-terminal parent carrying
238/// `/FT /Sig` propagates the field type down to leaves that omit it
239/// (ISO 32000-1 §12.7.3.1). Fields with no `/V` (placeholder /
240/// not-yet-signed) are skipped silently — they don't carry signed bytes
241/// to verify.
242///
243/// The walker is also tolerant of:
244/// * Documents with no `/AcroForm` (returns an empty Vec).
245/// * `/AcroForm /Fields` arrays containing non-reference items
246///   (skipped).
247/// * Signature dicts with malformed `/Contents` or `/ByteRange`
248///   (skipped).
249///
250/// Returns `Err` only on infrastructural problems (catalog missing,
251/// xref errors propagating up from [`DocumentReader::resolve`]).
252pub fn signatures(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfSignature>, PdfError> {
253    let root_id = reader.xref().root()?;
254    let catalog = reader.resolve(root_id)?;
255    let Object::Dict(catalog) = catalog else {
256        return Ok(Vec::new());
257    };
258    let acro_form = catalog
259        .entries()
260        .iter()
261        .find(|(k, _)| k == "AcroForm")
262        .map(|(_, v)| v.clone());
263    let Some(acro_obj) = acro_form else {
264        return Ok(Vec::new());
265    };
266    let acro_dict = match reader.deref(acro_obj)? {
267        Object::Dict(d) => d,
268        _ => return Ok(Vec::new()),
269    };
270    let fields = acro_dict
271        .entries()
272        .iter()
273        .find(|(k, _)| k == "Fields")
274        .map(|(_, v)| v.clone());
275    let Some(Object::Array(field_refs)) = fields else {
276        return Ok(Vec::new());
277    };
278    let mut out = Vec::new();
279    for item in field_refs {
280        if let Object::Reference(id) = item {
281            walk_field(reader, id, /* inherited_ft = */ None, &mut out)?;
282        }
283    }
284    Ok(out)
285}
286
287/// Recursive `/Fields` / `/Kids` walker — terminal nodes carrying
288/// `/FT /Sig` are surfaced; `/Kids` arrays are recursed.
289fn walk_field(
290    reader: &mut DocumentReader<'_>,
291    field_id: ObjectId,
292    inherited_ft: Option<String>,
293    out: &mut Vec<PdfSignature>,
294) -> Result<(), PdfError> {
295    let field = reader.resolve(field_id)?;
296    let Object::Dict(d) = field else {
297        return Ok(());
298    };
299    let ft = d
300        .entries()
301        .iter()
302        .find(|(k, _)| k == "FT")
303        .and_then(|(_, v)| match v {
304            Object::Name(n) => Some(n.clone()),
305            _ => None,
306        })
307        .or(inherited_ft);
308
309    let kids = d
310        .entries()
311        .iter()
312        .find(|(k, _)| k == "Kids")
313        .map(|(_, v)| v.clone());
314    if let Some(Object::Array(items)) = kids {
315        // Non-terminal field — recurse.
316        for item in items {
317            if let Object::Reference(id) = item {
318                walk_field(reader, id, ft.clone(), out)?;
319            }
320        }
321        return Ok(());
322    }
323
324    // Terminal field. Only /FT /Sig is interesting to round 21.
325    if ft.as_deref() != Some("Sig") {
326        return Ok(());
327    }
328    let v = d
329        .entries()
330        .iter()
331        .find(|(k, _)| k == "V")
332        .map(|(_, v)| v.clone());
333    let Some(v) = v else {
334        return Ok(());
335    };
336    let sig_dict_obj = reader.deref(v)?;
337    let Object::Dict(sig_dict) = sig_dict_obj else {
338        return Ok(());
339    };
340    if let Some(parsed) = decode_sig_dict(&sig_dict)? {
341        out.push(parsed);
342    }
343    Ok(())
344}
345
346/// Convert a fully-resolved signature `Dict` into a [`PdfSignature`].
347/// Returns `Ok(None)` when the dict is missing required fields
348/// (`/ByteRange` or `/Contents`) — those are treated as "skip this
349/// signature, don't fail the doc".
350fn decode_sig_dict(dict: &Dict) -> Result<Option<PdfSignature>, PdfError> {
351    let lookup = |k: &str| {
352        dict.entries()
353            .iter()
354            .find(|(kk, _)| kk == k)
355            .map(|(_, v)| v.clone())
356    };
357
358    let byte_range = match lookup("ByteRange") {
359        Some(Object::Array(items)) if items.len() == 4 => {
360            let mut br = [0i64; 4];
361            for (i, item) in items.iter().enumerate() {
362                br[i] = match item {
363                    Object::Integer(n) => *n,
364                    Object::Real(f) => *f as i64,
365                    _ => return Ok(None),
366                };
367            }
368            br
369        }
370        _ => return Ok(None),
371    };
372
373    let contents = match lookup("Contents") {
374        // The lexer decoded the hex string already — the inner bytes
375        // are the raw DER blob.
376        Some(Object::HexString(bytes)) | Some(Object::LiteralString(bytes)) => bytes,
377        _ => return Ok(None),
378    };
379
380    let sub_filter = match lookup("SubFilter") {
381        Some(Object::Name(s)) => Some(s),
382        _ => None,
383    };
384    let filter = match lookup("Filter") {
385        Some(Object::Name(s)) => Some(s),
386        _ => None,
387    };
388    let sig_type = match lookup("Type") {
389        Some(Object::Name(s)) => Some(s),
390        _ => None,
391    };
392
393    let signed_data = if matches!(
394        sub_filter.as_deref(),
395        Some("adbe.pkcs7.detached") | Some("ETSI.CAdES.detached")
396    ) {
397        // Best-effort — a malformed CMS surfaces as `None` rather than
398        // failing the whole walk. Callers that care can re-parse via
399        // [`parse_signed_data`] directly to get the structural error.
400        //
401        // The hex literal in `/Contents` is a fixed-size budget chosen
402        // by the signing tool (Adobe / iText etc. routinely reserve
403        // more bytes than the actual SignedData consumes); the trailing
404        // bytes are zero-padding (or `0x00` after hex decode, since the
405        // reserved bytes are spec'd as `0`). `parse_signed_data` rejects
406        // trailing bytes, so trim to the outer SEQUENCE length first.
407        cms_trim_to_outer_sequence(&contents)
408            .ok()
409            .and_then(|trimmed| parse_signed_data(&trimmed).ok())
410    } else {
411        None
412    };
413
414    Ok(Some(PdfSignature {
415        byte_range,
416        contents,
417        sub_filter,
418        filter,
419        sig_type,
420        name: text_value(&lookup("Name")),
421        reason: text_value(&lookup("Reason")),
422        location: text_value(&lookup("Location")),
423        contact_info: text_value(&lookup("ContactInfo")),
424        signing_time: text_value(&lookup("M")),
425        signed_data,
426        contents_offset: None,
427    }))
428}
429
430/// Trim trailing bytes after the outer SEQUENCE in a CMS `ContentInfo`
431/// blob. Adobe / iText routinely reserve more bytes for the `/Contents`
432/// hex string than the actual SignedData consumes; the unused bytes
433/// are zero-padding (decoded to `0x00`). [`parse_signed_data`] rejects
434/// trailing bytes, so we ask the DER tag/length parser how long the
435/// outer SEQUENCE is and slice off the rest before handing it on.
436fn cms_trim_to_outer_sequence(data: &[u8]) -> Result<Vec<u8>, PdfError> {
437    let (tlv, _) = der::read_tlv(data)?;
438    // Outer SEQUENCE = tag(1) + length(1..5) + body. Re-derive the
439    // header length by subtracting body.len() from the position of
440    // body relative to data.
441    let body_offset = (tlv.body.as_ptr() as usize)
442        .checked_sub(data.as_ptr() as usize)
443        .ok_or_else(|| PdfError::other("CMS trim: body pointer math failed"))?;
444    let total = body_offset
445        .checked_add(tlv.body.len())
446        .ok_or_else(|| PdfError::other("CMS trim: total length overflow"))?;
447    if total > data.len() {
448        return Err(PdfError::other("CMS trim: SEQUENCE extends past input"));
449    }
450    Ok(data[..total].to_vec())
451}
452
453/// Decode a PDF text-string entry into a `String`. Mirrors the same
454/// rule [`crate::reader::document`] uses for `/Info` entries: a
455/// hex-string starting with the UTF-16BE BOM (`FE FF`) is decoded as
456/// UTF-16BE; everything else is treated as PDFDocEncoding-equivalent
457/// (close enough to UTF-8 for the ASCII subset Sig metadata uses in
458/// practice — and `from_utf8_lossy` keeps the path total).
459fn text_value(o: &Option<Object>) -> Option<String> {
460    let Some(o) = o else {
461        return None;
462    };
463    match o {
464        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
465        Object::HexString(b) => {
466            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
467                let utf16: Vec<u16> = b[2..]
468                    .chunks_exact(2)
469                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
470                    .collect();
471                Some(String::from_utf16_lossy(&utf16))
472            } else {
473                Some(String::from_utf8_lossy(b).into_owned())
474            }
475        }
476        _ => None,
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn signed_bytes_concatenates_two_ranges() {
486        let pdf = b"AAAABBBBCCCCDDDD";
487        // First range = bytes 0..4 ("AAAA"); skip "BBBB" (4 bytes);
488        // second range = bytes 8..16 ("CCCCDDDD").
489        let signed = signed_bytes(pdf, &[0, 4, 8, 8]).unwrap();
490        assert_eq!(signed, b"AAAACCCCDDDD");
491    }
492
493    #[test]
494    fn signed_bytes_rejects_negative_range() {
495        let pdf = b"AAAA";
496        assert!(signed_bytes(pdf, &[-1, 0, 0, 0]).is_err());
497        assert!(signed_bytes(pdf, &[0, -1, 0, 0]).is_err());
498    }
499
500    #[test]
501    fn signed_bytes_rejects_out_of_bounds() {
502        let pdf = b"AAAA";
503        // Range 0..5 doesn't fit a 4-byte file.
504        assert!(signed_bytes(pdf, &[0, 5, 5, 0]).is_err());
505        // Second range overruns.
506        assert!(signed_bytes(pdf, &[0, 2, 2, 5]).is_err());
507    }
508
509    #[test]
510    fn signed_bytes_rejects_overlapping_ranges() {
511        let pdf = b"AAAABBBBCCCC";
512        // Second range starts (3) before first range ends (4).
513        assert!(signed_bytes(pdf, &[0, 4, 3, 9]).is_err());
514    }
515
516    #[test]
517    fn signed_bytes_overflow_caught() {
518        let pdf = b"AAAA";
519        let huge = i64::MAX;
520        assert!(signed_bytes(pdf, &[huge, huge, 0, 0]).is_err());
521    }
522
523    #[test]
524    fn pdf_signature_is_cms_detached_recognises_two_subfilters() {
525        let mut s = PdfSignature {
526            byte_range: [0, 0, 0, 0],
527            contents: Vec::new(),
528            sub_filter: Some("adbe.pkcs7.detached".into()),
529            filter: None,
530            sig_type: None,
531            name: None,
532            reason: None,
533            location: None,
534            contact_info: None,
535            signing_time: None,
536            signed_data: None,
537            contents_offset: None,
538        };
539        assert!(s.is_cms_detached());
540        s.sub_filter = Some("ETSI.CAdES.detached".into());
541        assert!(s.is_cms_detached());
542        s.sub_filter = Some("ETSI.RFC3161".into());
543        assert!(!s.is_cms_detached());
544        s.sub_filter = None;
545        assert!(!s.is_cms_detached());
546    }
547}