Skip to content

Add PDF 2.0 AES-256 password encryption#9

Merged
earonesty merged 9 commits into
mainfrom
agent/pdf-encryption
Jul 24, 2026
Merged

Add PDF 2.0 AES-256 password encryption#9
earonesty merged 9 commits into
mainfrom
agent/pdf-encryption

Conversation

@earonesty

@earonesty earonesty commented Jul 24, 2026

Copy link
Copy Markdown
Owner

What changed

  • adds PDF 2.0 Standard Security Handler revision 6 output with AES-256 for flowToPdf, renderToPdf, caller-owned savePdf, and streamFlow
  • supports separate user/owner passwords, Unicode SASLprep, viewer permissions, metadata encryption control, object streams, classic xref output, and fresh security material for every save
  • uses the portable Web Crypto API for AES-CBC and SHA-2, with no new runtime dependency or BoxPDF-owned cipher implementation
  • keeps encryption behind dynamic imports and marks the package side-effect-free; core-only bundles contain no encryption implementation
  • adds QPDF/Poppler interoperability verification, browser/Deno/Worker smoke coverage, encrypted memory benchmark modes, public docs, and CLI guidance
  • raises the minimum Node version from 18 to 20 because ordinary Node 18 ESM file execution does not reliably expose unflagged global Web Crypto

User impact

const bytes = await flowToPdf(build, {
  encryption: {
    password: "open me",
    ownerPassword: "admin",
    permissions: { copying: false, modify: false }
  }
});

Calling pdf.save() remains unencrypted; caller-owned documents use savePdf(pdf, { encryption }).

Validation

  • pnpm test — 18 files, 195 tests
  • pnpm run typecheck
  • pnpm run build — ESM, CJS, and declarations
  • current Node and Node 20 ESM: all four output paths pass QPDF 10.6.3 R6/AESv3 checks, wrong-password rejection, and Poppler text extraction
  • Node 20 CommonJS encrypted-save smoke
  • Chrome 147 browser bundle smoke
  • Deno 2.9.4 packaged ESM smoke
  • Wrangler 4.114.0 / local workerd smoke
  • encrypted buffered and streaming memory benchmark modes at 10, 100, and 500 workload units
  • package dry-run includes all dynamic encryption chunks
  • core-only tree-shaken bundle contains no AESV3, SASLprep, or encryption runtime markers

The design document remains explicit that Acrobat/Firefox manual checks and final security review are release gates.

Summary by CodeRabbit

  • New Features
    • Added password-protected PDF output using PDF 2.0 Standard Security (R6) with AES-256.
    • Added encryption support across buffered, rendered, flow-based, and streamed generation (including streamFlow).
    • Added savePdf for saving caller-owned documents with optional password/owner password and configurable permissions and metadata encryption.
  • Documentation
    • Updated API and guides to explain encryption options, password rules, limitations, and streaming usage; clarified that pdf.save() remains unencrypted.
    • Documented the Node.js requirement change to version 20+.
  • Chores
    • Ignored Wrangler output artifacts in Git.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@earonesty, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d691da0b-4078-4fd2-bf9e-ab1d417b6d2a

📥 Commits

Reviewing files that changed from the base of the PR and between 207bc82 and 18f5d23.

📒 Files selected for processing (1)
  • package.json
📝 Walkthrough

Walkthrough

Adds PDF 2.0 revision 6 AES-256 password encryption for buffered and streamed output, including cryptographic primitives, R6 material generation, encrypted serialization, public API integration, validation scripts, benchmarks, runtime documentation, and Node 20 support.

Changes

PDF encryption

Layer / File(s) Summary
Encryption contracts and design
docs/design/encryption.md, src/encryption/types.ts
Defines encryption options, permissions, error codes, lifecycle rules, wire-format requirements, and validation criteria.
Password preparation and R6 material
src/encryption/{web-crypto,saslprep,r6,permissions}.ts, scripts/generate-saslprep-tables.py, test/encryption/core.test.ts
Adds Web Crypto operations, SASLprep processing, permission encoding, R6 derivation, generated Unicode tables, and cryptographic tests.
Encrypted object serialization and writers
src/encryption/{serialize,writer}.ts, test/encryption/writer.test.ts
Adds encrypted object handling, preflight checks, metadata behavior, object-stream/classic writers, trailer updates, and cleanup.
Save, document, and stream integration
src/{save,document,stream,index,cli}.ts, test/stream.test.ts, README.md, package.json
Exposes encryption through save and rendering APIs, integrates streamed output, updates exports and documentation, and raises the Node requirement to 20+.
Interoperability, runtime, and benchmark tooling
scripts/{verify-encryption,smoke-encryption-runtimes,bench-memory,bench-worker}.*, .gitignore
Adds external-tool fixture verification, runtime smoke checks, optional encrypted benchmarks, and a Wrangler ignore rule.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • earonesty/boxpdf#3: Reshaped the flowToPdf API later extended here with encrypted save options.

Poem

I’m a rabbit with a password key,
Hopping through bytes securely.
AES blooms where pages sleep,
Fresh little secrets guard the heap.
R6 carrots, crunchy and bright—
Encrypted PDFs take flight!

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding PDF 2.0 AES-256 password encryption.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No obvious security regressions: encrypted saves preflight signed/encrypted inputs, use WebCrypto, and cleanup removes Encrypt/ID state and zeroes file-key material.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/pdf-encryption

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/encryption/r6.ts (1)

