mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Adds crates/crypt/tests/qpdf_literals.rs (305 lines): three tests that hand our security
handler /O, /U, /OE, /UE and /Perms values from outside implementations, then require our
reader to authenticate them and report the intended role. Tests only - no product code, no
new dependency; PDFs stay outside the repository and only quoted hex is committed.
Why outsiders wrote these numbers: create() and open() share one derivation, so a
wrong-but-self-consistent implementation passes every internal test. Two review rounds proved
it by mutation - several key-derivation breaks stayed green in both suites because writer and
reader drifted together. Quoting bytes we did not compute breaks the circle: open() recomputes
/U (or the revision 6 hash chain) and compares it to what was stored, so a divergent derivation
cannot authenticate.
Six cases come from qpdf core 12.2.0 via pikepdf (R2/R3, R4 in both metadata modes, R6 in both).
Revisions 2-4 are deterministic given /ID and passwords, so the generator reproduces their quoted bytes
byte for byte (verified). Revision 6 salts are fresh on every run by design: these literals match the
fixtures as captured, and re-running the generator will produce different - equally valid - revision 6
numbers, so compare R/V/Length structurally there and re-quote if you regenerate. One
case is HAND-DERIVED, labelled as such in the source: an independent re-implementation of ISO
Algorithms 2/3 at n = 5, cross-validated by having that same routine reproduce qpdf's n=16 /O
byte for byte. It exists because no conforming producer emits R3 at 40 bits - qpdf writes 128
there and this crate's create() maps Rc4_40 to revision 2 - yet the derivation loop only runs
for r >= 3, so without a crafted row its truncation is unobservable and mutating it changes
behaviour that nothing catches.
Measured effect, same battery, upstream --lib run separately each time:
r6-hash-selector (%3 collapsed to %2) CAUGHT here; upstream stays green
r6-sha-arms-swapped (SHA-384 <-> SHA-512) CAUGHT here; upstream stays green
r6-selector-window (sums 17 bytes not 16) CAUGHT here; upstream stays green
r4-key-chain-truncation (loop hashes whole digest) CAUGHT here, via the crafted row;
upstream stays green. Pristine 3/3 pass, mutated 2 passed / 1 FAILED - verified twice.
Still invisible, named rather than glossed: r6-round-floor (minimum rounds 64 -> 63) - the
terminator does not separate the two floors on these inputs; /OE,/UE length loosening and the
/Perms filler source, both unreached here.
Corrections carried from review: qpdf's advice against a static /ID does not weaken these
revision 6 vectors, because key_r6 never receives the file /ID at all; it matters only to the
revision 2-4 paths, where we quote /ID literally so the numbers stay exact. And no ATTRIBUTION
entry is added, matching crates/crypt/src/tests.rs:237 which already quotes qpdf vectors
unattributed; cargo xtask assets stays ok.
Gates on a clean tree: cargo test -p pdfcraft-crypt 28 passed / 0 failed (17 lib + the 8 properties from #2358, now merged,
plus 3 oracle tests - one
per concern - role authentication across seven dictionaries, metadata-flag key separation, /Perms
decode);
clippy --all-targets -D warnings clean; cargo fmt --check clean; xtask layers OK (35 crates).
Integrity note: the literals in this commit were checked field by field against the PDFs on disk -
27 of 27 fields quoted verbatim, no transcription drift. A regression test for that check belongs in the
generator's output comparison rather than here, since it needs pikepdf.