Derive encryption keys from passwords
A password is not an encryption key: it is short, low entropy, and the wrong size for AES. PBKDF2 stretches a password and a salt into a proper 256 bit key. This example derives an AES-GCM key from a password with the built-in Web Crypto API and uses it to encrypt and decrypt data.
The OWASP recommended cost for PBKDF2 with SHA-256.
const ITERATIONS = 600_000;Derive an AES-GCM key from a password and a salt. This differs from password storage, where you keep only a digest to compare against; here the derived key is used directly to encrypt and is never stored.
async function deriveKey(
password: string,
salt: Uint8Array<ArrayBuffer>
): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return await crypto.subtle.deriveKey(
{ hash: "SHA-256", iterations: ITERATIONS, name: "PBKDF2", salt },
material,
{ length: 256, name: "AES-GCM" },
false,
["encrypt", "decrypt"]
);
}Encrypt with a random salt and a random IV. Neither is a secret, but the IV must never repeat for the same key, and both are needed again to decrypt, so ship them alongside the ciphertext.
async function encrypt(password: string, plaintext: string) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const ciphertext = await crypto.subtle.encrypt(
{ iv, name: "AES-GCM" },
key,
new TextEncoder().encode(plaintext),
);
return { ciphertext, iv, salt };
}Decrypt by re-deriving the same key from the password and stored salt.
async function decrypt(
password: string,
{ ciphertext, iv, salt }: Awaited<ReturnType<typeof encrypt>>
): Promise<string> {
const key = await deriveKey(password, salt);
const plaintext = await crypto.subtle.decrypt(
{ iv, name: "AES-GCM" },
key,
ciphertext
);
return new TextDecoder().decode(plaintext);
}Round trip: encrypt with a password, decrypt with the same password.
const encrypted = await encrypt("correct horse battery staple", "top secret");
console.log(new Uint8Array(encrypted.ciphertext).toBase64()); // e.g. nD0OJq+JqHEm...
console.log(await decrypt("correct horse battery staple", encrypted)); // top secretA wrong password derives a different key, and AES-GCM authenticates the ciphertext, so decryption rejects it instead of returning garbage.
try {
await decrypt("wrong password", encrypted);
} catch (error) {
console.log((error as Error).name); // OperationError
}Run this example locally using the Ende CLI:
ende run https://docs.ende.sh/examples/scripts/derive_aes_key.ts