nigig-org/REVIEWS/adr/0024-pdf-encryption-on-save.md
andodeki d4e3e9a443
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 21s
doc-engine / coverage (push) Successful in 31s
doc-engine / consumer (push) Failing after 16m57s
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
feat(pdf): encryption on save — AES-128 and AES-256 (Phase 6, part one)
ADR 0024. This reverses ADR 0005's "never write encryption", and the
reason it is safe to reverse is that the facts changed underneath it.

A crate that only reads cannot produce weak ciphertext, so refusing to
write any was free. Now that Phase 4 creates documents and Phase 5 edits
them, the refusal does something worse than protect nobody: open a
password-protected file, change one annotation, save, and the output is
plaintext. No error, no warning — the protection is silently dropped. That
is this project's recurring failure mode in the one place where the
consequence is a breach.

The principle survives in a narrower form: no hand-rolled crypto, and no
weak cipher offered as an option. RC4 stays readable because files use it
and is not writable — EncryptionAlgorithm has no RC4 variant, so the
refusal is a type, not a runtime check someone can route around.

The encryptor is the literal inverse of the decryptor and imports its
primitives rather than restating them; two implementations of one algorithm
drift, and here they drift towards "decrypts to garbage". Every unit test
round-trips through the existing Decryptor.

Encryption sits at one choke point: PdfWriter holds the Encryptor and
write_object_at encrypts everything passing through. Not per call site —
there are twenty-two of those in PdfDocBuilder, and one stream written in
the clear inside an encrypted document is not a partial failure, it is a
leak that no reader will report because the file is otherwise valid. The
/Encrypt dictionary is the single deliberate exemption: it holds the salts
a reader needs before it has a key, so encrypting it bricks the file.

Verified against implementations we share no code with, now gated in CI:

  ok    qpdf opens it with the password
  ok    it really is AES-256
  ok    the wrong password is refused
  ok    poppler decrypts the content
  ok    no plaintext in the encrypted file

Four mutations, all killed — two only after the tests were strengthened,
and both misses are the interesting part:

  A fixed IV survived two_saves_of_one_document_are_not_byte_identical,
  because the AES-256 file key is fresh per save and that alone makes the
  output differ. The property actually needed is narrower: one encryptor,
  identical plaintext, different bytes. In CBC a repeated IV under one key
  leaks that two plaintexts are equal.

  A wrong /Length survived because our own reader recovers by scanning for
  endstream — a robustness fix from ADR 0023. An independent reader that
  trusts /Length reads a truncated stream and decrypts garbage. A lenient
  reader hides a broken writer, which is why the external gate exists.

The /Length test itself had a bug first: it searched a from_utf8_lossy view
and reported a stream declaring 80 bytes holding 156. Ciphertext is not
UTF-8; the replacement characters shifted every offset.

Unencrypted output stays byte-reproducible; encrypted output cannot be, and
a test asserts that loss rather than leaving it implicit.

pdf: 1220 passed (was 1187). pdf-ui: green. Coverage 88.21%,
encrypt_write.rs at 96.5%.

Signing is NOT started. It needs the trust-anchor decision ADR 0010
deferred: VerificationStatus::Valid is unreachable by construction, and
making sign -> verify pass is a policy change, not an implementation
detail. The plan's Phase 6 status now says so.
2026-08-18 07:27:45 +00:00

224 lines
10 KiB
Markdown

