deriveJWKFromPassword() / deriveKeyFromPassword()

Turn a password into an AES key-wrapping key with PBES2, so you can run the slow derivation once and reuse the result.

import { deriveJWKFromPassword } from "unjwt/jwk";
import { secureRandomBytes } from "unjwt/utils";

const salt = secureRandomBytes(16); // store it next to whatever the key protects

const jwk = await deriveJWKFromPassword("correct horse battery staple", {
  alg: "PBES2-HS256+A128KW",
  salt,
  iterations: 600_000,
});
// {
//   alg: "A128KW",
//   ext: true,
//   k: "_gt-0w5HslIN0K0IUO8oFQ",
//   key_ops: ["wrapKey", "unwrapKey"],
//   kty: "oct",
//   kid: "p6xK5iH9-ZvVJ5L_DEoNRJlszQ7jRZ0O4ytiqxv91VE",
// }

A password is not a key. It is short, guessable and not random. PBES2 (RFC 7518 §4.8) runs it through PBKDF2 many times with a salt, which makes each guess expensive, and the output is an AES Key Wrap key. The same password, salt and count always give the same key.

#Signatures

function deriveJWKFromPassword(
  password: string | Uint8Array,
  options: {
    "alg": "PBES2-HS256+A128KW" | "PBES2-HS384+A192KW" | "PBES2-HS512+A256KW";
    "salt": string | Uint8Array;
    "iterations": number;
    "keyUsage"?: KeyUsage[];
    "kid"?: string | false;
    // JWK metadata merged onto the result
    "use"?: string;
    "x5c"?: string[];
    "x5t"?: string;
    "x5t#S256"?: string;
    "x5u"?: string;
  },
): Promise<JWK_oct<"A128KW" | "A192KW" | "A256KW">>;

function deriveKeyFromPassword(
  password: string | Uint8Array,
  options: {
    alg: "PBES2-HS256+A128KW" | "PBES2-HS384+A192KW" | "PBES2-HS512+A256KW";
    salt: string | Uint8Array;
    iterations: number;
    keyUsage?: KeyUsage[];
    extractable?: boolean;
    toJWK?: boolean;
  },
): Promise<CryptoKey | JWK_oct>;

deriveKeyFromPassword returns a CryptoKey unless toJWK: true, and the type follows the option.

#Options

OptionTypeDefaultWhat it does
algPBES2 namerequiredPicks the PBKDF2 hash and the output key size. Any other value is ERR_JWK_UNSUPPORTED.
saltstring | Uint8ArrayrequiredAt least 8 bytes (RFC 7518 §4.8.1.1). A string is UTF-8 encoded and measured as bytes.
iterationsnumberrequiredAn integer of at least 1000 (RFC 7518 §4.8.1.2). There is no upper limit here.
keyUsageKeyUsage[]["wrapKey", "unwrapKey"]Usages of the derived key.
kidstring | falsethe RFC 7638 thumbprintderiveJWKFromPassword only. A string is set verbatim; false sets none.
use, x5c, …JWK metadatanonederiveJWKFromPassword only. alg, kty, key_ops, ext and key material are reserved (ERR_INVALID_OPTION).
extractablebooleanfalsederiveKeyFromPassword only. Ignored with toJWK.
toJWKbooleanfalsederiveKeyFromPassword only. Return a JWK_oct instead of a CryptoKey.

#The algorithms

algPBKDF2 hashResult algKey size
PBES2-HS256+A128KWSHA-256A128KW16 bytes
PBES2-HS384+A192KWSHA-384A192KW24 bytes
PBES2-HS512+A256KWSHA-512A256KW32 bytes

The derived JWK carries the inner AES-KW algorithm, because that is what the key is: an ordinary A128KW key. Passing it to encrypt() produces an A128KW token, and no PBKDF2 runs at encrypt or decrypt time:

import { encrypt, decrypt } from "unjwt/jwe";

const token = await encrypt({ sub: "u1" }, jwk);
// header: { kid: "p6xK5iH9-ZvVJ5L_DEoNRJlszQ7jRZ0O4ytiqxv91VE", alg: "A128KW", enc: "A256GCM", typ: "JWT" }

const { payload } = await decrypt(token, jwk); // { sub: "u1" }

#Floors, ceilings and defaults

The values below are the ones the library holds every PBES2 count to.

WhereFloorCeilingDefault
deriveJWKFromPassword / deriveKeyFromPassword iterations1000nonenone, you choose
encrypt() p2c1000600 000600 000
encrypt() p2s8 bytesnone16 random bytes
wrapKey() p2c1000600 000none, required
decrypt() / unwrapKey() incoming p2cminIterations, 1000maxIterations, 600 000none

The ceiling on decryption is a defence: a token's p2c is chosen by whoever made the token, and an attacker would choose a huge one to burn your CPU. decrypt() also caps the PBKDF2 work one call may spend across all attempts (2 400 000 iterations by default, the limits.pbes2Iterations option).

For a new deployment, 600 000 iterations with PBES2-HS256 matches the OWASP guidance for PBKDF2-HMAC-SHA256.

#When to use this, and when to pass the password to encrypt()

encrypt(payload, "password") and decrypt(token, "password") already do PBES2. They pick a fresh random salt per token, write it and the count into the header as p2s and p2c, and run PBKDF2 on both sides:

const token = await encrypt({ sub: "u1" }, "correct horse battery staple");
// header: { p2c: 600000, p2s: "DeSZVJt1RgmLLxbpyHJ5sA", alg: "PBES2-HS256+A128KW", enc: "A256GCM", typ: "JWT" }

Prefer that when tokens are rare, such as an encrypted export a person unlocks by typing a password. Each token gets its own salt, and nothing but the password needs storing.

Prefer deriveJWKFromPassword when the same password protects many tokens, such as a service secret configured as a passphrase. You pay PBKDF2 once at startup instead of on every call. The trade-off is that every token shares one derived key, so the salt and count become configuration you must keep stable: change either and the key changes.

#Salts

The salt makes the same password derive different keys in different places, which defeats precomputed tables. Use random bytes from secureRandomBytes() of 16 bytes or more, and store the salt beside what it protects. It is not secret. A fixed string from configuration also works, and it still separates your deployment from everyone else's.

await deriveJWKFromPassword("pw", { alg: "PBES2-HS256+A128KW", salt: "abc", iterations: 600_000 });
// JWTError ERR_INVALID_OPTION: "salt" must be 8 or more octets (RFC 7518 §4.8.1.1).

Warning

PBKDF2 makes guessing slow. It does not make a weak password strong. A short or common password falls to a dictionary attack at any iteration count.

#Errors

Causecode
An alg outside the PBES2 familyERR_JWK_UNSUPPORTED
A salt under 8 bytesERR_INVALID_OPTION
iterations below 1000, fractional, or not a numberERR_INVALID_OPTION
A password that is neither a string nor bytes, or options that is not an objectERR_INVALID_OPTION
A kid that is neither a string nor false, or a reserved JWK memberERR_INVALID_OPTION

#See also