diff --git a/REVIEWS/adr/0010-pdf-signatures.md b/REVIEWS/adr/0010-pdf-signatures.md deleted file mode 100644 index 50987da..0000000 --- a/REVIEWS/adr/0010-pdf-signatures.md +++ /dev/null @@ -1,189 +0,0 @@ -# ADR 0010: PDF digital signatures — read, report, verify integrity; never sign - -- **Status:** Accepted -- **Date:** 2026-07-31 -- **Review item:** Phase 8, `DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md` - ("Signatures", 4–6 weeks, "CMS/X.509 via `x509-cert` + `rsa`/`p256`, trust - store, interop tests against OpenSSL") -- **Supersedes:** nothing -- **Related:** ADR 0003 (incremental save — the byte range an append - preserves is exactly what a signature covers), ADR 0005 (encryption — same - rule that audited crates do the cryptography) - -## Context - -A signed PDF today is not merely unverified. It is **misreported**, in two -distinct ways, and one of them is not limited to signatures at all. - -### 1. `acroform()` silently dropped fields - -`PdfDocument::acroform` dereferenced the `/AcroForm` entry with -`self.resolve()`, which recurses. That replaced every `/Fields [5 0 R]` -entry with an inline dictionary, so `AcroForm::walk` saw -`node.as_ref() == None`, concluded the field had no identity to key an edit -on, and dropped it. - -Probing a signed document whose `/AcroForm` is a direct dictionary: - -``` -acroform present, 0 fields -annotations on page 0: 1 -``` - -The form is not reported as broken. It is reported as **empty**, which is -indistinguishable from a document that has no fields — so nothing announces -the loss. - -This is the third appearance of one defect: `page_annotations` once called -`self.resolve()` on `/Annots` and destroyed every annotation's `obj_ref`, -and `extract_xobjects` resolved a reference and then asked the *resolved* -object for `as_ref()`, leaving every page's XObject map empty. Same -mistake, third location. - -It was masked because both existing fixtures (`acroform.pdf`, -`corpus/forms/all_types.pdf`) declare `/AcroForm` as an **indirect** -reference, where only one level is dereferenced and the `/Fields` refs -survive. A direct `/AcroForm` dictionary — equally legal, and what the -signature fixture uses — hits the bug. The test suite could not have caught -it. - -### 2. Nothing reads a signature at all - -`FieldType::Signature` is classified and then nothing else happens. -`/ByteRange`, `/Contents`, `/SubFilter`, `/M`, `/Name`, `/Reason`, -`/DocMDP` are never read anywhere in the codebase. A document carrying a -signature is presented exactly like an unsigned one. - -For a viewer, that is the dangerous direction of failure. A user shown a -signed contract with no indication that it is signed — or worse, no -indication that the signature covers only part of the file — has been -given less information than the file contains. - -## Decision - -Implement signature **reading, reporting and byte-range integrity -checking**. Do not implement cryptographic verification in this ADR, and do -not implement signing at all. - -That split is deliberate and is the whole point of the decision, so it is -stated plainly rather than buried: - -### In scope - -- Parse every signature field: `/ByteRange`, `/Contents`, `/Filter`, - `/SubFilter`, `/M`, `/Name`, `/Reason`, `/Location`, `/ContactInfo`. -- Classify the `/SubFilter` into a known algorithm family - (`adbe.pkcs7.detached`, `adbe.pkcs7.sha1`, `adbe.x509.rsa_sha1`, - `ETSI.CAdES.detached`) and report an unknown one by name. -- **Byte-range integrity**, which needs no cryptography and catches the - most common real-world tampering: - - the four `/ByteRange` numbers are well-formed, ascending, non-negative - and inside the file; - - the gap between the two ranges is exactly the hex `/Contents` string, - so the signature covers everything except its own container; - - **whether the range reaches the end of the file** — a signature that - stops short leaves appended bytes uncovered, which is precisely how an - incremental-update attack hides content behind a valid-looking - signature. -- Report **document-level** state: is the document signed, how many - signatures, does any of them cover the whole file, and is there a - `/DocMDP` transform declaring the permitted modifications. -- A typed `SignatureError` for every failure mode. - -### Explicitly out of scope, and refused rather than faked - -- **Cryptographic verification.** Parsing CMS/PKCS#7, walking an X.509 - chain, checking validity dates, revocation (CRL/OCSP), and timestamp - tokens. This needs `x509-cert`, `rsa`, `p256`, `cms` and a **trust - store** — a policy decision about which roots to trust, which belongs to - the host, not to a parsing library. -- **Signing.** Creating a signature requires a private key, and ADR 0003 - already refuses to save an encrypted document for the same class of - reason: a library should not make a security decision on the user's - behalf. - -The API therefore reports `VerificationStatus::NotVerified` with a reason, -never `Valid`. **There is no code path in this ADR that can return -"valid".** That is enforced by the type: the `Valid` variant does not -exist. A future ADR that adds real verification adds the variant with the -cryptography, in the same change. - -This matters more than it might look. The failure mode for a signature -feature is not "it doesn't work" — it is a green tick next to a document -whose signature was never checked. Refusing to render that tick until the -cryptography is real is the only honest position, and making it a type-level -guarantee means it cannot be undone by accident. - -## Non-negotiable rules - -1. **Nothing claims a signature is valid.** No `Valid` variant exists until - real cryptography backs it. -2. **A signature that does not cover the whole file is reported as such**, - prominently, because that is an attack signature and not a curiosity. -3. **Byte-range arithmetic is bounds-checked against the real file length.** - A `/ByteRange` is attacker-controlled input. -4. **An unknown `/SubFilter` is named, not ignored.** -5. **No signing, and no private-key handling, anywhere in this crate.** -6. **The `acroform()` reference bug is fixed with a regression test that - fails against the old code**, and the fixture uses a direct `/AcroForm` - dictionary, because an indirect one cannot reproduce it. - -## Merge criteria - -- [x] `acroform()` returns signature fields; a direct-`/AcroForm` fixture - regression-tests the reference-destroying bug. -- [x] `/ByteRange` and `/Contents` are parsed, with the hex string decoded. -- [x] A signature covering the whole file is distinguished from one that - does not. -- [x] A `/ByteRange` that is malformed, out of order, negative or past the - end of the file is a typed error, not a panic and not a pass. -- [x] The gap between the two ranges is checked to be exactly the - `/Contents` container. -- [x] `/SubFilter` is classified, and an unknown one is reported by name. -- [x] Signer metadata (`/M`, `/Name`, `/Reason`, `/Location`) is exposed. -- [x] `/DocMDP` permissions are reported when present. -- [x] The API cannot report a signature as cryptographically valid, and a - test asserts the absence of that capability. -- [x] Corpus fixtures: a whole-file signature, a partial-coverage - signature, a malformed byte range, an unknown SubFilter, and a - certification (`/DocMDP`) signature — all generated by the checked-in - script. -- [x] Incremental save over a signed document is shown to preserve the - signed byte range. -- [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes, rustfmt and - clippy `-D warnings` clean. - -All criteria met. `TEST_TARGET=pdf` went from 497 to 536. - -The `acroform()` regression test was mutation-checked: reverting the -one-line fix fails 9 of the 12 signature acceptance tests. A -`parse_signature` fuzz target was added because `/ByteRange` is four -attacker-controlled integers used to index the file. - -One implementation bug worth recording, because it is the kind that ships -quietly: `/Contents` was initially hex-decoded. The COS lexer already -decodes `<...>`, so this ran twice — and since the common placeholder blob -is 128 zero bytes, which contain no hex digits, it decoded to an **empty -vector**. The signature blob was silently discarded while every other field -looked correct. Caught by asserting the blob is non-empty rather than -asserting the parse returned `Ok`. - -## Consequences - -**Positive.** A viewer can finally tell the user a document is signed, who -signed it, and — critically — whether the signature covers the whole file. -The byte-range checks catch incremental-update tampering with no -cryptography at all. The `acroform()` fix repairs *every* form with a direct -`/AcroForm` dictionary, not only signed ones. - -**Negative.** "Signed" without "verified" is a partial answer, and a host -must be careful how it presents that. Mitigated by the API being unable to -say "valid", so the host cannot accidentally imply it. - -**Risk.** Someone later adds a `Valid` variant that is set by anything less -than real chain verification. Mitigated by rule 1, by the test asserting the -capability's absence, and by this paragraph. - -**Out of scope, deliberately:** CMS/PKCS#7 parsing, X.509 chains, trust -stores, revocation, timestamps, signature creation, `/Perms`, usage-rights -signatures, and appearance generation for signature widgets. diff --git a/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml b/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml index 5245b94..d0610ee 100644 --- a/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml +++ b/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml @@ -79,9 +79,3 @@ name = "parse_ext_gstate" path = "fuzz_targets/parse_ext_gstate.rs" test = false doc = false - -[[bin]] -name = "parse_signature" -path = "fuzz_targets/parse_signature.rs" -test = false -doc = false diff --git a/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs b/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs deleted file mode 100644 index c1dbc2a..0000000 --- a/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Fuzz signature dictionary parsing and byte-range validation. -//! -//! A `/ByteRange` is four attacker-controlled integers used to index into -//! the file. Every combination must produce a typed result rather than a -//! panic, an overflow, or an out-of-bounds read. -#![no_main] - -use libfuzzer_sys::fuzz_target; -use nigig_pdf_cos::{Lexer, ObjRef, PdfObj}; -use nigig_pdf_document::signature::{check_byte_range, measure_contents_span, parse_signature_dict}; - -fuzz_target!(|data: &[u8]| { - // Treat the first bytes as a synthetic file length so the range checks - // are exercised against many file sizes, not just this input's. - let file_len = data.len(); - - let mut lexer = Lexer::new(data, 0); - if let Ok(obj) = lexer.read_object() { - if let Some(dict) = obj.as_dict() { - let r = ObjRef { num: 1, gen: 0 }; - // Several contents spans, including ones that cannot match. - for span in [0usize, 1, 130, usize::MAX / 2] { - let _ = parse_signature_dict(r, "fuzz", dict, span, file_len); - } - let byte_range: Vec = dict - .get_array("ByteRange") - .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) - .unwrap_or_default(); - let _ = measure_contents_span(data, &byte_range); - } - } - - // Also drive the range checker directly with raw integers, which - // reaches extremes a parsed dictionary rarely produces. - if data.len() >= 32 { - let n = |i: usize| -> i64 { - let mut b = [0u8; 8]; - b.copy_from_slice(&data[i..i + 8]); - i64::from_le_bytes(b) - }; - let range = [n(0), n(8), n(16), n(24)]; - let _ = check_byte_range(&range, data.len(), file_len); - let _ = check_byte_range(&range, 0, usize::MAX); - } - - let _ = PdfObj::Null; -}); diff --git a/crates/apps/pdf/pdf-document/src/document.rs b/crates/apps/pdf/pdf-document/src/document.rs index 4c96230..466b3c8 100644 --- a/crates/apps/pdf/pdf-document/src/document.rs +++ b/crates/apps/pdf/pdf-document/src/document.rs @@ -356,19 +356,8 @@ impl<'a> PdfDocument<'a> { let Some(root_dict) = root.as_dict() else { return Ok(None); }; - // Only the /AcroForm entry itself is dereferenced. `self.resolve` - // recurses, which would replace every `/Fields [5 0 R]` entry with - // an inline dictionary; `walk` then sees `node.as_ref() == None`, - // decides the field has no identity to key an edit on, and drops - // it. Every field defined by indirect reference - which is to say - // nearly every field in every real document - disappeared, and the - // form came back empty rather than wrong, so nothing announced it. - // - // Same defect as the one that once destroyed annotation object - // references via `page_annotations`. let acro_obj = match root_dict.get("AcroForm") { - Some(PdfObj::Ref(r)) => self.resolve_ref(*r)?, - Some(obj) => obj.clone(), + Some(obj) => self.resolve(obj)?, None => return Ok(None), }; let Some(acro_dict) = acro_obj.as_dict().cloned() else { @@ -396,52 +385,6 @@ impl<'a> PdfDocument<'a> { Ok(Some(AcroForm::parse(&acro_dict, &mut resolve, &page_of))) } - /// Every signature in the document, with byte-range integrity checked. - /// - /// This reports what the file says and whether the signed byte range is - /// structurally sound. It performs **no cryptography** and cannot - /// report a signature as valid; see - /// `REVIEWS/adr/0010-pdf-signatures.md`. - pub fn signatures(&mut self) -> PdfResult { - use crate::signature::{build_report, SignatureFieldInput}; - - let Some(form) = self.acroform()? else { - return Ok(Default::default()); - }; - // Resolve each signature field's /V before borrowing `self.data`. - let mut resolved: Vec<(ObjRef, String, Option)> = Vec::new(); - for field in form.fields() { - if field.field_type != crate::form::FieldType::Signature { - continue; - } - let raw = field.raw_dict_snapshot(); - let entry = raw.get("V").cloned(); - resolved.push((field.obj_ref, field.full_name.clone(), entry)); - } - - // Dereference /V now that the immutable borrow of `form` is done. - let resolved: Vec<(ObjRef, String, Option)> = resolved - .into_iter() - .map(|(r, name, entry)| { - let value = match entry { - Some(PdfObj::Ref(rf)) => self.resolve_ref(rf).ok(), - other => other, - }; - (r, name, value) - }) - .collect(); - - let inputs: Vec> = resolved - .iter() - .map(|(r, name, value)| SignatureFieldInput { - field_ref: *r, - field_name: name, - value: value.as_ref(), - }) - .collect(); - Ok(build_report(&inputs, self.data)) - } - /// Every annotation on `page_index`, with the page recorded on each. /// /// An annotation that fails to parse is skipped rather than failing the diff --git a/crates/apps/pdf/pdf-document/src/lib.rs b/crates/apps/pdf/pdf-document/src/lib.rs index 3c110f1..295f92f 100644 --- a/crates/apps/pdf/pdf-document/src/lib.rs +++ b/crates/apps/pdf/pdf-document/src/lib.rs @@ -6,7 +6,6 @@ pub mod document; pub mod form; pub mod page; pub mod save; -pub mod signature; pub use annotation_edit::{ AnnotationColor, AnnotationEdit, AnnotationEditor, AnnotationError, EditableAnnotation, @@ -25,9 +24,6 @@ pub use form::{ }; pub use page::{CMapData, ExtGStateResource, FontEncoding, FontResource, PdfPage, XObjectResource}; pub use save::{save_annotation_edits, save_form_edits, SaveReport}; -pub use signature::{ - DocMdpPermission, Signature, SignatureError, SignatureReport, SubFilter, VerificationStatus, -}; #[cfg(test)] mod integration_tests { diff --git a/crates/apps/pdf/pdf-document/src/signature.rs b/crates/apps/pdf/pdf-document/src/signature.rs deleted file mode 100644 index 994bc1e..0000000 --- a/crates/apps/pdf/pdf-document/src/signature.rs +++ /dev/null @@ -1,861 +0,0 @@ -//! PDF digital signatures: read, report, and check byte-range integrity. -//! -//! PDF 32000-1 §12.8. See `REVIEWS/adr/0010-pdf-signatures.md`. -//! -//! **This module cannot report a signature as cryptographically valid, by -//! construction.** [`VerificationStatus`] has no `Valid` variant. Parsing -//! CMS/PKCS#7, walking an X.509 chain and consulting a trust store are a -//! separate piece of work with a separate policy decision behind them, and -//! until that exists the honest answer is "not verified, here is why". -//! -//! The failure mode for a signature feature is not that it does not work — -//! it is a green tick beside a document nobody checked. Making the absence -//! of `Valid` a type-level fact means that tick cannot be rendered by -//! accident. -//! -//! What *is* implemented needs no cryptography and catches the most common -//! real-world tampering: **byte-range integrity**. A `/ByteRange` says which -//! bytes the signature covers. If it does not reach the end of the file, -//! everything after it is unsigned — which is exactly how an -//! incremental-update attack hides content behind a signature that still -//! verifies. - -use nigig_pdf_cos::{ObjRef, PdfDict, PdfObj}; - -/// Why a signature is not being reported as valid. -/// -/// Deliberately has no `Valid` variant; see the module documentation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum VerificationStatus { - /// The signature was read and its byte range is structurally sound, but - /// no cryptography has been performed. - NotVerified { - /// Why, in terms a host can show a user. - reason: &'static str, - }, - /// The byte range itself is wrong, so the signature cannot be trusted - /// regardless of any cryptography. - ByteRangeInvalid(SignatureError), -} - -/// A structural fault in a signature. None of these require cryptography to -/// detect, and each one means the signature cannot be relied on. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum SignatureError { - /// `/ByteRange` absent, not four numbers, or not integers. - MalformedByteRange(String), - /// An offset or length is negative, or the pair does not ascend. - ByteRangeNotAscending, - /// A range extends past the end of the file. - ByteRangeOutOfBounds { end: usize, file_len: usize }, - /// The gap between the two covered ranges is not the `/Contents` - /// string, so the signature does not cover what it claims to. - GapIsNotContents { gap: usize, contents: usize }, - /// `/Contents` absent or not a string. - MissingContents, - /// A `/SubFilter` this code does not recognise. - UnknownSubFilter(String), -} - -impl std::fmt::Display for SignatureError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SignatureError::MalformedByteRange(m) => write!(f, "malformed /ByteRange: {m}"), - SignatureError::ByteRangeNotAscending => { - write!(f, "/ByteRange offsets do not ascend") - } - SignatureError::ByteRangeOutOfBounds { end, file_len } => write!( - f, - "/ByteRange ends at {end} but the file is {file_len} bytes" - ), - SignatureError::GapIsNotContents { gap, contents } => write!( - f, - "the gap between the signed ranges is {gap} bytes but /Contents is {contents}" - ), - SignatureError::MissingContents => write!(f, "/Contents is missing or not a string"), - SignatureError::UnknownSubFilter(s) => write!(f, "unknown /SubFilter /{s}"), - } - } -} - -impl std::error::Error for SignatureError {} - -/// The signature algorithm family named by `/SubFilter`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum SubFilter { - /// `adbe.pkcs7.detached` — the common case; `/Contents` is a detached - /// CMS blob over the byte range. - Pkcs7Detached, - /// `adbe.pkcs7.sha1` — legacy, SHA-1 based. - Pkcs7Sha1, - /// `adbe.x509.rsa_sha1` — legacy, raw PKCS#1 signature. - X509RsaSha1, - /// `ETSI.CAdES.detached` — PAdES. - CadesDetached, - /// `ETSI.RFC3161` — a document timestamp, not an identity signature. - Rfc3161Timestamp, - /// Something else. Named rather than ignored. - Unknown(String), -} - -impl SubFilter { - pub fn from_name(name: &str) -> Self { - match name { - "adbe.pkcs7.detached" => SubFilter::Pkcs7Detached, - "adbe.pkcs7.sha1" => SubFilter::Pkcs7Sha1, - "adbe.x509.rsa_sha1" => SubFilter::X509RsaSha1, - "ETSI.CAdES.detached" => SubFilter::CadesDetached, - "ETSI.RFC3161" => SubFilter::Rfc3161Timestamp, - other => SubFilter::Unknown(other.to_string()), - } - } - - /// Whether this is a timestamp rather than an identity signature. - pub fn is_timestamp(&self) -> bool { - matches!(self, SubFilter::Rfc3161Timestamp) - } -} - -/// `/DocMDP` transform parameters: what the author permitted after signing -/// (PDF 32000-1 table 254). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DocMdpPermission { - /// 1 — no changes at all are permitted. - NoChanges, - /// 2 — filling in forms and signing is permitted. - FormFillAndSign, - /// 3 — as 2, plus annotation creation, deletion and modification. - FormFillSignAndAnnotate, -} - -impl DocMdpPermission { - pub fn from_int(v: i64) -> Option { - match v { - 1 => Some(DocMdpPermission::NoChanges), - 2 => Some(DocMdpPermission::FormFillAndSign), - 3 => Some(DocMdpPermission::FormFillSignAndAnnotate), - _ => None, - } - } - - /// Whether an incremental save that edits form fields is permitted. - pub fn allows_form_fill(&self) -> bool { - !matches!(self, DocMdpPermission::NoChanges) - } - - /// Whether editing annotations is permitted. - pub fn allows_annotations(&self) -> bool { - matches!(self, DocMdpPermission::FormFillSignAndAnnotate) - } -} - -/// One signature, as read from the file. -#[derive(Clone, Debug)] -pub struct Signature { - /// The signature field's object reference. - pub field_ref: ObjRef, - /// The field's fully qualified name. - pub field_name: String, - /// `/Filter` — the security handler, normally `Adobe.PPKLite`. - pub filter: Option, - pub sub_filter: Option, - /// The four `/ByteRange` numbers, as written. - pub byte_range: Vec, - /// The decoded `/Contents` blob (the CMS/PKCS#7 signature itself). - /// Retained so a future verifier does not need to reparse the file. - pub contents: Vec, - /// `/M` — the claimed signing time, as the raw PDF date string. Not - /// parsed into a timestamp type: an unverified signature's self-declared - /// time carries no authority and converting it would dress it up. - pub signing_time: Option, - pub name: Option, - pub reason: Option, - pub location: Option, - pub contact_info: Option, - /// `/DocMDP` permission when this is a certification signature. - pub doc_mdp: Option, - /// Whether the byte range reaches the last byte of the file. - /// - /// `false` means content was appended after signing and is **not - /// covered**. This is the single most important field here. - pub covers_whole_file: bool, - /// Byte offset one past the last signed byte. - pub coverage_end: usize, - pub status: VerificationStatus, -} - -impl Signature { - /// Whether this is a certification (author) signature rather than an - /// ordinary approval signature. - pub fn is_certification(&self) -> bool { - self.doc_mdp.is_some() - } - - /// The byte ranges this signature actually covers. - pub fn covered_ranges(&self) -> Vec<(usize, usize)> { - self.byte_range - .chunks(2) - .filter_map(|c| { - if c.len() == 2 && c[0] >= 0 && c[1] >= 0 { - Some((c[0] as usize, (c[0] + c[1]) as usize)) - } else { - None - } - }) - .collect() - } -} - -/// Every signature in a document, plus the document-level conclusions a -/// host actually needs. -#[derive(Clone, Debug, Default)] -pub struct SignatureReport { - pub signatures: Vec, - /// Signature fields present but with no `/V`, i.e. unsigned placeholders. - pub unsigned_fields: Vec, -} - -impl SignatureReport { - pub fn is_signed(&self) -> bool { - !self.signatures.is_empty() - } - - /// True when at least one signature covers the entire file. - /// - /// A document where this is false but `is_signed()` is true has had - /// bytes appended after every signature — the shape of an - /// incremental-update attack. - pub fn any_covers_whole_file(&self) -> bool { - self.signatures.iter().any(|s| s.covers_whole_file) - } - - /// Signatures whose coverage stops before the end of the file. - pub fn partially_covering(&self) -> Vec<&Signature> { - self.signatures - .iter() - .filter(|s| !s.covers_whole_file) - .collect() - } - - /// The strictest `/DocMDP` permission any certification signature set. - pub fn doc_mdp(&self) -> Option { - self.signatures - .iter() - .filter_map(|s| s.doc_mdp) - .min_by_key(|p| match p { - DocMdpPermission::NoChanges => 0, - DocMdpPermission::FormFillAndSign => 1, - DocMdpPermission::FormFillSignAndAnnotate => 2, - }) - } -} - -/// Validate a `/ByteRange` against the real file, without any cryptography. -/// -/// `contents_len` is the length of the *encoded* `/Contents` string -/// including its `<` and `>` delimiters, because that is what physically -/// occupies the gap between the two signed ranges. -pub fn check_byte_range( - byte_range: &[i64], - contents_len: usize, - file_len: usize, -) -> Result<(bool, usize), SignatureError> { - if byte_range.len() != 4 { - return Err(SignatureError::MalformedByteRange(format!( - "expected 4 numbers, found {}", - byte_range.len() - ))); - } - if byte_range.iter().any(|v| *v < 0) { - return Err(SignatureError::ByteRangeNotAscending); - } - let (a, b, c, d) = ( - byte_range[0] as usize, - byte_range[1] as usize, - byte_range[2] as usize, - byte_range[3] as usize, - ); - - // The second range must start after the first ends, or the "gap" is - // meaningless and the ranges may overlap. - let first_end = a - .checked_add(b) - .ok_or_else(|| SignatureError::MalformedByteRange("first range overflows".to_string()))?; - let second_end = c - .checked_add(d) - .ok_or_else(|| SignatureError::MalformedByteRange("second range overflows".to_string()))?; - if c < first_end { - return Err(SignatureError::ByteRangeNotAscending); - } - if second_end > file_len { - return Err(SignatureError::ByteRangeOutOfBounds { - end: second_end, - file_len, - }); - } - - // The gap between the ranges is exactly where /Contents sits. If it is - // any other size, the signature is not covering what it claims to and - // some bytes are silently excluded. - let gap = c - first_end; - if gap != contents_len { - return Err(SignatureError::GapIsNotContents { - gap, - contents: contents_len, - }); - } - - Ok((second_end == file_len, second_end)) -} - -fn text_of(dict: &PdfDict, key: &str) -> Option { - dict.get_str(key) - .map(decode_pdf_text) - .or_else(|| dict.get_name(key).map(str::to_string)) -} - -/// Decode a PDF text string, honouring the UTF-16BE byte-order mark. -fn decode_pdf_text(bytes: &[u8]) -> String { - if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { - let units: Vec = bytes[2..] - .chunks(2) - .filter(|c| c.len() == 2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) - .collect(); - String::from_utf16_lossy(&units) - } else { - String::from_utf8_lossy(bytes).into_owned() - } -} - -/// Build a [`Signature`] from a `/V` signature dictionary. -/// -/// `contents_encoded_len` is how many bytes the `/Contents` string occupies -/// in the file, delimiters included; the caller measures it because only it -/// has seen the raw bytes. -pub fn parse_signature_dict( - field_ref: ObjRef, - field_name: &str, - sig: &PdfDict, - contents_encoded_len: usize, - file_len: usize, -) -> Signature { - let byte_range: Vec = sig - .get_array("ByteRange") - .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) - .unwrap_or_default(); - - // The COS lexer already decodes `<...>` into bytes, so /Contents - // arrives here as the signature blob itself. Re-decoding it as hex - // silently destroyed it: a blob of 128 zero bytes contains no hex - // digits, so it decoded to nothing at all and the field came back - // empty rather than wrong. - let contents = sig - .get_str("Contents") - .map(<[u8]>::to_vec) - .unwrap_or_default(); - - let sub_filter = sig.get_name("SubFilter").map(SubFilter::from_name); - - // /DocMDP lives in the first entry of /Reference. - let doc_mdp = sig - .get_array("Reference") - .and_then(|refs| { - refs.iter().find_map(|r| { - let d = r.as_dict()?; - if d.get_name("TransformMethod") != Some("DocMDP") { - return None; - } - d.get_dict("TransformParams") - .and_then(|tp| tp.get_int("P")) - .and_then(DocMdpPermission::from_int) - }) - }) - // Some producers put /P directly on the signature dictionary. - .or_else(|| sig.get_int("P").and_then(DocMdpPermission::from_int)); - - let (covers_whole_file, coverage_end, status) = - match check_byte_range(&byte_range, contents_encoded_len, file_len) { - Ok((covers, end)) => { - let reason = if covers { - "signature read and byte range sound; cryptographic \ - verification is not implemented" - } else { - "the signature does not cover the end of the file, so \ - appended content is unsigned" - }; - (covers, end, VerificationStatus::NotVerified { reason }) - } - Err(e) => (false, 0, VerificationStatus::ByteRangeInvalid(e)), - }; - - Signature { - field_ref, - field_name: field_name.to_string(), - filter: sig.get_name("Filter").map(str::to_string), - sub_filter, - byte_range, - contents, - signing_time: text_of(sig, "M"), - name: text_of(sig, "Name"), - reason: text_of(sig, "Reason"), - location: text_of(sig, "Location"), - contact_info: text_of(sig, "ContactInfo"), - doc_mdp, - covers_whole_file, - coverage_end, - status, - } -} - -/// Measure how many bytes the `/Contents` hex string occupies in `data`, -/// including its `<` and `>`. -/// -/// The parsed object has lost that framing, but the byte-range gap is -/// measured against the file, so it has to be recovered from the raw bytes. -/// Searching is bounded to the region between the two signed ranges. -pub fn measure_contents_span(data: &[u8], byte_range: &[i64]) -> Option { - if byte_range.len() != 4 || byte_range.iter().any(|v| *v < 0) { - return None; - } - let first_end = (byte_range[0] as usize).checked_add(byte_range[1] as usize)?; - let second_start = byte_range[2] as usize; - if second_start < first_end || second_start > data.len() { - return None; - } - Some(second_start - first_end) -} - -/// Everything needed to describe a signature field, gathered by the caller. -pub struct SignatureFieldInput<'a> { - pub field_ref: ObjRef, - pub field_name: &'a str, - /// The resolved `/V` dictionary, if the field is signed. - pub value: Option<&'a PdfObj>, -} - -/// Build a report from signature fields the caller has already located. -pub fn build_report(fields: &[SignatureFieldInput<'_>], data: &[u8]) -> SignatureReport { - let mut report = SignatureReport::default(); - for field in fields { - let Some(value) = field.value else { - report.unsigned_fields.push(field.field_name.to_string()); - continue; - }; - let Some(sig_dict) = value.as_dict() else { - report.unsigned_fields.push(field.field_name.to_string()); - continue; - }; - let byte_range: Vec = sig_dict - .get_array("ByteRange") - .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) - .unwrap_or_default(); - // Fall back to the encoded length of the parsed string when the - // range is unusable; check_byte_range will reject it anyway, and - // this keeps the error about the range rather than about framing. - let span = measure_contents_span(data, &byte_range) - .unwrap_or_else(|| sig_dict.get_str("Contents").map_or(0, |c| c.len() * 2 + 2)); - report.signatures.push(parse_signature_dict( - field.field_ref, - field.field_name, - sig_dict, - span, - data.len(), - )); - } - report -} - -#[cfg(test)] -mod tests { - use super::*; - - fn dict(entries: &[(&str, PdfObj)]) -> PdfDict { - let mut d = PdfDict::new(); - for (k, v) in entries { - d.set(k, v.clone()); - } - d - } - - fn ints(v: &[i64]) -> PdfObj { - PdfObj::Array(v.iter().map(|i| PdfObj::Int(*i)).collect()) - } - - const REF: ObjRef = ObjRef { num: 5, gen: 0 }; - - // --- byte range --- - - #[test] - fn a_whole_file_range_is_recognised() { - // [0 100] [400 100] over a 500-byte file, /Contents spanning 300. - let (covers, end) = check_byte_range(&[0, 100, 400, 100], 300, 500).expect("valid"); - assert!(covers, "the range reaches the end of the file"); - assert_eq!(end, 500); - } - - #[test] - fn a_range_that_stops_short_is_flagged() { - // The file is 900 bytes but the signature stops at 500: the last - // 400 bytes are appended and unsigned. - let (covers, end) = check_byte_range(&[0, 100, 400, 100], 300, 900).expect("valid"); - assert!( - !covers, - "a signature that does not reach EOF must not claim full coverage" - ); - assert_eq!(end, 500); - } - - #[test] - fn a_range_past_the_end_of_the_file_is_refused() { - assert_eq!( - check_byte_range(&[0, 100, 400, 100], 300, 400), - Err(SignatureError::ByteRangeOutOfBounds { - end: 500, - file_len: 400 - }) - ); - } - - #[test] - fn a_negative_range_is_refused() { - assert_eq!( - check_byte_range(&[0, -1, 400, 100], 300, 500), - Err(SignatureError::ByteRangeNotAscending) - ); - } - - #[test] - fn an_overlapping_range_is_refused() { - // Second range starts before the first ends. - assert_eq!( - check_byte_range(&[0, 400, 100, 100], 0, 500), - Err(SignatureError::ByteRangeNotAscending) - ); - } - - #[test] - fn a_gap_that_is_not_the_contents_is_refused() { - // The gap is 300 bytes but /Contents is only 100: 200 bytes are - // excluded from coverage without explanation. - assert_eq!( - check_byte_range(&[0, 100, 400, 100], 100, 500), - Err(SignatureError::GapIsNotContents { - gap: 300, - contents: 100 - }) - ); - } - - #[test] - fn a_wrong_length_range_is_refused() { - assert!(matches!( - check_byte_range(&[0, 100, 400], 300, 500), - Err(SignatureError::MalformedByteRange(_)) - )); - assert!(matches!( - check_byte_range(&[], 0, 500), - Err(SignatureError::MalformedByteRange(_)) - )); - } - - #[test] - fn an_overflowing_range_cannot_panic() { - assert!(check_byte_range(&[0, i64::MAX, i64::MAX, i64::MAX], 0, 500).is_err()); - } - - // --- the central guarantee --- - - /// The API must be structurally incapable of reporting a signature as - /// cryptographically valid. If someone later adds a `Valid` variant - /// without the cryptography behind it, this test is the tripwire. - #[test] - fn nothing_can_report_a_signature_as_valid() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ("SubFilter", PdfObj::Name("adbe.pkcs7.detached".into())), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); - match s.status { - VerificationStatus::NotVerified { reason } => { - assert!( - reason.contains("not implemented"), - "the reason must say verification did not happen: {reason}" - ); - } - other => panic!("a signature must never be reported as verified: {other:?}"), - } - } - - // --- dictionary parsing --- - - #[test] - fn signer_metadata_is_exposed() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ("Filter", PdfObj::Name("Adobe.PPKLite".into())), - ("SubFilter", PdfObj::Name("adbe.pkcs7.detached".into())), - ("M", PdfObj::Str(b"D:20260731120000Z".to_vec())), - ("Name", PdfObj::Str(b"Test Signer".to_vec())), - ("Reason", PdfObj::Str(b"Approval".to_vec())), - ("Location", PdfObj::Str(b"Nairobi".to_vec())), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); - assert_eq!(s.name.as_deref(), Some("Test Signer")); - assert_eq!(s.reason.as_deref(), Some("Approval")); - assert_eq!(s.location.as_deref(), Some("Nairobi")); - assert_eq!(s.signing_time.as_deref(), Some("D:20260731120000Z")); - assert_eq!(s.filter.as_deref(), Some("Adobe.PPKLite")); - assert_eq!(s.sub_filter, Some(SubFilter::Pkcs7Detached)); - assert!(s.covers_whole_file); - } - - #[test] - fn utf16_signer_names_decode() { - let mut bytes = vec![0xFE, 0xFF]; - for c in "Ã…dne".encode_utf16() { - bytes.extend_from_slice(&c.to_be_bytes()); - } - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ("Name", PdfObj::Str(bytes)), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); - assert_eq!(s.name.as_deref(), Some("Ã…dne")); - } - - #[test] - fn every_known_subfilter_is_classified() { - for (name, expected) in [ - ("adbe.pkcs7.detached", SubFilter::Pkcs7Detached), - ("adbe.pkcs7.sha1", SubFilter::Pkcs7Sha1), - ("adbe.x509.rsa_sha1", SubFilter::X509RsaSha1), - ("ETSI.CAdES.detached", SubFilter::CadesDetached), - ("ETSI.RFC3161", SubFilter::Rfc3161Timestamp), - ] { - assert_eq!(SubFilter::from_name(name), expected); - } - } - - #[test] - fn an_unknown_subfilter_is_named_not_dropped() { - assert_eq!( - SubFilter::from_name("acme.custom.sig"), - SubFilter::Unknown("acme.custom.sig".to_string()) - ); - } - - #[test] - fn a_timestamp_is_distinguished_from_an_identity_signature() { - assert!(SubFilter::from_name("ETSI.RFC3161").is_timestamp()); - assert!(!SubFilter::from_name("adbe.pkcs7.detached").is_timestamp()); - } - - /// The COS lexer decodes `<...>` before this module sees it, so - /// /Contents must be taken verbatim. An earlier version re-decoded it - /// as hex, which turned a blob of zero bytes - the overwhelmingly - /// common placeholder shape - into an empty vector. - #[test] - fn contents_is_taken_verbatim_not_re_decoded() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 10, 20, 10])), - ("Contents", PdfObj::Str(vec![0xDE, 0xAD, 0xBE, 0xEF])), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 10, 30); - assert_eq!(s.contents, vec![0xDE, 0xAD, 0xBE, 0xEF]); - } - - #[test] - fn an_all_zero_contents_blob_is_retained() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 10, 20, 10])), - ("Contents", PdfObj::Str(vec![0u8; 128])), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 10, 30); - assert_eq!( - s.contents.len(), - 128, - "a zero-filled blob must survive; re-decoding it as hex empties it" - ); - } - - // --- DocMDP --- - - #[test] - fn docmdp_is_read_from_the_reference_array() { - let tp = dict(&[("P", PdfObj::Int(1))]); - let reference = dict(&[ - ("TransformMethod", PdfObj::Name("DocMDP".into())), - ("TransformParams", PdfObj::Dict(tp)), - ]); - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ("Reference", PdfObj::Array(vec![PdfObj::Dict(reference)])), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); - assert_eq!(s.doc_mdp, Some(DocMdpPermission::NoChanges)); - assert!(s.is_certification()); - } - - #[test] - fn docmdp_permissions_gate_the_right_edits() { - assert!(!DocMdpPermission::NoChanges.allows_form_fill()); - assert!(!DocMdpPermission::NoChanges.allows_annotations()); - assert!(DocMdpPermission::FormFillAndSign.allows_form_fill()); - assert!(!DocMdpPermission::FormFillAndSign.allows_annotations()); - assert!(DocMdpPermission::FormFillSignAndAnnotate.allows_form_fill()); - assert!(DocMdpPermission::FormFillSignAndAnnotate.allows_annotations()); - } - - #[test] - fn an_invalid_docmdp_value_is_none_not_a_guess() { - assert_eq!(DocMdpPermission::from_int(0), None); - assert_eq!(DocMdpPermission::from_int(4), None); - assert_eq!(DocMdpPermission::from_int(-1), None); - } - - #[test] - fn an_ordinary_signature_is_not_a_certification() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ]); - assert!(!parse_signature_dict(REF, "Sig1", &sig, 300, 500).is_certification()); - } - - // --- report --- - - #[test] - fn a_report_distinguishes_signed_from_unsigned_fields() { - let sig = PdfObj::Dict(dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ])); - let data = vec![0u8; 500]; - let report = build_report( - &[ - SignatureFieldInput { - field_ref: REF, - field_name: "Signed", - value: Some(&sig), - }, - SignatureFieldInput { - field_ref: ObjRef { num: 9, gen: 0 }, - field_name: "Empty", - value: None, - }, - ], - &data, - ); - assert!(report.is_signed()); - assert_eq!(report.signatures.len(), 1); - assert_eq!(report.unsigned_fields, vec!["Empty".to_string()]); - } - - #[test] - fn a_report_surfaces_partial_coverage() { - let sig = PdfObj::Dict(dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ])); - // 900-byte file, signature covers to 500. - let data = vec![0u8; 900]; - let report = build_report( - &[SignatureFieldInput { - field_ref: REF, - field_name: "Sig1", - value: Some(&sig), - }], - &data, - ); - assert!(report.is_signed()); - assert!( - !report.any_covers_whole_file(), - "appended bytes must not be reported as covered" - ); - assert_eq!(report.partially_covering().len(), 1); - } - - #[test] - fn the_strictest_docmdp_wins() { - let mk = |p: i64| { - let tp = dict(&[("P", PdfObj::Int(p))]); - let r = dict(&[ - ("TransformMethod", PdfObj::Name("DocMDP".into())), - ("TransformParams", PdfObj::Dict(tp)), - ]); - PdfObj::Dict(dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ("Reference", PdfObj::Array(vec![PdfObj::Dict(r)])), - ])) - }; - let lax = mk(3); - let strict = mk(1); - let data = vec![0u8; 500]; - let report = build_report( - &[ - SignatureFieldInput { - field_ref: REF, - field_name: "A", - value: Some(&lax), - }, - SignatureFieldInput { - field_ref: ObjRef { num: 9, gen: 0 }, - field_name: "B", - value: Some(&strict), - }, - ], - &data, - ); - assert_eq!(report.doc_mdp(), Some(DocMdpPermission::NoChanges)); - } - - #[test] - fn an_empty_report_is_not_signed() { - let report = SignatureReport::default(); - assert!(!report.is_signed()); - assert!(!report.any_covers_whole_file()); - assert_eq!(report.doc_mdp(), None); - } - - #[test] - fn covered_ranges_are_reported_as_offsets() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 100, 400, 100])), - ("Contents", PdfObj::Str(vec![0u8; 150])), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); - assert_eq!(s.covered_ranges(), vec![(0, 100), (400, 500)]); - } - - #[test] - fn a_malformed_range_yields_a_typed_status_not_a_panic() { - let sig = dict(&[ - ("ByteRange", ints(&[0, 100])), - ("Contents", PdfObj::Str(vec![0u8; 10])), - ]); - let s = parse_signature_dict(REF, "Sig1", &sig, 10, 500); - assert!(matches!( - s.status, - VerificationStatus::ByteRangeInvalid(SignatureError::MalformedByteRange(_)) - )); - assert!(!s.covers_whole_file); - } - - #[test] - fn measure_contents_span_is_bounds_checked() { - let data = vec![0u8; 100]; - assert_eq!(measure_contents_span(&data, &[0, 10, 40, 10]), Some(30)); - // Second range starts before the first ends. - assert_eq!(measure_contents_span(&data, &[0, 50, 10, 10]), None); - // Past the end of the buffer. - assert_eq!(measure_contents_span(&data, &[0, 10, 900, 10]), None); - assert_eq!(measure_contents_span(&data, &[0, 10, 40]), None); - assert_eq!(measure_contents_span(&data, &[-1, 10, 40, 10]), None); - } -} diff --git a/crates/apps/pdf/pdf-document/tests/signatures.rs b/crates/apps/pdf/pdf-document/tests/signatures.rs deleted file mode 100644 index a690636..0000000 --- a/crates/apps/pdf/pdf-document/tests/signatures.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Signature acceptance tests. -//! -//! The merge criteria from `REVIEWS/adr/0010-pdf-signatures.md`. -//! -//! Two things are being guarded here, and the second is the more important: -//! -//! 1. Signatures are read and their byte-range coverage is checked. -//! 2. **Nothing ever claims a signature is cryptographically valid.** The -//! dangerous failure for this feature is not "it doesn't work" — it is a -//! green tick beside a document nobody verified. - -use std::path::PathBuf; - -use nigig_pdf_document::form::FieldType; -use nigig_pdf_document::signature::{ - DocMdpPermission, SignatureError, SubFilter, VerificationStatus, -}; -use nigig_pdf_document::PdfDocument; - -fn corpus(relative: &str) -> Vec { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../tests/corpus") - .join(relative); - std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) -} - -/// The bug this fixture exists for: `acroform()` deep-resolved the -/// `/AcroForm` entry, which replaced every `/Fields [N 0 R]` reference with -/// an inline dictionary. `AcroForm::walk` then saw no object reference, -/// decided the field had no identity to key an edit on, and dropped it — so -/// the form came back **empty rather than wrong**, and nothing announced it. -/// -/// `signatures/whole_file.pdf` declares `/AcroForm` as a direct dictionary, -/// which is what triggers it. Every pre-existing fixture uses an indirect -/// `/AcroForm`, where only one level is dereferenced and the refs survive, -/// so the old test suite could not have caught this. -#[test] -fn a_direct_acroform_dictionary_still_yields_its_fields() { - let bytes = corpus("signatures/whole_file.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let form = doc.acroform().expect("acroform").expect("present"); - assert_eq!( - form.fields().len(), - 1, - "a direct /AcroForm dictionary lost its fields to deep resolution" - ); - let field = &form.fields()[0]; - assert_eq!(field.field_type, FieldType::Signature); - assert_eq!(field.full_name, "Signature1"); - assert_eq!( - field.obj_ref.num, 5, - "the field must keep the object reference an edit would key on" - ); -} - -#[test] -fn a_whole_file_signature_is_reported_as_covering_everything() { - let bytes = corpus("signatures/whole_file.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - - assert!(report.is_signed()); - assert_eq!(report.signatures.len(), 1); - assert!(report.any_covers_whole_file()); - assert!(report.partially_covering().is_empty()); - - let sig = &report.signatures[0]; - assert!(sig.covers_whole_file); - assert_eq!( - sig.coverage_end, - bytes.len(), - "coverage must reach the last byte" - ); - assert_eq!(sig.sub_filter, Some(SubFilter::Pkcs7Detached)); - assert_eq!(sig.filter.as_deref(), Some("Adobe.PPKLite")); -} - -/// The attack case. Bytes appended after signing are not covered, and a -/// reader that reports the document as merely "signed" is misleading its -/// user. -#[test] -fn appended_bytes_are_reported_as_uncovered() { - let whole = corpus("signatures/whole_file.pdf"); - let bytes = corpus("signatures/partial_coverage.pdf"); - assert!( - bytes.len() > whole.len(), - "the fixture must actually have appended bytes" - ); - - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - - assert!(report.is_signed(), "it is still a signed document"); - assert!( - !report.any_covers_whole_file(), - "no signature covers the appended bytes, and that must be visible" - ); - assert_eq!(report.partially_covering().len(), 1); - - let sig = &report.signatures[0]; - assert!(!sig.covers_whole_file); - assert!( - sig.coverage_end < bytes.len(), - "coverage ends at {} but the file is {} bytes", - sig.coverage_end, - bytes.len() - ); - match &sig.status { - VerificationStatus::NotVerified { reason } => assert!( - reason.contains("does not cover"), - "the reason must name the coverage gap: {reason}" - ), - other => panic!("expected a coverage warning, got {other:?}"), - } -} - -#[test] -fn signer_metadata_survives_a_real_parse() { - let bytes = corpus("signatures/whole_file.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - let sig = &report.signatures[0]; - assert_eq!(sig.name.as_deref(), Some("Test Signer")); - assert_eq!(sig.reason.as_deref(), Some("Approval")); - assert_eq!(sig.location.as_deref(), Some("Nairobi")); - assert_eq!(sig.signing_time.as_deref(), Some("D:20260731120000Z")); - assert!( - !sig.contents.is_empty(), - "the /Contents blob must be retained for a future verifier" - ); -} - -#[test] -fn a_certification_signature_reports_its_docmdp_permission() { - let bytes = corpus("signatures/certification.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - - assert_eq!(report.doc_mdp(), Some(DocMdpPermission::NoChanges)); - let sig = &report.signatures[0]; - assert!(sig.is_certification()); - assert!( - !sig.doc_mdp.expect("docmdp").allows_form_fill(), - "P=1 forbids even form filling" - ); -} - -#[test] -fn an_unknown_subfilter_is_named_rather_than_dropped() { - let bytes = corpus("signatures/unknown_subfilter.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - assert_eq!( - report.signatures[0].sub_filter, - Some(SubFilter::Unknown("acme.custom.signature".to_string())), - "an unrecognised handler must be reported by name" - ); -} - -#[test] -fn a_malformed_byte_range_is_a_typed_error_not_a_pass() { - let bytes = corpus("signatures/malformed_byte_range.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - - let sig = &report.signatures[0]; - assert!(!sig.covers_whole_file, "a bad range cannot cover anything"); - assert!( - matches!( - sig.status, - VerificationStatus::ByteRangeInvalid(SignatureError::ByteRangeOutOfBounds { .. }) - ), - "expected an out-of-bounds range, got {:?}", - sig.status - ); - assert!( - !report.any_covers_whole_file(), - "a document whose only signature has a broken range is not covered" - ); -} - -#[test] -fn an_unsigned_signature_field_is_not_a_signature() { - let bytes = corpus("signatures/unsigned_field.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - - assert!( - !report.is_signed(), - "an empty signature placeholder must not count as a signature" - ); - assert_eq!(report.unsigned_fields, vec!["Signature1".to_string()]); -} - -/// The central guarantee of ADR 0010, asserted against real files. -/// -/// No fixture, however well formed, may come back as verified. If someone -/// later adds a `Valid` variant without the cryptography behind it, this -/// fails. -#[test] -fn no_fixture_is_ever_reported_as_cryptographically_valid() { - for name in [ - "whole_file.pdf", - "partial_coverage.pdf", - "certification.pdf", - "unknown_subfilter.pdf", - "malformed_byte_range.pdf", - ] { - let bytes = corpus(&format!("signatures/{name}")); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - for sig in &report.signatures { - match &sig.status { - VerificationStatus::NotVerified { .. } - | VerificationStatus::ByteRangeInvalid(_) => {} - // Unreachable today by construction; here so that adding a - // `Valid` variant without real verification breaks a test - // rather than shipping a false green tick. - #[allow(unreachable_patterns)] - other => panic!("{name} was reported as verified: {other:?}"), - } - } - } -} - -/// An unsigned document must not acquire a signature report. -#[test] -fn an_unsigned_document_reports_no_signatures() { - let bytes = corpus("forms/all_types.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - let report = doc.signatures().expect("signatures"); - assert!(!report.is_signed()); - assert!(report.signatures.is_empty()); - assert!(report.unsigned_fields.is_empty()); - assert_eq!(report.doc_mdp(), None); -} - -/// ADR 0003 chose an append-only save specifically so a signed byte range -/// survives. That claim is worth testing rather than asserting. -#[test] -fn an_incremental_save_preserves_the_signed_byte_range() { - use nigig_pdf_document::annotation_edit::{AnnotationEditor, PageAnnotations}; - use nigig_pdf_document::save::save_annotation_edits; - - let bytes = corpus("signatures/whole_file.pdf"); - let mut doc = PdfDocument::parse(&bytes).expect("parses"); - - let before = doc.signatures().expect("signatures"); - let covered_before = before.signatures[0].covered_ranges(); - assert!(before.any_covers_whole_file()); - - // Make an edit and save it incrementally. - let annots = doc.page_annotations(0).expect("annotations"); - let trailer = doc.trailer().clone(); - let page_ref = doc.page_object_ref(0).expect("page ref"); - let page_dict = doc - .resolve_ref(page_ref) - .expect("page") - .as_dict() - .cloned() - .expect("dict"); - let mut page = PageAnnotations::new(0, annots); - let target = page - .live() - .next() - .and_then(|a| a.annotation.obj_ref) - .expect("the signature widget"); - AnnotationEditor::new(&mut page) - .move_to(target, 100.0, 500.0) - .expect("move"); - let (out, _) = - save_annotation_edits(&bytes, &trailer, &page, page_ref, &page_dict).expect("save"); - - // The original bytes must still be a prefix, so the signed range is - // byte-for-byte intact. - assert!(out.starts_with(&bytes), "an incremental save must append"); - for (start, end) in &covered_before { - assert_eq!( - &out[*start..*end], - &bytes[*start..*end], - "the signed range {start}..{end} changed across a save" - ); - } - - // And the signature must now report partial coverage, because the save - // appended bytes it does not cover. That is correct and must be visible. - let mut resaved = PdfDocument::parse(&out).expect("reparses"); - let after = resaved.signatures().expect("signatures"); - assert!(after.is_signed(), "the signature survived the save"); - assert!( - !after.any_covers_whole_file(), - "bytes appended by the save are not covered, and that must be reported" - ); -} - -#[test] -fn every_signature_fixture_parses_without_panicking() { - for name in [ - "whole_file.pdf", - "partial_coverage.pdf", - "certification.pdf", - "unknown_subfilter.pdf", - "malformed_byte_range.pdf", - "unsigned_field.pdf", - ] { - let bytes = corpus(&format!("signatures/{name}")); - let mut doc = PdfDocument::parse(&bytes) - .unwrap_or_else(|e| panic!("signatures/{name} should parse: {e}")); - let _ = doc.signatures(); - let _ = doc.acroform(); - let _ = doc.page(0); - } -} diff --git a/crates/apps/pdf/pdf-makepad/Cargo.toml b/crates/apps/pdf/pdf-makepad/Cargo.toml index 5b0ad99..e13163e 100644 --- a/crates/apps/pdf/pdf-makepad/Cargo.toml +++ b/crates/apps/pdf/pdf-makepad/Cargo.toml @@ -9,15 +9,9 @@ description = "Makepad PDF integration: MakepadPdfDevice, GPU textures, PdfView nigig-pdf-cos = { path = "../pdf-cos" } nigig-pdf-document = { path = "../pdf-document" } nigig-pdf-graphics = { path = "../pdf-graphics" } -# The `test` feature gates makepad-widgets' re-export of makepad-test, -# which tests/ui.rs imports as makepad_widgets::makepad_test. -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec", features = ["test"] } +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec"} [dev-dependencies] -# Both are required, and neither is redundant: `ui.rs` imports the symbols -# through the re-export above, but the `#[makepad_test]` attribute expands -# to an absolute `::makepad_test::` path, which only resolves if the crate -# is also a direct dependency. Dropping either breaks the UI tests. makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec", package = "makepad-test" } # A binary host so makepad_test can drive the widget through real event diff --git a/crates/apps/pdf/tests/corpus/generate.py b/crates/apps/pdf/tests/corpus/generate.py index 14b84c6..95f7ca9 100644 --- a/crates/apps/pdf/tests/corpus/generate.py +++ b/crates/apps/pdf/tests/corpus/generate.py @@ -1155,130 +1155,6 @@ def transparency_missing_gstate(): }) - -# ----------------------------------------------------------- signatures - -def _sign(objects, sig_num, root=1, extra_trailer=b"", trailing=b""): - """Assemble a PDF and back-fill a real /ByteRange into object `sig_num`. - - A signature's /ByteRange must reference actual byte offsets in the - finished file, so the file is built once with a placeholder, the offsets - are measured, and the placeholder is overwritten in place. The - placeholder is padded to a fixed width so the overwrite cannot move any - other byte - which is exactly the constraint a real signer works under. - - `trailing` appends bytes AFTER the signed range, producing the - partial-coverage case that an incremental-update attack relies on. - """ - data = bytearray(build(objects, root=root, extra_trailer=extra_trailer)) - data += trailing - - marker = b"/ByteRange " + BR_PLACEHOLDER - at = data.find(marker) - if at < 0: - raise AssertionError("byte range placeholder not found") - - # Locate the /Contents hex string that follows. - c_at = data.find(b"/Contents <", at) - if c_at < 0: - raise AssertionError("/Contents not found") - c_start = c_at + len(b"/Contents ") - c_end = data.find(b">", c_start) + 1 - - first_len = c_start - second_start = c_end - second_len = len(data) - second_start - len(trailing) - - real = b"[%d %d %d %d]" % (0, first_len, second_start, second_len) - if len(real) > len(BR_PLACEHOLDER): - raise AssertionError("byte range longer than its placeholder") - real += b" " * (len(BR_PLACEHOLDER) - len(real)) - data[at + len(b"/ByteRange "):at + len(marker)] = real - return bytes(data) - - -# Wide enough for any offsets these fixtures produce, fixed so back-filling -# never shifts a byte. -BR_PLACEHOLDER = b"[0 00000000 00000000 00000000]" - - -def _sig_objects(sub_filter=b"/adbe.pkcs7.detached", reference=b"", name=b"Test Signer"): - sig_blob = b"<" + b"00" * 128 + b">" - return { - 1: b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] " - b"/SigFlags 3 >> >>", - 2: page_tree([3]), - 3: (b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " - b"/Contents 4 0 R /Resources << >> /Annots [5 0 R] >>"), - 4: simple_stream(b"", b"BT /F1 12 Tf 72 700 Td (signed document) Tj ET\n"), - 5: (b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) " - b"/Rect [72 600 272 660] /P 3 0 R /V 6 0 R >>"), - 6: (b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter " + sub_filter + - b" /ByteRange " + BR_PLACEHOLDER + - b" /Contents " + sig_blob + - b" /M (D:20260731120000Z) /Name (" + name + b")" - b" /Reason (Approval) /Location (Nairobi)" + reference + b" >>"), - } - - -def signatures_whole_file(): - """A signature whose /ByteRange covers the entire file. - - The /AcroForm here is a DIRECT dictionary on the catalog. That matters: - acroform() used to deep-resolve it, which destroyed the ObjRefs in - /Fields and made every such form come back empty. An indirect /AcroForm - - what every other fixture uses - cannot reproduce that bug. - """ - return _sign(_sig_objects(), sig_num=6) - - -def signatures_partial_coverage(): - """A signature followed by unsigned appended bytes. - - This is the shape of an incremental-update attack: the signature still - covers what it covered, but content was added afterwards that it does - not cover. A reader that reports this as simply "signed" is misleading - its user. - """ - trailing = (b"\n% appended after signing - NOT covered by the signature\n" - b"9 0 obj\n<< /Sneaky true >>\nendobj\n") - return _sign(_sig_objects(), sig_num=6, trailing=trailing) - - -def signatures_certification(): - """A certification signature with /DocMDP P=1 (no changes permitted).""" - reference = (b" /Reference [ << /Type /SigRef /TransformMethod /DocMDP " - b"/TransformParams << /Type /TransformParams /P 1 /V /1.2 >> " - b">> ]") - return _sign(_sig_objects(reference=reference), sig_num=6) - - -def signatures_unknown_subfilter(): - """A /SubFilter outside the specification's table.""" - return _sign(_sig_objects(sub_filter=b"/acme.custom.signature"), sig_num=6) - - -def signatures_malformed_byte_range(): - """A /ByteRange pointing far past the end of the file. - - Must be a typed error, never a panic and never a pass. - """ - objects = _sig_objects() - objects[6] = objects[6].replace( - b"/ByteRange " + BR_PLACEHOLDER, - b"/ByteRange [0 999999 9999999 999999]") - return build(objects) - - -def signatures_unsigned_field(): - """A signature field with no /V: a placeholder, not a signature.""" - objects = _sig_objects() - objects[5] = (b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) " - b"/Rect [72 600 272 660] /P 3 0 R >>") - del objects[6] - return build(objects) - - # --------------------------------------------------------------- encrypted # # Encryption fixtures are produced with the same algorithms the reader @@ -1667,14 +1543,6 @@ CORPUS = { ("unknown_blend_mode.pdf", transparency_unknown_blend_mode), ("missing_gstate.pdf", transparency_missing_gstate), ], - "signatures": [ - ("whole_file.pdf", signatures_whole_file), - ("partial_coverage.pdf", signatures_partial_coverage), - ("certification.pdf", signatures_certification), - ("unknown_subfilter.pdf", signatures_unknown_subfilter), - ("malformed_byte_range.pdf", signatures_malformed_byte_range), - ("unsigned_field.pdf", signatures_unsigned_field), - ], "perf": [ ("large_60_pages.pdf", perf_large), ], diff --git a/crates/apps/pdf/tests/corpus/signatures/certification.pdf b/crates/apps/pdf/tests/corpus/signatures/certification.pdf deleted file mode 100644 index 9009bba..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/certification.pdf +++ /dev/null @@ -1,38 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> -endobj -6 0 obj -<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 416] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) /Reference [ << /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >> ] >> -endobj -xref -0 7 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -0000000499 00000 n -trailer -<< /Size 7 /Root 1 0 R >> -startxref -1098 -%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf b/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf deleted file mode 100644 index d9319cc..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf +++ /dev/null @@ -1,38 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> -endobj -6 0 obj -<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 999999 9999999 999999] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> -endobj -xref -0 7 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -0000000499 00000 n -trailer -<< /Size 7 /Root 1 0 R >> -startxref -974 -%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf b/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf deleted file mode 100644 index 4d4c992..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf +++ /dev/null @@ -1,43 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> -endobj -6 0 obj -<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> -endobj -xref -0 7 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -0000000499 00000 n -trailer -<< /Size 7 /Root 1 0 R >> -startxref -979 -%%EOF - -% appended after signing - NOT covered by the signature -9 0 obj -<< /Sneaky true >> -endobj diff --git a/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf b/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf deleted file mode 100644 index 32d0720..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf +++ /dev/null @@ -1,38 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> -endobj -6 0 obj -<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /acme.custom.signature /ByteRange [0 630 888 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> -endobj -xref -0 7 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -0000000499 00000 n -trailer -<< /Size 7 /Root 1 0 R >> -startxref -981 -%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf b/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf deleted file mode 100644 index 74ba479..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf +++ /dev/null @@ -1,34 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R >> -endobj -xref -0 6 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -trailer -<< /Size 6 /Root 1 0 R >> -startxref -490 -%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf b/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf deleted file mode 100644 index 2428883..0000000 --- a/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf +++ /dev/null @@ -1,38 +0,0 @@ -%PDF-1.7 -%âãÏÓ -1 0 obj -<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> -endobj -4 0 obj -<< /Length 47 >> -stream -BT /F1 12 Tf 72 700 Td (signed document) Tj ET - -endstream -endobj -5 0 obj -<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> -endobj -6 0 obj -<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> -endobj -xref -0 7 -0000000000 65535 f -0000000015 00000 n -0000000108 00000 n -0000000165 00000 n -0000000285 00000 n -0000000382 00000 n -0000000499 00000 n -trailer -<< /Size 7 /Root 1 0 R >> -startxref -979 -%%EOF