Compare commits

..

2 commits

Author SHA1 Message Date
e45a717ce7 fix(pdf-ui): enable the makepad-widgets test feature so ui.rs compiles
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
456cfa5 rewrote tests/ui.rs to import makepad_test through the
makepad-widgets re-export, but did not add the `test` feature that gates
that re-export, so the pdf-ui target stopped compiling:

  error[E0432]: unresolved import `makepad_widgets::makepad_test`
  note: the item is gated behind the `test` feature

Enabling the feature alone is not sufficient. The `#[makepad_test]`
attribute expands to an absolute `::makepad_test::` path, which resolves
only if the crate is also a direct dependency:

  error[E0433]: cannot find `makepad_test` in the crate root

So both are needed, and the manifest now says why - the direct
dev-dependency looks redundant next to the re-export and would otherwise
be an obvious thing for someone to delete. crates/apps/map keeps the
same pair for the same reason.

TEST_TARGET=pdf-ui 580 passing (was: failed to compile).
2026-07-31 20:02:29 +00:00
e4a0c0a79e feat(pdf): read signatures, and fix a third ObjRef-destroying resolve
Phase 8 #6 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md
("Signatures"). Design and merge criteria in
REVIEWS/adr/0010-pdf-signatures.md.

Two defects, and the first is not about signatures at all.

1. acroform() dereferenced /AcroForm 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, decided the field had no
   identity to key an edit on, and dropped it. The form came back EMPTY
   rather than wrong, which is indistinguishable from a document with no
   fields, so nothing announced the loss.

   This is the third appearance of one mistake: page_annotations once
   destroyed every annotation's obj_ref the same way, and
   extract_xobjects resolved a reference then asked the resolved object
   for as_ref(), leaving every page's XObject map empty.

   It was invisible because both existing fixtures declare /AcroForm as
   an INDIRECT reference, where only one level is dereferenced and the
   refs survive. A direct /AcroForm dictionary - equally legal - hits it.
   The new fixture uses one deliberately; reverting the one-line fix
   fails 9 of the 12 new acceptance tests.

2. Nothing read signatures. FieldType::Signature was classified and then
   ignored; /ByteRange, /Contents, /SubFilter and /DocMDP appear nowhere
   in the codebase. A signed contract was presented exactly like an
   unsigned one.

New pdf-document/src/signature.rs reads the signature dictionary and
checks BYTE-RANGE INTEGRITY, which needs no cryptography and catches the
common real-world tampering: whether the signed range reaches the end of
the file. A signature that stops short leaves appended bytes uncovered,
which is exactly how an incremental-update attack hides content behind a
signature that still verifies.

Cryptographic verification is NOT implemented and cannot be faked:
VerificationStatus has no Valid variant. That is enforced by the type,
not by convention, because the failure mode for a signature feature is
not "it doesn't work" - it is a green tick beside a document nobody
checked. A test asserts the capability's absence so adding Valid without
the cryptography breaks the build rather than shipping a false tick.
Signing is out of scope entirely: no private keys in this crate.

Also verified ADR 0003's claim that an append-only save preserves a
signed byte range, byte for byte, rather than leaving it asserted.

Implementation bug worth recording: /Contents was initially hex-decoded,
but the COS lexer already decodes <...>. Running it twice on the common
128-zero-byte placeholder - which contains no hex digits - produced an
EMPTY vector, silently discarding the signature blob while every other
field looked right. Caught by asserting the blob is non-empty rather
than asserting the parse returned Ok.

6 corpus fixtures, 12 acceptance tests, 28 unit tests, and a
parse_signature fuzz target because /ByteRange is four
attacker-controlled integers used to index the file.

TEST_TARGET=pdf 497 -> 536 passing. rustfmt and clippy -D warnings clean.
2026-07-31 20:02:29 +00:00
15 changed files with 1846 additions and 2 deletions

View file

@ -0,0 +1,189 @@
# 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", 46 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.

View file

@ -79,3 +79,9 @@ name = "parse_ext_gstate"
path = "fuzz_targets/parse_ext_gstate.rs" path = "fuzz_targets/parse_ext_gstate.rs"
test = false test = false
doc = false doc = false
[[bin]]
name = "parse_signature"
path = "fuzz_targets/parse_signature.rs"
test = false
doc = false

View file

@ -0,0 +1,47 @@
//! 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<i64> = 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;
});

View file

