Skip to content

Support writing to encrypted documents and AES-256 R6 - #5

Merged
vanbroup merged 2 commits into
digitorus:mainfrom
nascuite:feature/encryption-for-writers
Sep 21, 2026
Merged

vanbroup merged 2 commits into
digitorus:mainfrom
nascuite:feature/encryption-for-writers

Conversation

@nascuite

Copy link
Copy Markdown

Writers that append objects to an encrypted document, such as a signature in an incremental update, must encrypt their strings and streams with the document’s key. The key and cipher parameters were only available internally, so such writers produced plain-text objects that readers decrypt into garbage.

  • Add Reader.IsEncrypted and Reader.Encrypt, which encrypts string or stream data for a given object (RC4, AES-128 V4, AES-256 V5) without exposing the key, and NewPtr to reference new objects.
  • Do not decrypt the Contents of signature and document timestamp dictionaries, which are never encrypted (ISO 32000-2, 7.6.2). Decrypting them broke verification of signed encrypted documents.
  • Support AES-256 revision 6 (ISO 32000-2, Algorithm 2.B), written by default by qpdf and Acrobat, and by pdfcpu for PDF 2.0 documents.
  • Fix authentication with the owner password for V5: the hash must include the 48 bytes of U.

@nascuite
nascuite marked this pull request as draft September 17, 2026 15:26
Writers that append objects to an encrypted document, such as a signature in an incremental update, must encrypt their strings and streams with the document’s key. The key and cipher parameters were only available internally, so such writers produced plain-text objects that readers decrypt into garbage.

- Add Reader.IsEncrypted and Reader.Encrypt, which encrypts string or stream data for a given object (RC4, AES-128 V4, AES-256 V5) without exposing the key, and NewPtr to reference new objects.
- Do not decrypt the Contents of signature and document timestamp dictionaries, which are never encrypted (ISO 32000-2, 7.6.2). Decrypting them broke verification of signed encrypted documents.
- Support AES-256 revision 6 (ISO 32000-2, Algorithm 2.B), written by default by qpdf and Acrobat, and by pdfcpu for PDF 2.0 documents.
- Fix authentication with the owner password for V5: the hash must include the 48 bytes of U.
- Support authentication with the owner password for V1, V2 and V4 (R2–R4, Algorithm 7). Only the user password was accepted.
- Reject key lengths over 128 bits for V1, V2 and V4 instead of panicking.
@nascuite
nascuite force-pushed the feature/encryption-for-writers branch from 384b99a to d60316b Compare September 17, 2026 19:17
@nascuite
nascuite marked this pull request as ready for review September 17, 2026 19:17
nascuite pushed a commit to nascuite/pdfsign that referenced this pull request Sep 18, 2026
Signing an encrypted document produced an internally inconsistent file: the signature dictionary, field, appearance and updated catalog and page were written in plain text, while readers decrypt every string and stream with the document key. Acrobat and pdfcpu found no signatures, and no error was reported.

* Encrypt all strings and streams of objects written by AddObject and UpdateObject when the document is encrypted. Strings are written as hexadecimal strings and stream Length values are updated. The encryption dictionary, the Contents of signature dictionaries and cross-reference streams are not encrypted, as required by ISO 32000.
* Add OpenWithPassword and OpenFileWithPassword. The password is also used when the document is re-opened for further signatures, verification and signature extraction.
* Add VerifyOptions.Password. The DocMDP incremental update scope check now opens encrypted documents too; previously it was silently skipped.
* Add encrypted test files, created from testfile17.pdf with password “pdfsign”: AES-128 R4 (qpdf), AES-256 R5 (pdfcpu, xref streams), AES-256 R6 (qpdf) and RC4 R3 (qpdf), the last one with its own owner password.

Requires github.com/digitorus/pdf with Reader.Encrypt and R6 support, which
is digitorus/pdf#5. Until that is released the replace in go.mod builds
against the commit the pull request was opened from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@vanbroup vanbroup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the contribution and the extensive encryption tests. I found two issues to address: the file-size bound rejects valid compressed cross-reference streams, and explicit Crypt filters can be decrypted with the wrong algorithm. Both are detailed inline and reproduced with focused tests; the existing go test ./... suite passes.

