What this is
Torinoa Tools is a solo side project — a collection of browser-only developer utilities. One of them is an AES Encrypt / Decrypt tool that does AES-GCM/AES-CBC encryption and decryption entirely in the browser.
No CryptoJS, no external crypto library — just the browser's native SubtleCrypto (Web Crypto API). Here's how it's built.
Why avoid a crypto library
- Keeps the tool's shipped bundle small
-
SubtleCryptois a native implementation — faster and more auditable than a pure-JS crypto library - Fewer dependencies, less to maintain
The tradeoff: SubtleCrypto is a low-level API. Deriving a key from a user-supplied passphrase, and managing salt/IV, is on you.
Deriving a key from a passphrase (PBKDF2)
Users type a passphrase, not a raw AES key, so the tool derives one with PBKDF2:
const PBKDF2_ITERATIONS = 100_000;
async function deriveKey(
passphrase: string,
salt: Uint8Array,
algorithm: AesAlgorithm,
keySize: AesKeySize,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(passphrase),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
baseKey,
{ name: algorithm, length: keySize },
false,
["encrypt", "decrypt"],
);
}
This is a two-step dance:
-
importKey("raw", ..., "PBKDF2", ...)treats the passphrase as PBKDF2 key material -
deriveKey(...)actually derives the AES key from it
100,000 SHA-256 iterations. Since everything runs client-side and shouldn't freeze the UI, that number is a deliberate tradeoff between security and how long the browser blocks on the calculation.
A self-contained format: salt and IV travel with the ciphertext
Reusing the same key+IV pair for AES is dangerous, so a fresh salt and IV are generated on every encryption. Since decryption needs both, they're packed into one byte array together with the ciphertext and Base64-encoded:
export async function aesEncrypt(
plaintext: string,
passphrase: string,
opts: AesOptions,
): Promise<AesEncryptResult> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(ivLengthFor(opts.algorithm)));
const key = await deriveKey(passphrase, salt, opts.algorithm, opts.keySize);
const cipherBuffer = await crypto.subtle.encrypt(
opts.algorithm === "AES-GCM"
? { name: "AES-GCM", iv }
: { name: "AES-CBC", iv },
key,
new TextEncoder().encode(plaintext),
);
const ciphertext = new Uint8Array(cipherBuffer);
const header = new Uint8Array([algoToByte(opts.algorithm), opts.keySize / 8]);
const combined = new Uint8Array(
HEADER_LENGTH + salt.length + iv.length + ciphertext.length,
);
combined.set(header, 0);
combined.set(salt, HEADER_LENGTH);
combined.set(iv, HEADER_LENGTH + salt.length);
combined.set(ciphertext, HEADER_LENGTH + salt.length + iv.length);
return { combined: uint8ToBase64(combined), /* ...hex breakdown for display */ };
}
The binary layout:
[algorithm byte] [keySize/8 byte] [salt (16 bytes)] [IV (12 or 16 bytes)] [ciphertext]
Encoding the algorithm and key size into the first two bytes means decryption can figure out AES-GCM vs. AES-CBC, and the 128/192/256-bit key size, straight from the ciphertext itself. So the person decrypting only ever needs to type the passphrase — no separate dropdown for algorithm or key size on the decrypt side. Small implementation detail, but it keeps the UI simpler.
Decryption gets tamper detection for free
export async function aesDecrypt(
combinedBase64: string,
passphrase: string,
): Promise<string> {
const combined = base64ToUint8(combinedBase64.trim());
const algorithm = byteToAlgo(combined[0]);
const keySize = (combined[1] * 8) as AesKeySize;
// ... slice out salt, iv, ciphertext
const key = await deriveKey(passphrase, salt, algorithm, keySize);
// Wrong passphrase or corrupted data both just throw here
// (AES-GCM's auth tag verification doubles as tamper detection)
const plainBuffer = await crypto.subtle.decrypt(
algorithm === "AES-GCM" ? { name: "AES-GCM", iv } : { name: "AES-CBC", iv },
key,
ciphertext,
);
return new TextDecoder().decode(plainBuffer);
}
AES-GCM is an AEAD cipher — it appends an authentication tag to the ciphertext. That means a wrong passphrase, corrupted data, or tampering all just cause crypto.subtle.decrypt to throw, with no custom integrity-check code needed. This is probably the single best argument for reaching for SubtleCrypto directly instead of a higher-level wrapper library: the primitives already do the hard part.
One caveat: not designed for interop
This format is intentionally self-contained — built so the tool can decrypt what it encrypted — not designed to be binary-compatible with external tools like OpenSSL. Pasting output from openssl enc -aes-256-cbc into this tool won't decrypt (the header format differs). It's built for one specific use case: encrypt and decrypt entirely in the browser, nothing more.
Wrap-up
The Web Crypto API is low-level enough that you have to think through key derivation and your own wire format, but in exchange you get fine-grained control over exactly what's happening, with fewer dependencies to trust. Hopefully useful if you're building something similar.
Try it here: https://tools.torinoa.com/tools/aes-encrypt-decrypt/
Top comments (0)