74-91: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Generated owner credential bypasses preparePassword/SASLprep.

The design doc (docs/design/encryption.md lines 180-189) states the internally generated owner credential "is prepared like any other owner password," but here it's passed straight from generatedOwnerPassword into r6Hash/aesCbcEncryptNoPadding without going through preparePassword. Currently harmless (the base64url alphabet is untouched by SASLprep), but it silently diverges from the documented contract and would break invisibly if the alphabet/generation strategy ever changes.

♻️ Route the generated credential through preparePassword
   const ownerPassword = options.ownerPassword === undefined
-    ? generatedOwnerPassword(random)
+    ? preparePassword(new TextDecoder().decode(generatedOwnerPassword(random)), "ownerPassword")
     : preparePassword(options.ownerPassword, "ownerPassword");

As per docs/design/encryption.md: encoded as a standards-valid printable-ASCII password within the R6 byte limit... It is prepared like any other owner password, used only to derive O and OE, then discarded with the other ephemeral secret material

Also applies to: 111-114

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/encryption/r6.ts` around lines 74 - 91, Route the value returned by
generatedOwnerPassword through preparePassword before using it in the R6
owner-password derivation flow, including the corresponding path at the other
noted call site. Ensure the prepared bytes, rather than the raw generated
credential, are passed to r6Hash and aesCbcEncryptNoPadding, while preserving
cleanup of ephemeral secret material.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/encryption/r6.ts`:
- Around line 122-167: Update the finally block in the key-derivation function
to zeroize fileKey when the try block fails, while preserving it for successful
returns because the caller needs the returned key. Ensure the cleanup covers the
existing password and permissions buffers without changing the successful
encryption flow.

In `@src/encryption/writer.ts`:
- Around line 200-224: Update saveEncryptedPdf to honor updateFieldAppearances
before pdf.flush(), using pdf-lib’s existing appearance-update mechanism and the
destructured option (including its default true). Ensure field appearances are
updated before prepareEncryption and either encrypted writer path runs.
- Around line 161-224: Serialize encrypted saves per PDFDocument so only one
save can execute the prepare, write, and cleanup sequence at a time. Add a
per-document guard used by saveEncryptedPdf around prepareEncryption and the
writer calls, and release it in all completion and failure paths. Preserve the
existing cleanup behavior while ensuring concurrent calls cannot overlap trailer
mutation or encryption output.

In `@src/stream.ts`:
- Around line 106-119: Move the encryption setup in the stream flow, including
prepareEncryption and encryptedFrame assignment, inside the existing try/finally
cleanup scope so failures during preparation or pre-measurement always restore
the document state. Preserve normal encryption behavior, and add a regression
test confirming setup failure removes /Encrypt, its registered dictionary, and
replacement file IDs from the PDF.

---

Nitpick comments:
In `@src/encryption/r6.ts`:
- Around line 74-91: Route the value returned by generatedOwnerPassword through
preparePassword before using it in the R6 owner-password derivation flow,
including the corresponding path at the other noted call site. Ensure the
prepared bytes, rather than the raw generated credential, are passed to r6Hash
and aesCbcEncryptNoPadding, while preserving cleanup of ephemeral secret
material.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a51a4d13-16a1-4ca8-b63a-9fbb93d28310

📥 Commits

Reviewing files that changed from the base of the PR and between 3a921d0 and f103f87.

📒 Files selected for processing (25)
  • .gitignore
  • README.md
  • docs/design/encryption.md
  • package.json
  • scripts/bench-memory.ts
  • scripts/bench-worker.ts
  • scripts/generate-saslprep-tables.py
  • scripts/smoke-encryption-runtimes.ts
  • scripts/verify-encryption.mjs
  • src/cli.ts
  • src/document.ts
  • src/encryption/permissions.ts
  • src/encryption/r6.ts
  • src/encryption/saslprep-tables.ts
  • src/encryption/saslprep.ts
  • src/encryption/serialize.ts
  • src/encryption/types.ts
  • src/encryption/web-crypto.ts
  • src/encryption/writer.ts
  • src/index.ts
  • src/save.ts
  • src/stream.ts
  • test/encryption/core.test.ts
  • test/encryption/writer.test.ts
  • test/stream.test.ts

Comment thread src/encryption/r6.ts Outdated
Comment thread src/encryption/writer.ts
Comment thread src/encryption/writer.ts
Comment thread src/stream.ts Outdated
@earonesty

Copy link
Copy Markdown
Owner Author

Addressed the review in 207bc82:

  • zeroes the R6 file key on derivation failures and routes generated owner credentials through preparePassword
  • rejects overlapping encrypted serialization of the same PDFDocument with CONCURRENT_SAVE_UNSUPPORTED
  • honors default-true updateFieldAppearances before encrypted saves
  • keeps all streaming encryption setup inside the cleanup scope and verifies trailer/dictionary restoration on setup failure

Validation: 199 tests passed, tsc --noEmit passed, build passed, and git diff --check passed. The optional 80% docstring warning is not a repository-configured gate, so I did not add broad boilerplate docstrings unrelated to these findings; the public encryption APIs and design contract remain documented.

@earonesty
earonesty merged commit dd0c62b into main Jul 24, 2026
1 check passed
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.

1 participant