Skip to content

sign: write the catalog /Perms /DocMDP entry for certification signatures - #169

Merged
vanbroup merged 4 commits into
mainfrom
agent/wizardly-shannon-mtm0lq
Sep 23, 2026
Merged

vanbroup merged 4 commits into
mainfrom
agent/wizardly-shannon-mtm0lq

Conversation

@vanbroup

Copy link
Copy Markdown
Member

Summary

A CertificationSignature writes the DocMDP transform into the signature dictionary's /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, /Perms /DocMDP is what makes a conforming reader apply it. Without it the output is read as an ordinary approval signature — the file verifies, verify.checkDocMDP still 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

createCatalog writes /Perms << /DocMDP N 0 R >> for a certification signature, referencing the signature object addSignatureObject assigns before addCatalog runs. An existing /Perms is rewritten with its entries (/UR3 etc.) preserved and /DocMDP merged in. Direct values inside an indirect /Perms carry that object's pointer, so the writer passes it to serializeCatalogEntry instead of the catalog's; without that they were written as references back to the superseded /Perms object.

validateSignData now rejects a certification signature up front (before the retry loop and PreSignCallback) when the document is already certified (/Perms /DocMDP present, checked by key so a dangling reference still counts), when /Perms cannot 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 /DocMDP for a later signature would produce a file readers flag as invalid, which is worse than the approval-style output main produced for that case. The errors name ApprovalSignature as the alternative; the README notes the constraint next to the CLI's CertificationSignature default. The field walk follows /Kids and inherited /FT like the verify package does, with a visited set and depth bound.

serializeCatalogEntry escapes 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 /Perms now the dictionary that defines the certification, a crafted entry could close it early and swallow the /DocMDP reference. This gap pre-dates the PR for every root-level entry (a root /Lang (\)) reaches the same branch on main), so the fix applies there too. Output for ordinary values is byte-identical.

Tests

TestCreateCatalog golden catalogs gain the entry with the object number pinned. sign/pdfcatalog_docmdp_test.go covers: only a certification signature writes /Perms; inline and indirect /Perms keep their entries (the indirect case fails on the old pointer logic); the escaping round-trip (fails without the fix — the /DocMDP pointer is lost); rejection of already-certified, unreadable-/Perms, already-signed, nested-signed and inherited-/FT documents, including a /Kids cycle; an unsigned signature field does not block; an end-to-end sign whose /Perms /DocMDP resolves through the reader to the /Type /Sig object with the right transform and /P.

go build, go vet and go test ./... are green. golangci-lint was 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

  • UsageRightsSignature has the identical omission (/TransformMethod /UR3 with no catalog /Perms /UR3). Same shape, one line; left out to keep this focused.
  • verify.checkDocMDP trusts /Reference alone and never checks the catalog points back at the signature, so pdfsign reports a pre-fix file as certified where no reader would.
  • fetchExistingSignatures, verify and extract walk the field tree without a cycle guard (pre-existing).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fey5UYDEY84too4QCQ28oM


Generated by Claude Code

…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
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
@akkaraponph

akkaraponph commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

@vanbroup

I have tested #169 against both a self-signed mock certificate and an AATL-trusted certificate (INET CA).

Black Box Testing result via Acrobat

Screenshot 2569-09-23 at 09 50 20 Screenshot 2569-09-23 at 09 49 58

White Box Testing using

1. Unpack streams and normalize indirect objects

qpdf --qdf --object-streams=disable mock-signing-document-signed.pdf unpacked.pdf

2. Search for the Perms dictionary and its sub-entries

grep -E "(/Perms|/DocMDP|/UR3)" unpacked.pdf

By compare to the version github.com/digitorus/pdfsign v0.9.0 (highlighted in the red box on the screenshot) with the locally checked-out branch agent/wizardly-shannon-mtm0lq." (green box highlighted)

Screenshot 2569-09-23 at 11 09 39

@vanbroup

Copy link
Copy Markdown
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.

@vanbroup
vanbroup merged commit 6664fa1 into main Sep 23, 2026
6 checks passed
@vanbroup
vanbroup deleted the agent/wizardly-shannon-mtm0lq branch September 23, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants