Compare commits
No commits in common. "e45a717ce78d9d4f8be9c540d412b684c4713c56" and "d6b35d798950994ea71611c59c2be82f1bbe6520" have entirely different histories.
e45a717ce7
...
d6b35d7989
15 changed files with 2 additions and 1846 deletions
|
|
@ -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.
|
|
||||||
|
|
@ -79,9 +79,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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<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;
|
|
||||||
});
|
|
||||||
|
|
@ -356,19 +356,8 @@ 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(PdfObj::Ref(r)) => self.resolve_ref(*r)?,
|
Some(obj) => self.resolve(obj)?,
|
||||||
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 {
|
||||||
|
|
@ -396,52 +385,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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,
|
||||||
|
|
@ -25,9 +24,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,15 +9,9 @@ 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" }
|
||||||
# The `test` feature gates makepad-widgets' re-export of makepad-test,
|
makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec"}
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
# --------------------------------------------------------------- encrypted
|
||||||
#
|
#
|
||||||
# Encryption fixtures are produced with the same algorithms the reader
|
# Encryption fixtures are produced with the same algorithms the reader
|
||||||
|
|
@ -1667,14 +1543,6 @@ 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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue