I appended one line of text to the page content of a signed PDF, then ran pdfsig on it. It printed "Signature is Valid". pyHanko looked at the same file and called it an illegal modification. Both are right about different things, and that gap is why I ended up writing a small check that compares a signature's ByteRange with the file size.
Some context. Our upload service compresses PDFs before storing them, and the business side recently started sending signed contracts through it. Before letting compression touch those, I wanted two answers from code: does this file carry a signature, and has anything happened to the file since it was signed.
What the two validators actually check
The test file is a one-page fictional agreement I made, signed with a self-made test CA, 931,980 bytes, adbe.pkcs7.detached. Its ByteRange is [0 923749 931275 705]: two byte intervals that get hashed, with a gap in the middle for the signature value. The second interval ends at 931,275 + 705 = 931,980, which is the end of the file.
I then appended three kinds of incremental updates to the end of the file, leaving the original bytes untouched:
| What I appended | File size | pdfsig on Sig1 | pyHanko on Sig1 |
|---|---|---|---|
| A second signature (pyHanko) | 942,215 | Valid | intact, acceptable modifications |
| A text annotation (pypdf) | 932,540 | Valid | intact, illegal modifications |
| One extra line in the page content (pyHanko) | 932,614 | Valid | intact, illegal modifications |
pdfsig only verifies the hash over the signed bytes, and those bytes didn't change, so it says Valid (with a "Not total document signed" note). pyHanko also diffs what came after the signature. One caveat on the annotation row: pyHanko's default policy didn't recognize the way pypdf appended it ("unexplained xrefs"), so that verdict tells you about pyHanko 0.33's defaults, not about Acrobat. I didn't test Acrobat.
Comparing the ByteRange end with the file size
Neither verdict is my detector's job, but noticing that something was appended is cheap. If the last signed byte equals the file size, the signature covers everything. If it's smaller, bytes were added after signing. If it's larger, the file was rewritten and the signature dictionary was copied over with stale numbers.
def signature_coverage(path):
file_len = os.path.getsize(path)
catalog = PdfReader(path).trailer["/Root"]
form = catalog["/AcroForm"].get_object() if "/AcroForm" in catalog else {}
report = []
for ref in form.get("/Fields", []): # top-level fields only
field = ref.get_object()
if field.get("/FT") == "/Sig" and field.get("/V") is not None:
_, _, start2, len2 = map(int, field["/V"].get_object()["/ByteRange"])
end = start2 + len2
state = "covers" if end == file_len else "appended" if end < file_len else "past EOF"
report.append((str(field["/T"]), end, file_len, state))
return report
I ran it (pypdf 6.20.1) over everything I had. The three incremental files all come back as "appended", and in the two-signature file Sig2 covers the whole file. Every rewritten copy that still has its signature field comes back as "past EOF": qpdf's rewrite is 930,748 bytes, the PDF24 output 50,877, and the output of ImgIng's PDF compressor 64,903, all still claiming a range up to 931,980. For that last one I used ImgIng (https://imging.ai/), ebook preset, in Chromium 149 on an M4 Mac.
Where the check gives up
The third branch is a heuristic. All my rewrites happened to be smaller than the original, but a rewrite that grows the file would land in the "appended" branch. My guess is that counting %%EOF markers would separate the two: in my samples the incremental files had 3 and every rewrite had 1. I haven't built that into the check yet.
Two outputs return an empty list: Ghostscript /ebook and iLovePDF's recommended level. Both deleted the signature field entirely, so the file looks exactly like one that was never signed. Nothing in the file itself can tell you a signature used to be there.
In the upload service it now works like this. Files whose signature covers the whole file are stored as-is and never compressed. "Appended" files are stored as-is too, and flagged for a human. "Past EOF" means the signature was already broken upstream, so the file goes back to whoever uploaded it. Files with no signed field get compressed. If you want to see the pdfsig/pyHanko gap yourself, append an annotation to any signed PDF with pypdf's incremental writer and run both validators on it.
Top comments (0)