Back to overview
Cryptography Oct 2024 8 min

AES Without the Magic: Implementation Notes from a Pure-JS Toolkit

  • JavaScript
  • Security
  • Node.js
View repository

Why I Kept Rewriting AES

This project started years ago as a teenage experiment that could only encrypt a single 128-bit block. It technically “worked”, but only in the toy case where the key and plaintext were exactly 16 bytes—no padding, no modes, no integrity, no streaming, just one block in and one block out. Coming back to that code as an adult, I wanted two things at the same time: understand AES well enough to trust my intuition when I read “AES-GCM” in a spec, not just nod along, and have a dependency-free, readable implementation I could embed in small tools where native crypto isn’t available.

The result, after two iterations, is a small AES toolkit written in modern JavaScript. It’s not meant to compete with OpenSSL or WebCrypto; it’s a controlled lab where I can inspect every moving part, break things on purpose and then fix them properly. This article is the technical walkthrough I wished I had when I started: enough internals to make AES feel like engineering, not folklore, and enough implementation detail to see how the pieces fit together in real code.


AES in One Mental Model

Formally, AES is a block cipher standardised by NIST in FIPS 197. It operates on 128-bit blocks and supports 128-, 192- and 256-bit keys. Internally, each block is seen as a 4×4 matrix of bytes (the State). Encryption runs this State through a fixed number of rounds:

  • SubBytes – non-linear substitution via an S-Box over GF(2⁸)
  • ShiftRows – rotate rows to move bytes across columns
  • MixColumns – mix each column using matrix multiplication over GF(2⁸)
  • AddRoundKey – XOR with a round key derived from the main key

The number of rounds depends on the key size (10, 12 or 14). The finite-field setting GF(2⁸) is what makes those steps reversible and uniform: addition is XOR, multiplication is polynomial arithmetic modulo a fixed irreducible polynomial. If you keep one picture in mind, it’s this: a 4×4 grid of bytes pushed over and over through the same pipeline of simple, reversible steps, under different round keys. Everything in my implementation is built around that model.


From One Block to a Toolkit

The first “grown-up” version of the project extended the single-block demo into something that could handle real messages: PKCS#7 padding, CBC mode, optional HMAC, key sizes up to 256 bits, Known-Answer Tests (KATs) from NIST and a basic timing breakdown. That was a good start, but it still had obvious limitations:

  • only ECB/CBC modes with nothing for streaming workloads
  • HMAC computed with the same key as AES
  • everything buffered in memory
  • tests focused mostly on KATs
  • a fallback to Math.random if a strong RNG wasn’t available

Over the last iteration I treated those as requirements, not annoyances, and refactored the repo into a small toolkit:

  • cipher.mjs – AES class, mode orchestration and helper exports (createAES, sha256, hmacSha256, pbkdf2Sha256, timingSafeEqualHex)
  • assets.mjs – S-Boxes, round constants, and GF(2⁸)/GHASH helpers for GCM
  • prototypeExtensions.mjs – text/hex conversion, PKCS#7 helpers and low-level primitives
  • test.mjs – KATs, regression tests, deterministic fuzzing, streaming round-trips and tampering checks

Everything is ES modules, runs on plain Node ≥ 18 and doesn’t pull any npm dependencies in.


Modes, Streaming and Where AES Actually Does Work

Encrypting a single block is boring; real data is messy. The current implementation supports ECB/CBC (mainly educational now, with CBC remaining useful for batch-style payloads), CTR/CFB/OFB/PCBC (modes that behave more like stream ciphers or offer different error-propagation profiles), and GCM (counter mode plus GHASH-based authentication, implemented in pure JS). A useful way to keep them straight is to think of them by role instead of acronym:

modebehaves likestreaming support in this toolkitintegrity built-intypical use in this project
ECBblock cipher demononodiagrams, tests, “don’t ship this”
CBCbatch encryptornonofixed-size payloads, legacy APIs
PCBCbatch with stronger error spreadnonoexperiments, education
CFBstream cipheryesnostreaming payloads
OFBstream cipheryesnostreaming payloads
CTRstream cipheryesnostreaming payloads, counters
GCMAEAD over CTRnot yet (buffered)yesauthenticated encryption with tags

The mapping between these modes and their formal definitions follows NIST’s SP 800-38A and SP 800-38D; the code mostly wraps those ideas in a more ergonomic JavaScript surface. On top of that there is a streaming API for CTR/CFB/OFB:

import { AES } from './cipher.mjs';

const key = '00112233445566778899aabbccddeeff';
const aes = new AES({ mode: 'CTR' });

const encStream = aes.createEncryptStream(key, { addHMAC: false });
const c1 = encStream.update('chunk-1');
const c2 = encStream.update('chunk-2');
const cFinal = encStream.final();

A few deliberate constraints fall out of this design:

  • streaming is only exposed where the mode naturally supports it (CTR/CFB/OFB)
  • CTR/GCM counters throw if they would exceed 2³² blocks instead of silently wrapping
  • GCM currently requires full buffers because GHASH is not yet incremental

This is the pattern that repeats throughout the project: use AES in ways that match the mode’s guarantees, not in ways that merely “work in tests”.


Keys, PBKDF2 and Why I Split Secrets

Early on I took the lazy route and used the same key for AES and HMAC. It kept the API simple but violated a basic hygiene rule: never reuse key material across primitives. The current design enforces separation: a single root key (or password) comes in, PBKDF2-HMAC-SHA256 derives a stretched key from that given a salt, that derived material is split into Kenc and Kmac, encryption uses Kenc and HMAC uses Kmac. Even when PBKDF2 is disabled and the user passes a raw key, the code expands a dedicated MAC key internally instead of reusing the AES key. HMAC is implemented with SHA-256, and there is an explicit option to turn it off (for GCM, which already authenticates).

