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
| Option | Type | Default | What it does |
|---|---|---|---|
alg | PBES2 name | required | Picks the PBKDF2 hash and the output key size. Any other value is ERR_JWK_UNSUPPORTED. |
salt | string | Uint8Array | required | At least 8 bytes (RFC 7518 §4.8.1.1). A string is UTF-8 encoded and measured as bytes. |
iterations | number | required | An integer of at least 1000 (RFC 7518 §4.8.1.2). There is no upper limit here. |
keyUsage | KeyUsage[] | ["wrapKey", "unwrapKey"] | Usages of the derived key. |
kid | string | false | the RFC 7638 thumbprint | deriveJWKFromPassword only. A string is set verbatim; false sets none. |
use, x5c, … | JWK metadata | none | deriveJWKFromPassword only. alg, kty, key_ops, ext and key material are reserved (ERR_INVALID_OPTION). |
extractable | boolean | false | deriveKeyFromPassword only. Ignored with toJWK. |
toJWK | boolean | false | deriveKeyFromPassword only. Return a JWK_oct instead of a CryptoKey. |
#The algorithms
alg | PBKDF2 hash | Result alg | Key size |
|---|---|---|---|
PBES2-HS256+A128KW | SHA-256 | A128KW | 16 bytes |
PBES2-HS384+A192KW | SHA-384 | A192KW | 24 bytes |
PBES2-HS512+A256KW | SHA-512 | A256KW | 32 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.
| Where | Floor | Ceiling | Default |
|---|---|---|---|
deriveJWKFromPassword / deriveKeyFromPassword iterations | 1000 | none | none, you choose |
encrypt() p2c | 1000 | 600 000 | 600 000 |
encrypt() p2s | 8 bytes | none | 16 random bytes |
wrapKey() p2c | 1000 | 600 000 | none, required |
decrypt() / unwrapKey() incoming p2c | minIterations, 1000 | maxIterations, 600 000 | none |
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
| Cause | code |
|---|---|
An alg outside the PBES2 family | ERR_JWK_UNSUPPORTED |
| A salt under 8 bytes | ERR_INVALID_OPTION |
iterations below 1000, fractional, or not a number | ERR_INVALID_OPTION |
A password that is neither a string nor bytes, or options that is not an object | ERR_INVALID_OPTION |
A kid that is neither a string nor false, or a reserved JWK member | ERR_INVALID_OPTION |
#See also
- JWE algorithms for PBES2 next to the other key management choices.
- wrapKey() / unwrapKey() for PBES2 wrapping with explicit
p2sandp2c.