Building a PDF password tool that never uploads your file
This tool encrypts a PDF with AES-256 in the browser tab, with no server involved. The interesting part is not the encryption, which we did not write. It is everything around it: picking an engine, discovering that the engine drags a hard hosting requirement behind it, and three separate failure modes that all present as a spinner that never stops.
What it is built with
- qpdf 11, compiled to WebAssembly (qpdf-wasm), Apache-2.0
- Vite, for emitting the wasm and its glue as hashed assets
- pdf.js, used only in the test suite to verify the output
- Cloudflare Pages `_headers`, for per-route cross-origin isolation
Step 1: the library you already have cannot do it
Every other PDF tool on this site uses pdf-lib, which is excellent and is already in the bundle. It cannot encrypt. That is not an oversight in pdf-lib; PDF encryption is a large, security-sensitive corner of the specification, and implementing it is a different project from manipulating page objects.
So the first real decision was whether to implement it ourselves. The PDF standard fully specifies the algorithm, so it is possible. We did not, and the reasoning is worth stating because it generalises: implementing a cipher and its key-derivation for a security feature means writing cryptographic code, and a fully specified algorithm does not make hand-rolled crypto a reasonable thing to ship. If the choice is between a well-worn implementation and our own, it is not a close call.
That narrowed it to compiling an existing PDF tool. qpdf has been in use for two decades, does AES-256, and is Apache-2.0, which is compatible with this repository. A WebAssembly build already existed on npm.
Step 2: spike it before writing a single line of UI
The package we found was version 0.1.0, published once, by one maintainer, with a 75-byte README and no API documentation whatsoever. Everything we know about it came from reading the minified Emscripten glue and running it.
That is exactly the situation where writing the route, the copy and the tests first is a way to waste a day. The spike had one question: does this actually encrypt in a browser, and does a real reader then demand the password? Not "does it return without an error", which proves nothing about whether the output is protected.
The verification step is the one people skip. Searching the output bytes for `/Encrypt` would pass on a file that merely claims to be protected. The test that means something is to open the result with a real PDF reader and check that it refuses.
// A zero exit code proves qpdf ran. It says nothing about whether
// the output is encrypted. Ask a real reader instead.
const open = async (password?: string) => {
try {
const doc = await pdfjs.getDocument({ data: bytes, password }).promise;
const page = await doc.getPage(1);
const text = (await page.getTextContent()).items.map((i) => i.str).join('');
return { opened: true, text };
} catch (e) {
return { opened: false, why: (e as Error).name };
}
};
expect((await open()).opened).toBe(false); // PasswordException
expect((await open('wrong')).opened).toBe(false);
expect((await open(PASSWORD)).text).toContain(CANARY); // and it is the same doc
// And the canary must not survive in the clear anywhere in the file.
expect(Buffer.from(bytes).toString('latin1')).not.toContain(CANARY);
Step 4: design for the failure mode, which is a hang
Run qpdf in a page that is not isolated and it does not throw. `callMain` never returns and the promise never settles. A `SharedArrayBuffer transfer requires self.crossOriginIsolated` error surfaces as an unhandled worker error, and then nothing else happens, forever.
A promise that never settles is the worst possible way to tell somebody their browser cannot do something, and it is precisely what this site’s capability gate exists to prevent. So `crossOriginIsolated` is a hard required capability, checked before the file picker is even rendered, and the engine refuses rather than trusting the gate.
/**
* Refuse, loudly, in a context where qpdf would hang.
*
* The capability gate should have kept the user off this tool entirely, so
* reaching here means the gate was bypassed or the headers regressed. Either
* way a thrown error is the only honest outcome; the alternative is a spinner
* that never stops.
*/
function ensureIsolated(): void {
if (!globalThis.crossOriginIsolated) throw new PdfPasswordError('notIsolated');
}
Step 5: driving a CLI that thinks it is on a desktop
qpdf-wasm is not a library with an API. It is the qpdf command-line program, with an in-memory filesystem. You write your input to a fake path, call `main` with an argv array, and read the output back out.
The paths are fixed rather than derived from the user’s filename. Nothing outside the module can observe them, and a filename supplied by a user is the single string most likely to contain something an argument parser treats as an option.
One password is set as both the user and the owner password. PDF allows them to differ, and that distinction is the source of most confusion about protected PDFs: a file with only an owner password opens for anybody who double-clicks it, while still being reported as password protected by every tool that inspects it. Offering that as a choice would be offering people a way to misunderstand their own file.
export function buildEncryptArgv(password: string): string[] {
return [
'--encrypt',
`--user-password=${password}`,
`--owner-password=${password}`,
'--bits=256',
'--',
IN,
OUT,
];
}
// --bits=128 selects RC4, which qpdf refuses to write without
// --allow-weak-crypto. That flag is never passed, so asking for 128
// fails outright rather than quietly producing a weaker file.
Step 6: getting an error message out of it
To tell a wrong password from a corrupt file you need qpdf’s own diagnostics. Emscripten has a `printErr` option for exactly this. In this build it never fires; the messages go to `console.error` instead.
Shimming `console.error` around the `callMain` call looks like the obvious fix and captures nothing at all. The module reads `console.error` once, while it is initialising, and holds that reference for the rest of its life. The collector has to be installed before `init`, which is a strange-looking piece of code that deserves the comment it has.
// The module reads console.error once, here, and keeps it. Patching it
// around callMain instead would capture nothing, which is how every
// failure ends up reported as a generic one. The global is restored
// immediately; the module keeps the collector it was handed.
const real = console.error;
console.error = (...args: unknown[]) => {
stderr.push(args.map(String).join(' '));
real.apply(console, args);
};
try {
return await init({ noInitialRun: true, locateFile });
} finally {
console.error = real;
}
Step 7: testing an engine that will not load in Node
The glue reads `self.location.href` at module scope, so importing it in Node throws before you can do anything. There is no unit-testing the encryption.
The split we settled on is the one that split itself: everything pure is unit tested, and the encryption is covered by browser tests that assert through pdf.js. Argv construction, the 127-byte password limit, and the mapping from exit code and stderr to a user-facing reason are all ordinary functions over strings, and they carry twenty tests. The engine carries six browser tests, one of which exists only to assert that the page is cross-origin isolated, because if that goes red every other test in the file hangs for its full timeout and the reason would not be obvious.
What went wrong
The most useful part of any build write-up, and the part usually left out.
- The local preview server did not apply the `_headers` file at all, so no isolated route had ever actually been isolated in a test run. ffmpeg did not care because it is single-threaded; this tool hung in every browser test until the server was taught to send the headers production sends.
- `locateFile` is asked for two files, not one. It resolves the wasm, and it also resolves the script the pthread worker re-loads by URL. Returning the filename unchanged sent the worker to `/qpdf.js` at the site root, where it 404s, and the engine then hangs rather than reporting anything.
- An early benchmark said a 5.6 MiB file took 60 seconds. That was the test harness marshalling a 5.6-million-element array over the browser automation protocol, not qpdf, which takes 126 ms. Time the call inside the page, not around the automation.
- A password longer than 127 bytes is truncated by the standard rather than rejected. Accept one and you hand back a file that opens with a prefix of what the user typed and refuses the rest. The limit is in bytes, so an emoji is four of them.
- The existing test that asserted no `.wasm` file survived the build was really asserting that the oversized ffmpeg core had been compressed. It only meant the same thing while ffmpeg was the only engine. It now asserts the rule it meant, which is about size.
Use the tool: Password-protect a PDF · How YappyKit works