Skip to main content

Command Palette

Search for a command to run...

What Building a File Encryptor in Rust Taught Me About Authenticated Encryption

Updated
โ€ข6 min readโ€ขView as Markdown
R
Cybersecurity graduate focused into privacy engineering and IAM. I write about the projects I build - what worked, what broke, and what I'd do differently. ๐Ÿ‘พ

What this does

A command-line tool that encrypts a single file with a password. Under the hood: Argon2id turns your password into a 256-bit key, AES-256-GCM encrypts the file with that key, and everything needed to decrypt later (a random salt and nonce, plus the ciphertext) is packed into one self-contained output file.

Why I built it

I wanted hands-on experience with memory-safe systems programming applied to something that actually matters. Cryptographic code is exactly where memory bugs โ€” buffer overflows, use-after-free, secrets leaking from uninitialized memory โ€” do the most damage. Rust's ownership model is supposed to make entire classes of these bugs unrepresentable. I wanted to find out what that guarantee actually feels like while building something real, not a toy syntax exercise, and to come out the other side able to explain every cryptographic choice, not just the code that makes it work.

How it works โ€” design decisions

Argon2id over SHA-256 or bcrypt

SHA-256 is fast โ€” great for hashing files, terrible for passwords, since attackers can brute-force billions of guesses per second on GPUs. bcrypt is deliberately slow but memory-fixed (a few KB), so custom hardware (FPGAs/ASICs) can still parallelize attacks cheaply. Argon2id is memory-hard (19 MiB in this project, tunable) โ€” a parallel attacker needs proportionally more RAM per attempt, and RAM is expensive to scale the way raw compute isn't. The "id" variant hybridizes Argon2i (side-channel resistant) and Argon2d (GPU resistant) for both properties, which is why OWASP recommends it as the default choice.

AES-GCM over AES-CBC

CBC gives confidentiality only, no integrity โ€” it's malleable and historically vulnerable to padding oracle attacks (real precedent: POODLE, the ASP.NET padding oracle attack). GCM is an AEAD cipher: confidentiality plus a 16-byte authentication tag. Tampered ciphertext fails decryption outright instead of silently returning corrupted plaintext.

Nonce reuse under the same key is catastrophic: encrypting two messages with the same (key, nonce) generates the same keystream both times. XORing the two resulting ciphertexts cancels the keystream and exposes the XOR of the plaintexts โ€” and can leak the authentication key itself, letting an attacker forge valid-looking ciphertexts. Always use a fresh random 96-bit nonce from the OS CSPRNG, never a counter or anything password-derived.

Self-contained output file over separate salt/nonce storage

The salt and nonce aren't secret โ€” their security value is uniqueness, not secrecy โ€” so nothing is lost by storing them in plaintext alongside the ciphertext. One file means one lifecycle; separate files can get lost, renamed, or separated independently, making decryption impossible even with the correct password. Fixed-length fields (16-byte salt, 12-byte nonce) mean decryption is just fixed-offset slicing โ€” no parser, no format-version field, minimal surface area for bugs.

Password entry: interactive prompt, not a CLI argument

A --password flag would land in shell history and be visible to other processes via ps/Task Manager โ€” a real, practical leak. Using rpassword to read from stdin with terminal echo disabled avoids both.

Zeroizing secrets in memory, not just relying on drop

When a String or byte array holding a password or derived key goes out of scope, Rust deallocates the memory โ€” but deallocation doesn't overwrite the bytes. The old secret can sit in freed memory until something else happens to reuse that address, recoverable via a crash dump, a swapped-out memory page, or a debugger attached to the running process.

A manual "zero it out in a loop before it drops" approach doesn't reliably work either: the compiler's optimizer is allowed to notice that a write to memory that's about to be freed and never read again has no observable effect, and can legally delete the loop entirely. The zeroize crate solves this with a volatile write โ€” a write the compiler is specifically forbidden from optimizing away โ€” and its Zeroizing<T> wrapper triggers that write automatically on drop, including on early returns via ?. Wrapping the password and derived key in Zeroizing<T> required zero changes to the rest of the code, since it transparently derefs to the underlying type everywhere it's used.

One unified, deliberately vague error type

Every fallible operation โ€” file I/O, key derivation, encryption, file-format parsing โ€” converts into a single AppError enum so the CLI can use Rust's ? operator throughout instead of .unwrap(). The one subtle decision: the Cipher variant deliberately discards the real underlying error detail. Whether decryption failed because of a wrong password or a tampered/corrupted file is indistinguishable to the end user, on purpose โ€” that distinction is itself information an attacker could use to probe the system.

Bugs and gotchas hit while building

  • Toolchain mismatch. clap's newer releases require edition 2024, which needs Rust 1.85+. My local Rust was 1.75.0, many months stale. Fixed with rustup update stable. Lesson: trust rustc --version and update the toolchain โ€” don't pin old crate versions to route around an outdated compiler.

  • A misplaced Cargo.toml key. argon2 and rand were accidentally declared under [package] instead of [dependencies]. Cargo silently ignored them, producing "unresolved import" errors that looked exactly like a missing crate rather than a misplaced TOML key.

  • Stale IDE cache, not a real error. rust-analyzer reported an "unsupported metadata version" error right after a toolchain upgrade. It was leftover proc-macro cache from the old compiler โ€” cargo build had been succeeding the entire time. Lesson: cargo build/cargo run output is ground truth over editor squiggles.

  • A panic is not "failing cleanly." Decrypting with the wrong password initially crashed the whole process with a raw Rust panic and stack trace via .unwrap(). It didn't corrupt anything โ€” the panic happened before the output file was ever written โ€” but a stack trace isn't a clean user-facing failure. Fixing this was the actual point of Step 6: replacing every .unwrap() with a unified error type and ?.

  • main() -> Result<_, E> prints Debug, not Display. Rust's built-in support for a Result-returning main automatically prints the error โ€” but using the type's Debug implementation, not Display. My carefully worded, non-specific error message never appeared; instead I got the bare enum variant name (Error: Cipher). The fix was to handle the Result manually in main and print with {} instead of relying on the automatic path.

Concrete numbers worth remembering

  • A 14-byte plaintext test case produced a 30-byte ciphertext โ€” the 16-byte GCM authentication tag is constant overhead, independent of file size.

  • Full packed file layout for that test case: 16-byte salt + 12-byte nonce

    • 30-byte ciphertext = 58 bytes total.

What I'd improve

What I'd improve

  • Add integration tests (in a tests/ directory) that exercise the compiled binary end-to-end as a black box, rather than only unit-testing internal functions.

  • Support streaming large files instead of reading the whole file into memory, if this ever needed to scale beyond small files.

  • Consider a file-format version byte, so a future breaking change to the layout could be detected and handled gracefully instead of silently misinterpreting old files.

How to run it

Full setup, build, and usage instructions are in the GitHub repository.