As a follow-up, consider documenting the supported encryption/filter combinations and adding end-to-end interoperability tests for encrypted incremental updates reopened by an independent implementation.

Comment thread read.go Outdated
// table of a file of end bytes. Every object needs at least one byte in the
// file, so a larger object number is malformed.
func xrefIndex(x, end int64) (int, error) {
if x < 0 || x >= end {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not bound object numbers by compressed file size

x >= end rejects valid PDFs whose compressed cross-reference entries outnumber the file's bytes. A 422-byte fixture with 10,001 entries opens on main but fails here at object 422. Please use an allocation limit or sparse representation instead of treating file size as the maximum object number, and add a regression test with a compressed xref stream.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Object numbers are now bounded by the PDF architectural limit instead of the file size, and a regression test covers a compressed xref stream.

Comment thread read.go
return nil, fmt.Errorf("unknown filter %s", name)
case "Crypt":
// newStreamReader decrypts the stream.
return rd, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor the stream's named crypt filter

This branch assumes newStreamReader used the selected filter, but it always decrypts with the document default. With AESV2 as the default and /DecodeParms << /Name /RC4CF >> selecting a V2 filter, a focused reproduction returns corrupted bytes and a block-size error. Please resolve the named filter before decrypting, or explicitly reject unsupported overrides, and cover this combination in a regression test. The PDF reference excludes streams with an explicit Crypt filter from StmF default processing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: a stream that names a crypt filter is now decrypted with that filter's method instead of the default one, and unsupported filters are rejected. A regression test covers AESV2 as the default with a V2 named filter.

- Bound object numbers by the architectural limit of 8,388,607 objects (ISO 32000-1, Annex C.2) instead of the file size, which rejected Flate-encoded cross-reference streams with more entries than the file has bytes.
- Decrypt streams that name a crypt filter in `/CF` using that filter’s method (`V2`, `AESV2`, `AESV3`, `None`, or `Identity`) instead of the method specified by `/StmF`. Document that `Reader.Encrypt` uses the default method.
- Keep `XrefInformation.ItemCount` for a cross-reference stream at least `/Size`, so an incremental update never declares a smaller `/Size`.
- Validate `/W` and `/Index` in cross-reference streams, bound predictor `/Columns`, reject negative trailer `/Size` values, cap nested object
@nascuite

Copy link
Copy Markdown
Author

Also hardened the reader against malformed input: validated /W and /Index of xref streams, bounded predictor /Columns and nesting of object streams, and made the %%EOF search linear.

@vanbroup vanbroup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing both findings and adding the regression coverage. I reviewed 016b3c8, including the additional parser changes, and reran both original reproductions: the compressed cross-reference document now opens, and the explicitly selected V2 crypt filter decrypts correctly with an AESV2 document default.

The full go test ./... suite and go vet ./... pass on this revision, and all GitHub checks are green. I found no further actionable issues in this update. Both previously reported findings are addressed.

@vanbroup
vanbroup merged commit 0d30511 into digitorus:main Sep 21, 2026
10 checks passed
nascuite pushed a commit to nascuite/pdfsign that referenced this pull request Sep 21, 2026
Signing an encrypted document produced an internally inconsistent file: the signature dictionary, field, appearance and updated catalog and page were written in plain text, while readers decrypt every string and stream with the document key. Acrobat and pdfcpu found no signatures, and no error was reported.

* Encrypt all strings and streams of objects written by AddObject and UpdateObject when the document is encrypted. Strings are written as hexadecimal strings and stream Length values are updated. The encryption dictionary, the Contents of signature dictionaries and cross-reference streams are not encrypted, as required by ISO 32000.
* Add OpenWithPassword and OpenFileWithPassword. The password is also used when the document is re-opened for further signatures, verification and signature extraction.
* Add VerifyOptions.Password. The DocMDP incremental update scope check now opens encrypted documents too; previously it was silently skipped.
* Add encrypted test files, created from testfile17.pdf with password “pdfsign”: AES-128 R4 (qpdf), AES-256 R5 (pdfcpu, xref streams), AES-256 R6 (qpdf) and RC4 R3 (qpdf), the last one with its own owner password.

Requires github.com/digitorus/pdf with Reader.Encrypt and R6 support, which
is digitorus/pdf#5. Until that is released the replace in go.mod builds
against the commit the pull request was opened from.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 21, 2026
The encryption API (`Reader.Encrypt`, `IsEncrypted`, `Trailer`, `Ptr`) and R6 support from digitorus/pdf#5 are released in v0.3.0, so the temporary `replace` pointing to the fork is no longer needed.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 21, 2026
Signing an encrypted document produced an internally inconsistent file: the signature dictionary, field, appearance and updated catalog and page were written in plain text, while readers decrypt every string and stream with the document key. Acrobat and pdfcpu found no signatures, and no error was reported.

* Encrypt all strings and streams of objects written by AddObject and UpdateObject when the document is encrypted. Strings are written as hexadecimal strings and stream Length values are updated. The encryption dictionary, the Contents of signature dictionaries and cross-reference streams are not encrypted, as required by ISO 32000.
* Add OpenWithPassword and OpenFileWithPassword. The password is also used when the document is re-opened for further signatures, verification and signature extraction.
* Add VerifyOptions.Password. The DocMDP incremental update scope check now opens encrypted documents too; previously it was silently skipped.
* Add encrypted test files, created from testfile17.pdf with password “pdfsign”: AES-128 R4 (qpdf), AES-256 R5 (pdfcpu, xref streams), AES-256 R6 (qpdf) and RC4 R3 (qpdf), the last one with its own owner password.

Requires github.com/digitorus/pdf with Reader.Encrypt and R6 support, which
is digitorus/pdf#5. Until that is released the replace in go.mod builds
against the commit the pull request was opened from.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 21, 2026
The encryption API (`Reader.Encrypt`, `IsEncrypted`, `Trailer`, `Ptr`) and R6 support from digitorus/pdf#5 are released in v0.3.0, so the temporary `replace` pointing to the fork is no longer needed.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 22, 2026
The encryption API (`Reader.Encrypt`, `IsEncrypted`, `Trailer`, `Ptr`) and R6 support from digitorus/pdf#5 are released in v0.3.0, so the temporary `replace` pointing to the fork is no longer needed.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 22, 2026
Signing an encrypted document produced an internally inconsistent file: the signature dictionary, field, appearance and updated catalog and page were written in plain text, while readers decrypt every string and stream with the document key. Acrobat and pdfcpu found no signatures, and no error was reported.

* Encrypt all strings and streams of objects written by AddObject and UpdateObject when the document is encrypted. Strings are written as hexadecimal strings and stream Length values are updated. The encryption dictionary, the Contents of signature dictionaries and cross-reference streams are not encrypted, as required by ISO 32000.
* Add OpenWithPassword and OpenFileWithPassword. The password is also used when the document is re-opened for further signatures, verification and signature extraction.
* Add VerifyOptions.Password. The DocMDP incremental update scope check now opens encrypted documents too; previously it was silently skipped.
* Add encrypted test files, created from testfile17.pdf with password “pdfsign”: AES-128 R4 (qpdf), AES-256 R5 (pdfcpu, xref streams), AES-256 R6 (qpdf) and RC4 R3 (qpdf), the last one with its own owner password.

Requires github.com/digitorus/pdf with Reader.Encrypt and R6 support, which
is digitorus/pdf#5. Until that is released the replace in go.mod builds
against the commit the pull request was opened from.
nascuite added a commit to nascuite/pdfsign that referenced this pull request Sep 22, 2026
The encryption API (`Reader.Encrypt`, `IsEncrypted`, `Trailer`, `Ptr`) and R6 support from digitorus/pdf#5 are released in v0.3.0, so the temporary `replace` pointing to the fork is no longer needed.
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.

2 participants