@ -356,8 +356,19 @@ impl<'a> PdfDocument<'a> {
let Some(root_dict) = root.as_dict() else { let Some(root_dict) = root.as_dict() else {
return Ok(None); 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") { let acro_obj = match root_dict.get("AcroForm") {
Some(obj) => self.resolve(obj)?, Some(PdfObj::Ref(r)) => self.resolve_ref(*r)?,
Some(obj) => obj.clone(),
None => return Ok(None), None => return Ok(None),
}; };
let Some(acro_dict) = acro_obj.as_dict().cloned() else { let Some(acro_dict) = acro_obj.as_dict().cloned() else {
@ -385,6 +396,52 @@ impl<'a> PdfDocument<'a> {
Ok(Some(AcroForm::parse(&acro_dict, &mut resolve, &page_of))) 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<crate::signature::SignatureReport> {
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<PdfObj>)> = 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<PdfObj>)> = 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<SignatureFieldInput<'_>> = 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. /// Every annotation on `page_index`, with the page recorded on each.
/// ///
/// An annotation that fails to parse is skipped rather than failing the /// An annotation that fails to parse is skipped rather than failing the

View file

@ -6,6 +6,7 @@ pub mod document;
pub mod form; pub mod form;
pub mod page; pub mod page;
pub mod save; pub mod save;
pub mod signature;
pub use annotation_edit::{ pub use annotation_edit::{
AnnotationColor, AnnotationEdit, AnnotationEditor, AnnotationError, EditableAnnotation, AnnotationColor, AnnotationEdit, AnnotationEditor, AnnotationError, EditableAnnotation,
@ -24,6 +25,9 @@ pub use form::{
}; };
pub use page::{CMapData, ExtGStateResource, FontEncoding, FontResource, PdfPage, XObjectResource}; pub use page::{CMapData, ExtGStateResource, FontEncoding, FontResource, PdfPage, XObjectResource};
pub use save::{save_annotation_edits, save_form_edits, SaveReport}; pub use save::{save_annotation_edits, save_form_edits, SaveReport};
pub use signature::{
DocMdpPermission, Signature, SignatureError, SignatureReport, SubFilter, VerificationStatus,
};
#[cfg(test)] #[cfg(test)]
mod integration_tests { mod integration_tests {

View file

@ -0,0 +1,861 @@
//! 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<Self> {
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<String>,
pub sub_filter: Option<SubFilter>,
/// The four `/ByteRange` numbers, as written.
pub byte_range: Vec<i64>,
/// 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<u8>,
/// `/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<String>,
pub name: Option<String>,
pub reason: Option<String>,
pub location: Option<String>,
pub contact_info: Option<String>,
/// `/DocMDP` permission when this is a certification signature.
pub doc_mdp: Option<DocMdpPermission>,
/// 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>,
/// Signature fields present but with no `/V`, i.e. unsigned placeholders.
pub unsigned_fields: Vec<String>,
}
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<DocMdpPermission> {
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<String> {
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<u16> = 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<i64> = 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<usize> {
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<i64> = 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);
}
}

View file

@ -0,0 +1,313 @@
//! 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<u8> {
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);
}
}

View file

@ -9,9 +9,15 @@ description = "Makepad PDF integration: MakepadPdfDevice, GPU textures, PdfView
nigig-pdf-cos = { path = "../pdf-cos" } nigig-pdf-cos = { path = "../pdf-cos" }
nigig-pdf-document = { path = "../pdf-document" } nigig-pdf-document = { path = "../pdf-document" }
nigig-pdf-graphics = { path = "../pdf-graphics" } nigig-pdf-graphics = { path = "../pdf-graphics" }
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec"} # 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"] }
[dev-dependencies] [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" } 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 # A binary host so makepad_test can drive the widget through real event

View file

@ -1155,6 +1155,130 @@ 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 # --------------------------------------------------------------- encrypted
# #
# Encryption fixtures are produced with the same algorithms the reader # Encryption fixtures are produced with the same algorithms the reader
@ -1543,6 +1667,14 @@ CORPUS = {
("unknown_blend_mode.pdf", transparency_unknown_blend_mode), ("unknown_blend_mode.pdf", transparency_unknown_blend_mode),
("missing_gstate.pdf", transparency_missing_gstate), ("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": [ "perf": [
("large_60_pages.pdf", perf_large), ("large_60_pages.pdf", perf_large),
], ],

View file

@ -0,0 +1,38 @@
%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

View file

@ -0,0 +1,38 @@
%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

View file

@ -0,0 +1,43 @@
%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

View file

@ -0,0 +1,38 @@
%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

View file

@ -0,0 +1,34 @@
%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

View file

@ -0,0 +1,38 @@
%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