A lot of e-contract pipelines sign first and compress later. The signed file often carries scans or photos, so shrinking it right before archiving feels natural. I wanted to see what that order actually does, so I built a test file, ran it through five compression or rewrite tools, and checked every output with two validators. Short version: every tool rewrote the whole file, and every signature either failed or disappeared.
The test file is a one-page fictional agreement with an 1800×1200 synthetic photo, signed by pyHanko as adbe.pkcs7.detached with a self-made test root CA. It came out at 931,980 bytes. I validated with poppler's pdfsig 26.08.0 and pyHanko 0.33.0, both trusting only that test root. No real CA and no timestamps.
The signature covers two byte ranges
The signature dictionary had ByteRange [0 923749 931275 705]: 923,749 bytes starting at offset 0, then 705 bytes starting at offset 931,275. Add 931,275 and 705 and you get 931,980, the end of the file. The 7,526-byte gap in the middle is /Contents, where the signature value sits as hex. It can't hash itself, so it gets cut out. A validator concatenates the two ranges, runs SHA-256 over them, and compares the result with the signed digest.
So the signature is about bytes, not about how the page looks. Reorder the objects or recompress a single stream and the hash stops matching, even if every glyph on the page is identical. I spent years on video codecs, and my reflex on a checksum failure is to hunt for the broken frame. There is nothing to hunt here: after a full rewrite almost every byte has moved.
import hashlib, re, sys
pdf_bytes = open(sys.argv[1], 'rb').read()
m = re.search(rb'/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', pdf_bytes)
lo, lo_len, hi, hi_len = (int(g) for g in m.groups())
signed_part = pdf_bytes[lo:lo + lo_len] + pdf_bytes[hi:hi + hi_len]
print(hi + hi_len == len(pdf_bytes), hashlib.sha256(signed_part).hexdigest()[:8])
On the original this prints True 040cd959. After a plain qpdf rewrite (930,748 bytes, ByteRange copied over unchanged) it prints False 571be5df. The regex only works while the ByteRange is stored as plain text. Once a tool packs objects into object streams, byte-level searching starts missing things.
All five tools rewrote the whole file
| Tool | Output bytes | Signature dictionary | pdfsig | pyHanko |
|---|---|---|---|---|
| qpdf rewrite | 930,748 | kept, ByteRange copied | Illegal values in ByteRange array | INVALID |
| qpdf object streams | 930,178 | kept | Illegal values in ByteRange array | INVALID |
| Ghostscript /ebook | 39,876 | gone | no signatures | nothing to check |
| ImgIng, ebook preset | 64,903 | kept, ByteRange still ends at 931,980 | Illegal values in ByteRange array | INVALID |
| iLovePDF, recommended | 37,513 | gone | no signatures | nothing to check |
| PDF24, default | 50,877 | kept, ByteRange copied | Illegal values in ByteRange array | INVALID |
Every output went from two %%EOF markers to one, and none of them started with the original bytes. For ImgIng I used its in-browser PDF compressor at https://imging.ai/ on the ebook preset, in Chromium 149 on an Apple M4 Mac. Its output still declares a range ending at byte 931,980 inside a 64,903-byte file. Ghostscript and iLovePDF removed the signature field entirely. I think that case is worse. A reader can warn about a broken signature, but there is nothing to warn about once the signature is gone. I didn't check what Acrobat or Foxit actually display.
An appended edit still validates
The way to change a signed file without breaking the signature is an incremental update: new objects, a new xref table and a new %%EOF go after the old end of file, and the original bytes stay untouched. The signed file already had two %%EOF markers because pyHanko's signing step is itself an incremental update.
I appended three more revisions: a second signature, a text annotation written with pypdf, and a pyHanko edit to the page content stream that adds a line of text. All three still began with the original 931,980 bytes, the covered-range hash stayed 040cd959, and pdfsig said "Signature is Valid" for each one.
The content edit is the one that matters. pdfsig only added "Not total document signed". pyHanko diffed the revisions and returned ILLEGAL_MODIFICATIONS; for the second signature it returned ACCEPTABLE_MODIFICATIONS. My understanding is that pyHanko judges post-signature revisions against PDF's modification rules (DocMDP and FieldMDP), while pdfsig only checks the bytes. A valid hash tells you the signed bytes are intact. It tells you nothing about what came after them.
One result I can't fully explain. pyHanko also flagged the annotation as illegal ("unexplained xrefs"), which looks like its default policy not recognizing the way pypdf appends annotations. I haven't tried that file in Acrobat.
Compression can't take the incremental route anyway. An incremental update has to keep every original byte, so the old image streams stay where they are and the file can only grow.
What I do now
Compression happens before signing, and a signed file never goes into a compressor. When a signed PDF comes in, I check whether the last ByteRange ends at end-of-file. If it doesn't, there are revisions after the signature, and a "Valid" from a byte-hash checker isn't enough. That file goes through a validator that diffs revisions.
Top comments (0)