Derive keys
One high-entropy secret, several independent keys, each labelled with what it is for.
#The rule
Never use one secret for two purposes. Derive a key per purpose with hkdf, give each one a distinct info label, and a compromise of one does not touch the others.
import { hkdf, importHkdfKey } from "unsecure/hkdf";
const ikm = await importHkdfKey(process.env.ROOT_SECRET);
const cookieKey = await hkdf(ikm, { info: "myapp/session-cookie/v1" });
const csrfKey = await hkdf(ikm, { info: "myapp/csrf/v1" });
const idKey = await hkdf(ikm, { info: "myapp/record-id/v1" });importHkdfKey imports the material once into a non-extractable, deriveBits-only CryptoKey. Every derivation after that skips the import.
#Labelling
Give every info a namespace and a version.
// Ambiguous
await hkdf(ikm, { info: "key" });
// Specific, and rotatable
await hkdf(ikm, { info: "myapp/enc/v1" });Rotating a key becomes a label change: bump v1 to v2, keep the old label for reading old data, write new data with the new one. The root secret never has to move.
Two derivations with the same IKM, salt and info produce the same key. That is what makes derivation reproducible across processes, and it is also why the label has to be unique per use.
#Salt
salt is not secret, and RFC 5869 allows omitting it. Use it to bind a derivation to something specific: a session id, a tenant, a record id.
async function tenantKey(tenantId: string) {
return hkdf(ikm, { salt: tenantId, info: "myapp/tenant-data/v1", returnAs: "bytes" });
}#Splitting one derivation
One call can produce several keys at once. Ask for the total length and slice.
async function deriveSessionKeys(sharedSecret: Uint8Array, sessionId: string) {
const okm = await hkdf(sharedSecret, {
salt: sessionId,
info: "myapp/session-keys/v1",
length: 64,
});
return {
clientToServer: okm.slice(0, 32),
serverToClient: okm.slice(32, 64),
};
}Either shape is fine: one call sliced, or two calls with distinct labels. The second reads better; the first is one derivation instead of two.
#Feeding another primitive
Key material is bytes. When the IKM is a string, the default returnAs mirrors it and gives you hex text, which is almost never what you want next.
// hex text, then encoded again by accident
const key = await hkdf("root-secret", { info: "myapp/enc/v1" });
// bytes, ready for importKey
const key = await hkdf("root-secret", { info: "myapp/enc/v1", returnAs: "bytes" });
const aesKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt", "decrypt"]);Choose "base64url" only when the material is about to be stored or transported as text.
#Deriving from a password
HKDF has no work factor, so running it on a password is no stronger than one HMAC over that password. Pay for the guessing defence first with Argon2, then split its output.
import { argon2 } from "unsecure/argon2";
import { hkdf } from "unsecure/hkdf";
const stretched = await argon2(passphrase, salt, {
m: 65_536,
t: 3,
length: 32,
returnAs: "uint8array",
});
const encKey = await hkdf(stretched, { info: "myvault/enc/v1", returnAs: "bytes" });
const macKey = await hkdf(stretched, { info: "myvault/mac/v1", returnAs: "bytes" });Store the Argon2 salt and its parameters alongside the encrypted data. Without them the key cannot be re-derived.
#Limits
RFC 5869 caps one derivation at 255 * HashLen bytes: 8160 for SHA-256, 16320 for SHA-512. The bound is checked before Web Crypto is reached.
await hkdf("x", { length: 9000 });
// UnsecureError OUT_OF_RANGE: hkdf: length must be an integer between 1 and 8160, got 9000.If you need more material, derive several keys with distinct info values.