I receive PDF attachments from banks and other financial institutions every month that are password protected. I want to archive these financial statements, and what I do now is manually decrypt these and then save them onto iCloud Drive.
Essentially I convert these email attachments:

to this:

These passwords themselves are not hard to guess: first four letters of my name + date of birth in DDMM seems to be the most popular combo, so it seems like a bit of security theater. These passwords have a bunch of permutations though: some folks want it lowercase, for an LLP I manage, banks want date of incorporation instead of my date of birth etc. So it’s a little bit annoying to try out these combinations until I hit upon the correct password.

So I wanted to make a small command-line tool that can do this, something like:
decrypt-pdf /path/to/file.pdf
that can try out all of these password combinations and decrypt the file in place.
Nowadays, my choice of scripting language is Node, and I looked at how this can be done using a library, and there is a QPDF wrapper that seems to do exactly what I want. Unfortunately, that shells out to qpdf, and I wanted something with native bindings. There is also an excellent pure JS library, but that does not seem to support decryption.
Exploring Bun
I’ve been meaning to play with Bun for a while, mostly because it simplifies the JavaScript ecosystem quite a bit, a single executable that runs TypeScript, installs packages, runs tests, and also compiles down code is pretty cool.
Bun also has a decent FFI support, and I decided to use this to bind to qpdf’s C wrapper. I use Cursor nowadays to get started, and a prompt like this can do wonders:
I would like to use Bun's FFI wrapper to bind to QPDF bindings here:
@https://github.com/qpdf/qpdf/blob/main/include/qpdf/qpdf-c.h
Make a function called decrypt_pdf that can take a PDF input file path, password and output file path and decrypt it.
This doesn’t get us all the way there, but it does do a bunch of the tedious mapping work:
import { dlopen, FFIType, suffix } from "bun:ffi";
// Define the library name based on platform
const libName =
process.platform === "darwin"
? "/opt/homebrew/lib/libqpdf.dylib" // Mac OS path when installed via Homebrew
: `libqpdf.${suffix}`; // Other platforms
// Define the symbols we want to import
const { symbols: qpdf } = dlopen(libName, {
// Core functions we need
qpdf_init: {
returns: FFIType.ptr,
args: [],
},
qpdf_cleanup: {
returns: FFIType.void,
args: [FFIType.ptr],
},
qpdf_read: {
returns: FFIType.int,
args: [FFIType.ptr, FFIType.cstring, FFIType.cstring],
},
qpdf_init_write: {
returns: FFIType.int,
args: [FFIType.ptr, FFIType.cstring],
},
qpdf_write: {
returns: FFIType.int,
args: [FFIType.ptr],
},
qpdf_has_error: {
returns: FFIType.bool,
args: [FFIType.ptr],
},
qpdf_get_error: {
returns: FFIType.ptr,
args: [FFIType.ptr],
},
qpdf_get_error_full_text: {
returns: FFIType.cstring,
args: [FFIType.ptr, FFIType.ptr],
},
qpdf_set_preserve_encryption: {
returns: FFIType.void,
args: [FFIType.ptr, FFIType.bool],
},
});
This is a bit cumbersome to write, and before AI, you had to do these mappings yourself, but now it’s just a prompt away. Once you have this, the decrypt_pdf function pretty much writes itself:
function decryptPDF(inputPath: string, password: string, outputPath: string) {
const qpdfData = qpdf.qpdf_init();
if (!qpdfData) {
throw new Error("Failed to initialize QPDF");
}
// Read the encrypted PDF
const readResult = qpdf.qpdf_read(
qpdfData,
Buffer.from(inputPath + "\0"),
Buffer.from(password + "\0")
);
// Check for errors after read operation
if (readResult !== 0 || qpdf.qpdf_has_error(qpdfData)) {
const error = qpdf.qpdf_get_error(qpdfData);
const errorText = qpdf.qpdf_get_error_full_text(qpdfData, error);
throw new Error(`QPDF read error: ${errorText}`);
}
// Then initialize write operation
const writeInitResult = qpdf.qpdf_init_write(
qpdfData,
Buffer.from(outputPath + "-temp" + "\0")
);
if (writeInitResult !== 0) {
throw new Error("Failed to initialize write operation");
}
// Disable encryption in output
qpdf.qpdf_set_preserve_encryption(qpdfData, false);
// Write the decrypted PDF
const writeResult = qpdf.qpdf_write(qpdfData);
if (writeResult !== 0) {
throw new Error("Failed to write decrypted PDF");
}
console.log("PDF decrypted successfully!");
}
There are a few tricky bits here:
- Strings in C have to be null ended, hence the
Buffer.from(string + "\0")sprinkled in everywhere. - It’s important to have good error checks. The code looks a lot like Go to be honest with its
if (result !== 0)constructs. - This was a bit tricky to find, but
qpdf.qpdf_set_preserve_encryption(qpdfData, false);can only be called afterqpdf_init_write, otherwise we have a crash.
& finally, process the list of passwords:
// Run the next commands only if it's executed directly.
if (require.main === module) {
// New command-line handling
let filePath = process.argv[2];
if (!filePath) {
console.error("Usage: bun decrypt-pdf.ts <pdf-file>");
process.exit(1);
}
// Work around an iCloud bug where the path is incorrect when dropping into the terminal
filePath = filePath.replace("comappleCloudDocs", "com~apple~CloudDocs");
const passwords = ["VISH...", "PASSWORD2", "PASSWORD3", "password4"];
for (let password of passwords) {
// Example usage
try {
decryptPDF(filePath, password, filePath);
break;
} catch (error) {}
}
// Move the decrypted file to the original path
await $`mv ${filePath}-temp ${filePath}`;
}
This also illustrates another one of Bun’s cool features, the $ shell function.
You have to install qpdf manually, on Mac that’s:
brew install qpdf
and once you’ve done that, this should work:
bun decrypt-pdf.ts ./encrypted.pdf
It’s also pretty cool how easy it is to get a single binary:
bun build decrypt-pdf.ts --compile --outfile ./decrypt-pdf
and then you get the command you always wanted:
./decrypt-pdf /path/to/file.pdf
Leave a Reply