Cryptographic goals

Never invent cryptography

Use maintained high-level libraries and established constructions instead of designing algorithms, modes, padding, or message formats yourself.

Cryptographic code can look correct and still be broken. It compiles. It round-trips your test data. And it fails in ways you cannot see from the output.

This is the one field where “it works on my machine” means nothing. The attacks that matter exploit structure, timing, and error behavior. They don’t throw exceptions.

What going wrong looks like

Say we encrypt with AES-CBC and add a custom checksum. The design looks reasonable. But our code accepts modified ciphertext before it checks the checksum, and it returns a different error depending on what went wrong. That difference in errors is the basis of padding-oracle attacks, which recover plaintext without ever touching the key.

Another classic: picking ECB mode because it has the shortest name in the list.

// never do this
import { createCipheriv } from 'node:crypto'
const cipher = createCipheriv('aes-256-ecb', key, null)

ECB encrypts every 16-byte block on its own. Identical plaintext blocks produce identical ciphertext blocks. Patterns in your data survive encryption and stay visible to anyone reading the ciphertext.

Neither mistake is a coding error. Both are design errors. We chose primitives and assembled them ourselves, instead of choosing a construction someone already got right.

What to use instead

Prefer APIs that pick secure algorithms, generate nonces, authenticate the ciphertext, and define a versioned format. In practice that means an AEAD construction (authenticated encryption with associated data) such as AES-GCM or ChaCha20-Poly1305, exposed through a maintained library. Not a cipher mode you wired together yourself.

Follow the library’s current documentation and run its published test vectors. A test vector is a known input with its known correct output. If your integration produces those exact bytes, you know it matches the specification:

node --test crypto-wrapper.test.js
# ✔ matches NIST GCM test vector (0.8ms)

Keep the cryptographic boundary small. One module in your codebase owns encryption and decryption. Everything else calls it. When guidance changes, you have one file to update and one file to review.

One more habit I recommend from day one: store an explicit version with every ciphertext. A single byte prefix is enough. Even a high-level construction may need a format migration later, and a version field makes that migration possible without guessing.

The people who design these constructions are specialists, and even their designs get broken after years of review. Your job is to pick the right construction and use it exactly as documented. That is hard enough.

Try this on your own project: list every direct cryptographic call and map each one to its security goal. Replace or wrap one low-level composition with a maintained high-level API, and run the published test vectors against it. Then corrupt the ciphertext, the nonce, and the tag one at a time, and check that every case fails without returning any plaintext.

Lesson completed