sign: write the catalog /Perms /DocMDP entry for certification signatures - #169
Merged
Merged
Conversation
…ures A certification signature already gets the DocMDP transform in its signature dictionary /Reference, but the document catalog never gained the matching /Perms entry. ISO 32000-1 12.8.2.2 requires both: /P states the permission level, while /Perms is what makes a conforming reader apply it. Without /Perms the file is read as an ordinary approval signature, with no "Certified by" banner and no enforced restriction. createCatalog now writes /Perms << /DocMDP N 0 R >> for a certification signature, referencing the signature object that addSignatureObject assigns before addCatalog runs. The catalog copy loop reproduces every existing root entry, so a document that already carries /Perms (for example /UR3 usage rights) would end up with the key twice. Such a /Perms is instead rewritten with its existing entries preserved and /DocMDP merged in. A document whose /Perms already has a /DocMDP entry is already certified and can only take an approval signature, so certifying it is refused rather than silently rewritten, as is a /Perms that is not a dictionary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
Move the checks out of createCatalog into validateSignData so they run before any work is done, and add the rule that a certification signature must be the first signature in the document: a /Perms /DocMDP pointing at a later signature is flagged as invalid by readers, which is worse than the approval-style output the previous code produced for that case. The errors name ApprovalSignature as the alternative, and the README notes the constraint next to the CLI's CertificationSignature default. The catalog /Perms entry is checked by key rather than by resolved value, so a /DocMDP or /Perms reference that cannot be resolved is rejected instead of being copied as a duplicate or dropped silently. When the existing /Perms is an indirect object, its direct values carry that object's pointer rather than the catalog's, so serializeCatalogEntry was writing them as references back to the superseded /Perms object. Pass the /Perms pointer instead, with a test that fails on the old code. Compute the fixture stream /Length instead of hard-coding it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
serializeCatalogEntry wrote strings and names back as the reader decoded them, so a copied value holding a delimiter could end its own token and continue as catalog structure. With /Perms now the dictionary that defines the certification, a crafted entry could close it early and swallow the /DocMDP reference. Literal strings are escaped and names are #-encoded on output, for the root entries as well. hasSignedField only looked at the top-level AcroForm fields, unlike the verify package, so a signed field below a parent field (a hierarchical field name, with /FT possibly inherited) did not block a certification signature. Walk the tree with a visited set and a depth bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
This was referenced Sep 22, 2026
vanbroup
pushed a commit
that referenced
this pull request
Sep 22, 2026
/V is inheritable like /FT (ISO 32000-1 Table 220), so a parent field with a value is the signed field; its child fields would only inherit that value. Report it instead of walking into it, as the walk in #169 did for the certification gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
Contributor
Member
Author
|
Thanks @akkaraponph, the "Certified by" ribbon and "No changes are allowed" shows Acrobat now recognises the certification, and the qpdf comparison against v0.9.0 pins it to the /Perms entry. |
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
A
CertificationSignaturewrites the DocMDP transform into the signature dictionary's/Reference, but the document catalog never gained the matching/Permsentry. ISO 32000-1 §12.8.2.2 requires both:/Pstates the permission level,/Perms /DocMDPis what makes a conforming reader apply it. Without it the output is read as an ordinary approval signature — the file verifies,verify.checkDocMDPstill reports it as certified from/Reference, but Acrobat shows no "Certified by" banner and does not enforce the restriction.Supersedes #165 and #166, which are independent submissions of the same fix (credit to @uinoushi and @akkaraponph for the diagnosis). Both miss the same case: the catalog copy loop already reproduces an existing
/Perms, so #165 emits the key twice (invalid dictionary) and #166 refuses any document that already has one, including a never-certified Reader-enabled (/UR3) document.Changes
createCatalogwrites/Perms << /DocMDP N 0 R >>for a certification signature, referencing the signature objectaddSignatureObjectassigns beforeaddCatalogruns. An existing/Permsis rewritten with its entries (/UR3etc.) preserved and/DocMDPmerged in. Direct values inside an indirect/Permscarry that object's pointer, so the writer passes it toserializeCatalogEntryinstead of the catalog's; without that they were written as references back to the superseded/Permsobject.validateSignDatanow rejects a certification signature up front (before the retry loop andPreSignCallback) when the document is already certified (/Perms /DocMDPpresent, checked by key so a dangling reference still counts), when/Permscannot be read as a dictionary, or when the document already has a signed field — the spec's "shall be the first signed field". Writing/Perms /DocMDPfor a later signature would produce a file readers flag as invalid, which is worse than the approval-style outputmainproduced for that case. The errors nameApprovalSignatureas the alternative; the README notes the constraint next to the CLI'sCertificationSignaturedefault. The field walk follows/Kidsand inherited/FTlike theverifypackage does, with a visited set and depth bound.serializeCatalogEntryescapes literal strings and #-encodes names on output. The reader decodes both, so a copied value containing a delimiter could end its own token and continue as catalog structure; with/Permsnow the dictionary that defines the certification, a crafted entry could close it early and swallow the/DocMDPreference. This gap pre-dates the PR for every root-level entry (a root/Lang (\))reaches the same branch onmain), so the fix applies there too. Output for ordinary values is byte-identical.Tests
TestCreateCataloggolden catalogs gain the entry with the object number pinned.sign/pdfcatalog_docmdp_test.gocovers: only a certification signature writes/Perms; inline and indirect/Permskeep their entries (the indirect case fails on the old pointer logic); the escaping round-trip (fails without the fix — the/DocMDPpointer is lost); rejection of already-certified, unreadable-/Perms, already-signed, nested-signed and inherited-/FTdocuments, including a/Kidscycle; an unsigned signature field does not block; an end-to-end sign whose/Perms /DocMDPresolves through the reader to the/Type /Sigobject with the right transform and/P.go build,go vetandgo test ./...are green.golangci-lintwas not run locally (the installed 2.5.0 refuses the Go 1.27 target); the new code mirrors the file's existing buffer-write patterns.Not in this PR
UsageRightsSignaturehas the identical omission (/TransformMethod /UR3with no catalog/Perms /UR3). Same shape, one line; left out to keep this focused.verify.checkDocMDPtrusts/Referencealone and never checks the catalog points back at the signature, so pdfsign reports a pre-fix file as certified where no reader would.fetchExistingSignatures,verifyandextractwalk the field tree without a cycle guard (pre-existing).🤖 Generated with Claude Code
https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM
Generated by Claude Code