Two practical consequences fall out of that:

  • config clarity – it’s harder to accidentally “just reuse the AES key for the MAC”
  • future flexibility – if I introduce GCM-SIV or other AEAD modes, they can plug into the same separation model

PBKDF2 itself is still synchronous and intentionally a bit annoying: high iteration counts will block the event loop. That’s a trade-off I accepted for now, and the roadmap includes an asynchronous variant that yields periodically to keep UIs responsive.


Input Handling, Padding and Metadata

The boring edges are where crypto code usually fails. A lot of work in this project went into “unexciting” topics: inputs — the library accepts UTF-8 strings, hex and Uint8Array values in predictable ways, and conversion is explicit instead of guessing types; padding — block modes use PKCS#7, while CFB and other streaming-style modes don’t require padding for trailing bytes; outputs — ciphertext, IVs, nonces, tags and HMACs are all hex strings, with no built-in Base64 for now so consumers can convert as needed; metadata — IVs/nonces and tags are returned alongside ciphertext and are meant to travel with it, treated as required parameters rather than secrets. All randomness (keys, IVs, salts) must either come from crypto.getRandomValues or from a user-supplied RNG; any fallback to Math.random is gone on purpose, and if the environment can’t provide secure entropy, the library forces that decision back onto the caller instead of pretending.


Testing: KATs, Fuzzing and Tampering

One lesson that became obvious after adding more modes and streaming is that KATs are necessary but not sufficient. The current test harness (test.mjs) combines:

  • official NIST KATs for ECB/CBC/CTR/CFB/OFB/GCM
  • deterministic fuzzing over random payloads, keys and options
  • streaming round-trips for CTR/CFB/OFB
  • explicit regression tests for missing IVs, odd-length hex inputs, wrong HMACs or tags, and PKCS#7 pad/unpad edge cases
  • a simple stress test over larger payloads

A future step I’d like to take is automatic cross-checks against WebCrypto when available: feed the same vectors into both implementations and assert that they match. That way any divergence is caught as soon as test vectors are updated, not months later in a bug report.


What Implementing AES Changed in How I Read “We Use AES”

The LinkedIn primer version of this article focused on where AES shows up: disk encryption toggles, “encrypted at rest” checkboxes, TLS offload, “secure cookies”, tokens, KMS-backed secrets. Working through my own implementation changed how I evaluate those claims. When I see “AES-GCM” in a config or design doc now, the questions I ask are much more concrete:

  • what role is AES playing here? Long-term storage, stateless tokens, one-off messages, streaming traffic?
  • which mode is actually used, and does it authenticate as well as encrypt? Plain CTR without a MAC is a different animal from GCM or ChaCha20-Poly1305.
  • how are IVs/nonces generated and stored? CBC needs unpredictable IVs; CTR/GCM require nonces that never repeat with the same key. Are we enforcing those constraints or relying on luck?
  • are keys segregated by purpose? One SECRET_KEY reused for HMAC, JWT signing, AES and CSRF tokens is a red flag.
  • what happens on failure? Does the system return distinct errors for “bad padding”, “bad MAC” and “bad key”, or does it fail uniformly?

These aren’t advanced cryptanalytic questions; they’re operational hygiene issues that implementing AES – and breaking my own code a few times – made impossible to ignore.


Limitations and Non-Goals

Being honest about what this library is not designed for is as important as listing features:

  • it is a didactic, pure-JS implementation, not a hardened, constant-time, side-channel-resistant library for high-sensitivity workloads.
  • GCM does not yet support streaming, so messages must fit in memory.
  • PBKDF2 is synchronous, and with large iteration counts you will block the event loop.
  • there is no opaque “key store” or KMS integration; key lifecycle (rotation, revocation, scoping) is deliberately left to the caller.
  • the API intentionally prefers explicit options and hex output over convenience, making the first contact slightly rougher, but once you understand what the library does, it’s harder to misuse it by accident.

In other words, this is a toolkit I’m comfortable depending on in my own utilities and experiments, not something I’d drop blindly into a bank’s production HSM cluster.


Roadmap: Where I Want to Take It

There is still plenty of room to grow, both for the code and for my understanding. The shortlist looks like this:

  • incremental GHASH – make GCM fully streaming by keeping a running GHASH accumulator instead of buffering everything.
  • asynchronous PBKDF2 – implement a cooperative version that yields back to the event loop after N iterations, so high iteration counts are affordable in browsers or CLIs.
  • cross-checks against WebCrypto – when crypto.subtle is present, automatically compare results against the native implementation in tests.
  • shared cipher base class – extract the AES-agnostic plumbing (State lifecycle, validation, streaming interface) so future schemes like XTS or GCM-SIV can reuse it cleanly.
  • safer ergonomics around keys and metadata – provide higher-level helpers for key rotation, key tags and IV/nonce management without hiding the underlying mechanisms.

Closing

Re-implementing AES twice – first to move from “one block” to “real messages”, then from “it works” to “I can trust it” – was less about reinventing the wheel and more about learning how the wheel is bolted to the rest of the car.

If you’ve read this far, the next step is not to copy-paste the code into production, but to treat it as a lab notebook: a place where a 4×4 grid of bytes, a handful of XORs and a few modes of operation show up as normal, inspectable engineering rather than magic.

Last updated on November 27, 2025.