nigig-org/REVIEWS/adr/0010-pdf-signatures.md
andodeki 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

189 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.