Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 5 min read

Removing a PDF password entirely in the browser (RC4, AES-128 and AES-256)

I run a small set of PDF tools that work without uploading anything: every file is processed in the browser tab. Most tools were straightforward with pdf-lib and pdf.js. Removing a password was not. pdf-lib can open an

I run a small set of PDF tools that work without uploading anything: every file is processed in the browser tab. Most tools were straightforward with pdf-lib and pdf.js. Removing a password was not.

pdf-lib can open an encrypted PDF with ignoreEncryption: true, but it doesn't decrypt it. Every string and stream is still ciphertext, so saving it gives you a broken file. I ended up implementing the PDF Standard Security Handler myself. Here's what that involved and where I got stuck.

What "encrypted PDF" actually means

A password-protected PDF has an /Encrypt dictionary in its trailer. The important fields:

  • /V and /R: the algorithm version and revision. In practice R2 to R4 use RC4 or AES-128 with an MD5-based key, and R5/R6 use AES-256 with SHA-2.
  • /O and /U: hashes derived from the owner and user passwords.
  • /P: the permission flags (print, copy, and so on).
  • /CF, /StmF, /StrF (V4 and later): named "crypt filters" saying how streams and strings are encrypted. /Identity means "not encrypted at all".

So the job is: turn the password into the file key, check it against /U, then decrypt every string and stream in the document and delete /Encrypt.

Reading the dictionary looks like this:

const doc = await PDFDocument.load(bytes, {
  ignoreEncryption: true,
  updateMetadata: false,
});
const context = doc.context;

const encryptRaw = context.trailerInfo.Encrypt;
if (!encryptRaw) throw new Error("This PDF isn't password-protected.");

const v = (encryptDict.get(PDFName.of("V")) as PDFNumber | undefined)?.asNumber() ?? 0;
const r = (encryptDict.get(PDFName.of("R")) as PDFNumber | undefined)?.asNumber() ?? 2;

If /Filter isn't /Standard, the file is locked to a certificate rather than a password, and there's nothing a password can do. Better to say that plainly than show "wrong password".

Problem 1: Web Crypto has no MD5

Revisions 2 to 4 derive the key with MD5. That's required by the spec. It isn't being used as a secure hash here, it's just the algorithm the format was defined with in the 90s. But crypto.subtle.digest doesn't support MD5, and I didn't want a dependency for 80 lines of code, so there's a small RFC 1321 implementation in the project. RC4 is the same story: it's about 20 lines, so I wrote it.

The key check for R2 to R4 then becomes:

const tryKey = (padded: Uint8Array) => {
  const key = computeEncryptionKey(padded, oBytes, p, id0, revision, keyLengthBytes, encryptMetadata);
  return bytesEqual(computeU(key, id0, revision), uBytes, matchLength) ? key : null;
};

matchLength is 32 bytes for R2 and 16 for R3/R4, because later revisions only define the first 16 bytes of /U.

Each candidate password is tried as the user password and then as the owner password. The owner password lets you recover the user password from /O, so either one unlocks the file.

Problem 2: Web Crypto's AES-CBC always pads

R5/R6 (AES-256) uses SHA-256/384/512, which Web Crypto has. But the R6 key derivation (Algorithm 2.B in ISO 32000-2) runs up to 64+ rounds of unpadded AES-CBC, and crypto.subtle always applies PKCS#7 padding. There's no option to turn it off.

Encrypting is easy: let it pad, then throw away the extra block.

const out = await crypto.subtle.encrypt({ name: "AES-CBC", iv }, key, plaintext);
return new Uint8Array(out, 0, plaintext.length);

Decrypting is harder, because decrypt throws if the last block doesn't end in valid padding. The trick: build one extra ciphertext block that you know decrypts to a full block of padding (sixteen 0x10 bytes), and append it.

In CBC, each plaintext block is decrypt(C[i]) XOR C[i-1]. So if you encrypt sixteen 0x10 bytes using the real last ciphertext block as the IV, the result is a block that decrypts to perfect padding when it follows that last block:

const lastBlock = ciphertext.slice(ciphertext.length - 16);
const forged = await crypto.subtle.encrypt(
  { name: "AES-CBC", iv: lastBlock },
  key,
  new Uint8Array(16).fill(16)
);
const padded = new Uint8Array(ciphertext.length + 16);
padded.set(ciphertext);
padded.set(new Uint8Array(forged, 0, 16), ciphertext.length);

const out = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, key, padded);

Web Crypto strips exactly the block we added and returns the raw plaintext. No AES library needed.

Problem 3: people mistype passwords

This one isn't cryptography, but it mattered more than I expected. A lot of real encrypted PDFs are bank statements and tax documents with passwords like "first four letters of your name in capitals plus your date of birth". People paste them with a trailing space, or get the case wrong.

So every password is tried in a few forms:

function passwordCandidates(password: string): string[] {
  const trimmed = password.trim();
  return [
    ...new Set([password, trimmed, trimmed.toUpperCase(), trimmed.toLowerCase(), ""]),
  ];
}

The empty string at the end handles files that only have an owner password: they open without one, but restrict printing or copying.

For R2 to R4 each candidate is also tried in two encodings. The spec says PDFDocEncoding, but plenty of producers use UTF-8, so a password with accented characters can fail if you only try one.

Problem 4: the parts that aren't encrypted

Getting the key is half the work. The other half is not decrypting things that were never encrypted, because "decrypting" plaintext turns it into garbage:

  • /EncryptMetadata false: the XMP metadata stream is stored in clear, and this flag also changes the key derivation.
  • Identity crypt filters: a stream can opt out of encryption with a /Crypt filter named /Identity.
  • The /Encrypt dictionary itself, which is never encrypted.
  • Encrypted object streams: pdf-lib can't parse objects packed inside a compressed object stream when that stream is still encrypted, so those get decrypted and unpacked first.

Once those are skipped, a single walk over every indirect object does the job:

const decryptTransform = async (data, objNum, genNum, kind) => {
  switch (kind === "string" ? stringMethod : streamMethod) {
    case "none":   return data;
    case "rc4":    return rc4(deriveObjectKey(key, objNum, genNum, false), data);
    case "aes128": return aesCbcDecryptWithIv(deriveObjectKey(key, objNum, genNum, true), data);
    case "aes256": return aesCbcDecryptWithIv(key, data);
  }
};

await walkAndTransformDocument(context, decryptTransform, skipRefs);

context.trailerInfo.Encrypt = undefined;
if (encryptRef) context.delete(encryptRef);
const outBytes = await doc.save();

Note that RC4 and AES-128 use a per-object key (the file key mixed with the object and generation number), while AES-256 uses the file key directly.

Testing it

I generated 65 test PDFs covering every revision, key length and crypt-filter combination I could produce, with user passwords, owner passwords and EncryptMetadata false, and checked that every one decrypted correctly. Then I tried it on a real bank statement, which is the file people actually bring to a tool like this.

Takeaways

  • pdf-lib's ignoreEncryption means "don't refuse to load", not "decrypt".
  • Web Crypto covers SHA-2 and AES, but not MD5, RC4 or unpadded CBC. The last one can be worked around with a forged padding block.
  • The error messages matter. "Incorrect password", "this file isn't encrypted" and "this file needs a certificate" are three very different situations for the person on the other end.

If you want to see it working, the tool is at Handybin Remove PDF Password. It runs in your browser, so you can open DevTools and confirm nothing is uploaded.

Questions about the spec details are welcome in the comments.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.