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.

Edit on GitHub

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 secret

A 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

Additional resources