# ADR 0024: encryption on save — reversing "never write encryption"
- **Status:** Accepted
- **Date:** 2026-08-18
- **Review item:** `NIGIG_PDF_FEATURE_PARITY_PLAN.md` §1 Phase 6,
"encryption applied on save for AES-128/256 (writer side, not just parser
side)" and the exit criterion "encrypted-write round-trip"
- **Supersedes:** ADR 0005's decision to "never write encryption"
- **Related:** ADR 0005 (reading the standard security handler), ADR 0019
(document creation), ADR 0023 (reproducible output — deliberately given
up here, for a reason)
## Context
ADR 0005 implemented the standard security handler for **reading** and
committed to never writing it. That was the right call at the time and for
the reason it gave: a crate that only parses cannot produce weak ciphertext
if it produces none.
Phases 4 and 5 changed the facts. The crate now creates documents and edits
them, and an editor that cannot write encryption does something worse than
refuse — it **silently drops the protection**. Open a password-protected
document, change one annotation, save, and the output is plaintext. No
error, no warning; the file simply stops being encrypted. That is the
failure mode this project keeps finding, in the one place where the
consequence is a data breach rather than a wrong glyph.
So the refusal now protects nobody, and the decision is reversed. The
principle behind ADR 0005 is kept intact in a narrower form: **no
hand-rolled cryptography, and no weak cipher offered as an option.**
## Decision
### What is written, and what is refused
| Cipher | Read | Write |
|---|---|---|
| RC4 40/128-bit (`V2`) | yes | **refused** |
| AES-128 (`AESV2`, `/V 4 /R 4`) | yes | yes |
| AES-256 (`AESV3`, `/V 5 /R 6`) | yes | yes, default |
RC4 stays readable because files exist that use it. It is **not writable**:
it is broken, and a library that offers a broken cipher as an option will
have it selected by someone who does not know that. `EncryptionAlgorithm`
has no RC4 variant, so the refusal is enforced by the type rather than by a
runtime check that could be bypassed.
AES-256 is the default. AES-128 is kept because Acrobat 7-9 and a long tail
of enterprise tooling cannot open AES-256.
### The encryptor is the exact inverse of the decryptor
`encrypt_write.rs` imports `hash_r6`, `pad_password` and `rc4_apply` from
`encrypt.rs` rather than restating them, and `Encryptor::object_key` is a
line-for-line mirror of `Decryptor::object_key`. Two implementations of one
algorithm drift, and the direction they drift in here is "decrypts to
garbage" — which is exactly what an encryption round-trip test would catch
and a unit test of either half alone would not.
Every unit test in the module round-trips through the **existing**
`Decryptor`. Nothing asserts against a hardcoded ciphertext, because a
hardcoded expectation only proves the code still does what it did.
### Encryption lives at one choke point
`PdfWriter` holds an optional `Encryptor`, and `write_object_at` encrypts
every string and stream on the way out. It is not applied at each call
site, and that is the important design decision:
> Encryption is all-or-nothing. One stream written in the clear inside an
> encrypted document is not a partial failure, it is a leak, and no reader
> will report it because the file is otherwise perfectly valid.
Putting it at the single point every object passes through makes "forgot to
encrypt that one" unrepresentable rather than merely unlikely. There are
twenty-two `write_object_at` call sites in `PdfDocBuilder`; auditing all of
them on every future change is not a plan.
One object is exempt and must be: `write_object_at_plain` writes the
`/Encrypt` dictionary itself. It carries the salts and validation hashes a
reader needs **before** it can derive any key. Encrypting it makes the
document permanently unopenable — by us and by everyone else.
### `/ID` is generated with the key, not after it
Revisions 2-4 mix the first `/ID` element into key derivation. A document
whose `/ID` is regenerated after the key is derived cannot be opened at
all. `PdfDocBuilder::finish` therefore generates the file ID, feeds it to
`Encryptor::new`, and writes the same bytes into the trailer, so the two
cannot disagree.
### Failing closed
`getrandom` supplies salts and IVs. When the OS random source fails there
is no safe fallback — a predictable IV in CBC is a real break, not a
degradation — so `finish` returns an empty `Vec` rather than a document.
Writing the file in the clear when the caller asked for a password would be
the worst available outcome.
## Verification
### Round trip, through our own reader
15 integration tests in `pdf-document/tests/encryption_write.rs` and 18
unit tests in the module. The two that carry the weight:
- **`the_plaintext_is_not_in_the_file`** searches the raw bytes, not any
API. An API that decrypts is precisely the thing that cannot see a leak.
It has a control: the same document unencrypted *does* contain the
payload, so the test cannot pass by the fixture having lost it.
- **`strings_are_encrypted_too_not_just_streams`** puts the payload in the
`/Info` title. Encrypting streams and forgetting strings is the easy
half-implementation, and it leaks document titles, form values and
annotation contents.
### Round trip, through implementations we share no code with
Added to `tools/check-pdf-external-readers.sh`, which CI runs:
```
== encrypted document (ADR 0024) ==
ok qpdf opens it with the password
ok it really is AES-256
ok the wrong password is refused
ok poppler decrypts the content
ok no plaintext in the encrypted file
```
`qpdf --check` reports `R = 6`, `stream encryption method: AESv3` and **no
syntax or stream encoding errors**; `pdfinfo` reports
`Encrypted: yes (... algorithm:AES-256)`; `pdftotext -upw` extracts the same
text as from the unencrypted control. AES-128 gives `R = 4` and `AESv2`.
### Mutation testing
Four mutations, all killed — but **two only after the tests were
strengthened**, and both failures are worth recording:
| Mutation | First run | After |
|---|---|---|
| strings not encrypted | 3 tests fail | — |
| `/Length` not updated for ciphertext | **survived** | 1 fails |
| fixed IV instead of random | **survived** | 1 fails |
| `/Encrypt` dict itself encrypted | 8 tests fail | — |
**The fixed IV survived because of the test I thought covered it.**
`two_saves_of_one_document_are_not_byte_identical` passes with a constant
IV, because the AES-256 file key is regenerated on every save and that
alone makes the output differ. The property I actually needed is narrower:
*one* encryptor, encrypting *identical* plaintext, must produce different
bytes. Holding the key fixed isolates the IV, which is the thing that
matters — in CBC, a repeated IV under one key leaks that two plaintexts are
equal.
**The `/Length` mutation survived because our own reader is too forgiving.**
It recovers from a wrong `/Length` by scanning for `endstream` — a
robustness fix from ADR 0023 — so a round-trip through our stack cannot see
the defect. An independent reader that trusts `/Length` would read a
truncated stream and decrypt garbage. The new test measures the written
bytes directly.
That second one is a general lesson: **a lenient reader hides a broken
writer.** Round-tripping through your own stack is necessary and not
sufficient, which is why the external-reader gate exists.
The `/Length` test also had to be rewritten. Its first draft searched a
`String::from_utf8_lossy` view of the file and reported a stream declaring
80 bytes while holding 156 — ciphertext is not valid UTF-8, and the
replacement characters shifted every offset. The bug was in the test.
## Merge criteria
Taken from the Phase 6 plan text:
- [x] Encryption applied on save, writer side
- [x] AES-128 (`/V 4 /R 4`, `AESV2`)
- [x] AES-256 (`/V 5 /R 6`, `AESV3`)
- [x] Permission flags carried into the saved file and read back
- [x] Encrypted-write round trip through our own reader
- [x] Encrypted-write round trip through qpdf and poppler
- [x] The plaintext is absent from the written bytes, asserted on bytes
- [x] Strings encrypted as well as streams
- [x] Wrong password refused, by us and by qpdf
- [x] Owner password opens the document
- [x] Empty user password still encrypts (permissions-only case)
- [x] `/Encrypt` written unencrypted; encrypting it fails 8 tests
- [x] `/ID` generated with the key it feeds
- [x] Fresh IV per object, isolated from key freshness
- [x] `/Length` describes the ciphertext
- [x] Unencrypted output stays byte-reproducible (ADR 0023 preserved)
- [x] No hand-rolled primitives; `getrandom` for all randomness
- [x] RC4 not writable, enforced by the type
- [x] Four mutations, all killed
- [ ] **Digital signature writing, PKIX validation, revocation** — the rest
of Phase 6. Not started; see below.
## Consequences
**Positive.** A document that arrives encrypted can be edited and saved
still encrypted. Phase 6's encryption exit criterion is met and gated in CI
against two external implementations.
**Negative, and deliberate.** Encrypted output is **not reproducible**. ADR
0023 made byte-identical output a property worth having — it makes files
diffable in review and cacheable — and encryption gives it up, because the
file key, the salts and every IV must be fresh. `two_saves_of_one_document_
are_not_byte_identical` asserts the loss rather than leaving it implicit,
and unencrypted output is still reproducible.
**Risk.** This code protects real secrets, and a subtle error here is worth
more to an attacker than any other defect in this repository. The
mitigations are: audited RustCrypto primitives only, the encryptor written
as the literal inverse of a decryptor that already reads other producers'
files, round-trips verified against qpdf and poppler, and a byte-level leak
test with a control. What it has **not** had is an independent security
review, and it should have one before it is offered as a product feature.
**Not done:** public-key security handlers (`/Filter /Adobe.PubSec`),
crypt filters other than the standard handler, encrypting only attachments
(`/EFF`), and the entire signing half of Phase 6 — CMS/PKCS#7 writing, RSA
and Ed25519 identities, PKIX chain validation, and revocation. That work
needs the trust-anchor decision ADR 0010 identified: `VerificationStatus::
Valid` is currently unreachable by construction, and making it reachable is
a policy change, not an implementation detail.