engineering

What happens when you password-protect a PDF

“Set a password” is one checkbox in most PDF tools, which makes it sound like one thing. It is not. The PDF specification has carried several incompatible encryption schemes over the years, one of them was withdrawn after publication, and picking the wrong one produces a file that some readers simply refuse to open. Here is what we ran into implementing it in a browser.

The revisions, briefly

PDF encryption is versioned by a number the file carries in its security dictionary, written /R. What matters in practice:

RevisionCipherStatus
R2 / R3RC4, 40–128 bitObsolete. Broken in the sense that matters: recovering the content is a solved problem.
R4RC4 or AES-128Legacy. Widely readable, no longer a reasonable choice for new files.
R5AES-256Withdrawn. Published by Adobe as an extension, then retracted — its key derivation was a single SHA-256 pass and fell to brute force far faster than intended. Some readers refuse R5 files.
R6AES-256Current. Standardised in ISO 32000-2 (PDF 2.0). Same cipher as R5, hardened key derivation.

The gap between R5 and R6 is entirely in how the password becomes a key. R5 hashes once. R6 runs Algorithm 2.B, which iterates at least 64 rounds, alternating hash functions and mixing in the previous round's output, with a termination condition that depends on the data itself so the round count cannot be predicted from the password. Same AES underneath; vastly more expensive to attack by guessing.

Why we had to write this ourselves

The library we use to write PDFs can produce AES-256 — but it chooses the revision from the PDF header string and writes R = 5 in that branch. That is the withdrawn one. We were producing files that were fine in some readers and rejected in others, which is the worst possible failure mode for a document you are about to email to someone.

Patching the library was the obvious fix and the wrong one: mousePDF has no build step, deliberately, and a patched vendor bundle is a maintenance liability that only reveals itself at the next upgrade. Instead the security handler is attached from the outside, through the library's own public interface. The bundle stays untouched.

An awkward constraint. The writer asks the security handler to encrypt each object synchronously. The browser's own crypto API, crypto.subtle, is asynchronous throughout — so it cannot be used for the encryption step at all. A crypto library was already inside the vendor bundle, but not exported; we checked all 321 exported names for anything matching crypt, hash, sha or aes, and none of them was reachable. That left writing AES itself: the encryption direction only, table-driven, synchronous.

The hashing is a different story. Key derivation happens once, up front, where asynchronous code is fine — so that part uses the browser's own implementation, as it should. Writing our own SHA-512 would have been code for its own sake.

The limitation we are not going to paper over

The writing library encrypts streams — page content, images, fonts. It does not encrypt strings sitting directly in dictionaries. In practice those strings end up inside object streams when the file is saved, and those streams are encrypted, so the content is protected. But it is protected as a consequence of how the file is assembled, not because each string was individually encrypted. We measured this rather than assumed it, and it is the same under R5 and R6 — the revision changes nothing here.

We mention this because “encrypted” is the kind of word people reasonably take literally. If your threat model is a lost laptop or a misdirected email, this is not a concern. If it is a determined adversary with the file and time, you want a container built for that job, not a PDF.

How you know it actually worked

There is no server to validate the output against, and a wrong implementation does not raise an error — it produces a file that opens nowhere. So the only test that means anything is opening the result in readers we did not write. Every change to this code path is checked against Acrobat, macOS Preview and several mobile readers before it ships. If the password is accepted in all of them and refused when wrong, it works.

What this means for you, practically

The specification, if you want the primary source, is ISO 32000-2 — algorithms 2.A and 2.B for key derivation, 8 and 9 for the user and owner entries, 10 for the permissions block. AES itself is FIPS-197.

Related: building a PDF editor with no server at all, and the password tool itself.

Open editor →