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}