iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

KDFs (HKDF / PBKDF2 / scrypt)

A Key Derivation Function turns a password (low entropy, user-typed) or a key (raw bytes) into one or more cryptographic keys. Argon2id and scrypt are the modern password KDFs; HKDF is the right tool for deriving sub-keys from an existing high-entropy secret.

Argon2id, scrypt, HKDF, parameters

EXAMPLE
// SCENARIO — password verification + per-purpose subkey derivation.

// ─── Password KDF — Argon2id (recommended) ─────────────────────

// Node — argon2 package wraps libargon2
// npm i argon2
import argon2 from 'argon2';

const HASH_OPTIONS = {
    type:         argon2.argon2id,
    memoryCost:   1 << 16,    // 64 MB — OWASP 2024 baseline
    timeCost:     3,           // 3 iterations
    parallelism:  1,
    hashLength:   32,          // 32 bytes of output
};

// Hash on signup
const hash = await argon2.hash(password, HASH_OPTIONS);
// store hash as a string — already contains salt + params + variant identifier

// Verify on login
const ok = await argon2.verify(hashFromDb, password);

// Tune params: aim for ~250 ms per hash on your prod hardware.
// If signup latency is OK and login is OK, you have enough work factor.
// Increase memoryCost faster than timeCost — memory hardness > sheer CPU.

// Upgrade-on-login pattern
if (ok && argon2.needsRehash(hashFromDb, HASH_OPTIONS)) {
    const rehashed = await argon2.hash(password, HASH_OPTIONS);
    await db.user.update({ where: { id }, data: { passwordHash: rehashed } });
}

// ─── Password KDF — scrypt (Node built-in alternative) ──────────

import crypto from 'node:crypto';

function scryptHash(password) {
    return new Promise((resolve, reject) => {
        const salt = crypto.randomBytes(16);
        crypto.scrypt(password, salt, 32, { N: 2 ** 16, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }, (err, key) => {
            if (err) reject(err);
            resolve(`scrypt$N=65536,r=8,p=1$${salt.toString('base64')}$${key.toString('base64')}`);
        });
    });
}

function scryptVerify(stored, password) {
    return new Promise((resolve, reject) => {
        const [, params, saltB64, keyB64] = stored.split('$');
        const opts = Object.fromEntries(params.split(',').map((p) => {
            const [k, v] = p.split('=');
            return [k, parseInt(v, 10)];
        }));
        const salt = Buffer.from(saltB64, 'base64');
        const key  = Buffer.from(keyB64,  'base64');
        crypto.scrypt(password, salt, key.length, { ...opts, maxmem: 128 * 1024 * 1024 }, (err, derived) => {
            if (err) return reject(err);
            resolve(crypto.timingSafeEqual(derived, key));
        });
    });
}

// ─── Password KDF — PBKDF2 (legacy / FIPS) ─────────────────────

// Use only when Argon2id or scrypt aren't allowed (FIPS environments).
// OWASP 2024: 600,000 iterations for SHA-256, 210,000 for SHA-512.

const salt = crypto.randomBytes(16);
const hashBuf = crypto.pbkdf2Sync(password, salt, 600_000, 32, 'sha256');

// ─── HKDF — derive sub-keys from an existing high-entropy secret ─

// Use HKDF when you ALREADY have key material (a session key, an HSM-derived
// secret, an exchanged ECDH shared secret) and want to derive labelled sub-keys.
// HKDF is NOT a password KDF — it's fast on purpose. Don't feed it a password.

import { hkdfSync } from 'node:crypto';

const masterKey = Buffer.from(process.env.MASTER_KEY_B64, 'base64'); // 32+ bytes

function subkey(label, length = 32) {
    return Buffer.from(hkdfSync('sha256', masterKey, Buffer.alloc(0), Buffer.from(label, 'utf8'), length));
}

const encryptKey = subkey('aead:encrypt:v1');
const macKey     = subkey('mac:v1');
const cookieKey  = subkey('cookie:v1');

// Why HKDF: a single master key spawns many independent keys, none of which
// reveal the others — and rotating the master invalidates everything atomically.

// ─── Per-user / per-tenant key derivation ───────────────────────

function tenantKey(tenantId) {
    const salt = Buffer.from(tenantId, 'utf8');
    const info = Buffer.from('tenant-encryption:v1', 'utf8');
    return Buffer.from(hkdfSync('sha256', masterKey, salt, info, 32));
}

// Encrypt with AES-GCM using the tenant key
import { createCipheriv, randomBytes } from 'node:crypto';
function encrypt(tenantId, plaintext) {
    const iv     = randomBytes(12);
    const cipher = createCipheriv('aes-256-gcm', tenantKey(tenantId), iv);
    const ct     = Buffer.concat([cipher.update(plaintext), cipher.final()]);
    return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}

// ─── Parameter selection cheat sheet (2024 baseline) ────────────

//   Argon2id:    m = 64 MB,  t = 3,  p = 1                — bump m up first
//   scrypt:      N = 2^16,   r = 8,  p = 1                — N quadratic in time
//   bcrypt:      cost = 12-14                              — old but still acceptable
//   PBKDF2:      iters = 600_000 (SHA-256)                — only when forced
//   HKDF:        SHA-256, salt = per-purpose label or random per-record

// ─── Storage format ────────────────────────────────────────────

// argon2 .hash() output already encodes algorithm + params + salt + tag.
// For your own format, store: $algo$params$saltB64$hashB64
// Never store: just the hex of the hash with no salt or params.
// Migrate by storing the algo+params so upgrades roll forward cleanly.

// ─── Login flow checklist ──────────────────────────────────────

//   ✓ Compare with crypto.timingSafeEqual / library verify (constant time)
//   ✓ Always run the KDF — even when the username is unknown
//   ✓ Rate-limit per IP + per account (different windows)
//   ✓ Lockout exponential backoff, not hard lockouts (denial-of-service vector)
//   ✓ Upgrade-on-login when params change
//   ✓ Log only the username + IP + outcome, never the password or its hash

// ─── Common bugs ───────────────────────────────────────────────

//   • Storing MD5 / SHA-1 / plain SHA-256 of password — not a KDF, instant break
//   • Using HKDF where Argon2id is needed — HKDF is fast on purpose
//   • Using Argon2id with default params on a tiny VM — login takes 3 s, OOMs the box
//   • Forgetting per-record salt — pre-computed rainbow-table attacks succeed
//   • Skipping KDF on unknown-username — leaks user existence via timing
//   • PBKDF2 with iters = 1000 — that's 2010 advice, ten times below modern guidance
//   • Returning the password hash in an API response — exposes the parameter strength to attackers

Why it matters

Use Argon2id (or scrypt) for passwords with parameters tuned to ~250 ms on prod hardware, and HKDF for deriving labelled sub-keys from an existing high-entropy secret — never the other way around. Always run the KDF even on unknown usernames to avoid timing leaks, and rehash on login when your parameter floor moves up.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// HKDF — derive multiple keys from one secret (HMAC-based).
// PBKDF2 — legacy password-based KDF; minimum 600k iters / SHA-256.
// scrypt — memory-hard; good when argon2 isn't available.
Try it Yourself »

Exercise

Modern HMAC-based key-derivation function.

Discussion

Loading…