wrapKey() / unwrapKey()
Encrypt one key under another, and get it back. This is the key management step inside every JWE, exposed for custom protocols.
import { generateJWK, wrapKey, unwrapKey } from "unjwt/jwk";
import { secureRandomBytes } from "unjwt/utils";
const cek = secureRandomBytes(32); // the key that will encrypt your data
const kek = await generateJWK("A256KW"); // the key that protects it
const { encryptedKey } = await wrapKey(cek, kek, { alg: "A256KW" });
// Uint8Array(40)
const back = await unwrapKey(encryptedKey, kek, { alg: "A256KW", format: "raw" });
// Uint8Array(32), the same bytes as cekA JWE encrypts its payload with a random content encryption key (CEK), then protects the CEK with the recipient's key. encrypt() and decrypt() do both steps for you. Call wrapKey and unwrapKey directly only when you build an envelope of your own, such as storing a per-record data key wrapped under a master key.
#Signatures
function wrapKey(
keyToWrap: CryptoKey | Uint8Array,
wrappingKey: CryptoKey | JWK | Uint8Array | string,
options: WrapKeyOptions,
): Promise<WrapKeyResult<typeof options.alg>>;
function unwrapKey(
wrappedKey: Uint8Array,
unwrappingKey: CryptoKey | JWK | Uint8Array | string,
options: UnwrapKeyOptions,
): Promise<CryptoKey | Uint8Array>;A string key is a PBES2 password and is refused under any other alg.
#Options
wrapKey:
| Option | Type | Default | What it does |
|---|---|---|---|
alg | KeyManagementAlgorithm | required | A*KW, A*GCMKW, RSA-OAEP*, ECDH-ES*, PBES2-* or dir. |
p2s, p2c | Uint8Array, number | required for PBES2 | Salt of 8 or more bytes; count between 1000 and 600 000. |
iv | Uint8Array | random | AES-GCMKW. For tests and known vectors only; never reuse one under the same key. |
ecdh.enc | content algorithm | none | Required for bare ECDH-ES, where the derived secret is the CEK. |
ecdh.ephemeralKey | key, pair or private JWK | generated | The sender's one-time key, on the recipient's curve. |
ecdh.partyUInfo, ecdh.partyVInfo | Uint8Array | empty | Agreement info mixed into the derivation, at most 1024 bytes each. |
unwrapKey:
| Option | Type | Default | What it does |
|---|---|---|---|
alg | KeyManagementAlgorithm | required | The algorithm the key was wrapped with. |
format | "cryptokey" | "raw" | "cryptokey" | Return a CryptoKey or the raw bytes. |
iv, tag | base64url or bytes | none | Required for AES-GCMKW. |
p2s, p2c | base64url or bytes, number | none | Required for PBES2. |
minIterations, maxIterations | number | 1000, 600_000 | Bounds on the incoming p2c. |
epk, apu, apv | JWK or CryptoKey, base64url or bytes | none | ECDH-ES. epk is required; apu/apv at most 1024 bytes. |
enc | content algorithm | none | Required for bare ECDH-ES. |
unwrappedKeyAlgorithm, keyUsage, extractable | Web Crypto params | AES-GCM, ["encrypt", "decrypt"], true | How the CEK is imported with format: "cryptokey". |
#Return shape
wrapKey's result depends on the algorithm. String members are base64url, the form they take in a JWE header.
alg | Result |
|---|---|
A*KW, RSA-OAEP* | { encryptedKey } |
dir | { encryptedKey }, always empty |
A*GCMKW | { encryptedKey, iv, tag } |
PBES2-* | { encryptedKey, p2s, p2c } |
ECDH-ES, ECDH-ES+A*KW | { encryptedKey, epk, apu?, apv? } |
unwrapKey returns a CryptoKey by default and a Uint8Array with format: "raw". A literal format narrows the type.
#Each algorithm
AES-KW wraps a CEK of 16 or more bytes in whole 8-byte blocks. The output is 8 bytes longer than the input.
AES-GCMKW encrypts the CEK and returns the iv and tag the unwrap needs back:
const kek = await generateJWK("A128GCMKW");
const wrapped = await wrapKey(cek, kek, { alg: "A128GCMKW" });
// { encryptedKey: Uint8Array(32), iv: "C4xH1LIgu0EXKCZt", tag: "MrEWY5UhBM8nkXJLkN_hkA" }
const { iv, tag } = wrapped;
await unwrapKey(wrapped.encryptedKey, kek, { alg: "A128GCMKW", iv, tag, format: "raw" });RSA-OAEP encrypts the CEK to the recipient's public key. Only the private key unwraps it.
const rsa = await generateJWK("RSA-OAEP-256");
const { encryptedKey } = await wrapKey(cek, rsa.publicKey, { alg: "RSA-OAEP-256" });
// Uint8Array(256)
await unwrapKey(encryptedKey, rsa.privateKey, { alg: "RSA-OAEP-256", format: "raw" });ECDH-ES+A*KW agrees on a key-wrapping key with the recipient's public key through a fresh ephemeral key, then wraps the CEK with AES-KW. The ephemeral public key travels as epk:
const ec = await generateJWK("ECDH-ES+A256KW");
const wrapped = await wrapKey(cek, ec.publicKey, { alg: "ECDH-ES+A256KW" });
// {
// encryptedKey: Uint8Array(40),
// epk: { kty: "EC", crv: "P-256", x: "53rm0OP0…", y: "mNcYq0yM…" },
// }
const { epk } = wrapped;
await unwrapKey(wrapped.encryptedKey, ec.privateKey, { alg: "ECDH-ES+A256KW", epk, format: "raw" });ECDH-ES (bare) wraps nothing. The agreed secret is the CEK itself, so encryptedKey is empty and both sides need enc to know its length. An X25519 recipient gets an OKP ephemeral key:
const x = await generateJWK("ECDH-ES", { namedCurve: "X25519" });
const agreed = await wrapKey(new Uint8Array(0), x.publicKey, {
alg: "ECDH-ES",
ecdh: { enc: "A256GCM", partyUInfo: new TextEncoder().encode("alice") },
});
// {
// encryptedKey: Uint8Array(0),
// epk: { kty: "OKP", crv: "X25519", x: "bxu2hvrz…" },
// apu: "YWxpY2U",
// }
const secret = await unwrapKey(agreed.encryptedKey, x.privateKey, {
alg: "ECDH-ES",
epk: agreed.epk,
apu: agreed.apu,
enc: "A256GCM",
format: "raw",
});
// Uint8Array(32)PBES2 wraps under a key derived from a password. wrapKey takes p2s and p2c explicitly; only encrypt() picks defaults for them. See Password-derived keys.
const wrapped = await wrapKey(cek, "correct horse battery staple", {
alg: "PBES2-HS256+A128KW",
p2s: secureRandomBytes(16),
p2c: 600_000,
});
// { encryptedKey: Uint8Array(40), p2c: 600000, p2s: "CobE_SWBITUz0ztyli2Y8w" }dir uses the key itself as the CEK. wrapKey returns an empty encryptedKey.
#Failed unwraps say only that they failed
Every unwrap that runs and does not succeed throws the same ERR_JWE_DECRYPTION_FAILED with the message JWE decryption failed. A wrong key, a corrupted encryptedKey and a forged tag look identical, so an attacker learns nothing from the difference (RFC 7516 §11.5). Missing parameters are your mistake and say so:
await unwrapKey(encryptedKey, await generateJWK("A256KW"), { alg: "A256KW" });
// JWTError ERR_JWE_DECRYPTION_FAILED: JWE decryption failed.
await unwrapKey(wrapped.encryptedKey, kek, { alg: "A128GCMKW" });
// JWTError ERR_JWE_INVALID: AES-GCMKW requires 'iv' and 'tag' options for unwrapping#deriveSharedSecret()
The ECDH-ES derivation on its own: an ECDH agreement followed by the Concat KDF (RFC 7518 §4.6). Both sides derive the same bytes, each from its own private key and the other's public key.
import { deriveSharedSecret, generateJWK, generateKey } from "unjwt/jwk";
const recipient = await generateJWK("ECDH-ES+A256KW");
const ephemeral = await generateKey("ECDH-ES", { namedCurve: "P-256" });
const senderSide = await deriveSharedSecret(recipient.publicKey, ephemeral.privateKey, {
enc: "ECDH-ES+A256KW",
});
const recipientSide = await deriveSharedSecret(ephemeral.publicKey, recipient.privateKey, {
enc: "ECDH-ES+A256KW",
});
// Uint8Array(32), and both are equalfunction deriveSharedSecret(
publicKey: CryptoKey | JWK_EC_Public,
privateKey: CryptoKey | JWK_EC_Private,
options: {
enc: JWK_ECDH_ES | ContentEncryptionAlgorithm; // the KDF's AlgorithmID
keyLength?: number; // bits; required when enc is "ECDH-ES"
partyUInfo?: Uint8Array;
partyVInfo?: Uint8Array;
},
): Promise<Uint8Array>;The length follows enc: 256 bits for ECDH-ES+A256KW, 128 for A128GCM. With enc: "ECDH-ES" there is no length to infer, so keyLength is required, a positive multiple of 8 up to 8192.
JWK inputs are imported under enc when it is an ECDH-ES* name, and under "ECDH-ES" when it is a content algorithm. Because a JWK's alg is binding, a key generated for ECDH-ES+A256KW derives with enc: "ECDH-ES+A256KW" and is refused with enc: "A128GCM"; a key generated for "ECDH-ES" serves the content algorithms.
#Errors
| Cause | code |
|---|---|
| Any unwrap that runs and fails | ERR_JWE_DECRYPTION_FAILED |
Missing iv/tag, p2s/p2c or epk; an incoming p2c outside the bounds; an oversized apu/apv | ERR_JWE_INVALID |
A string key under any alg but PBES2; a private key to wrapKey or a public one to unwrapKey; bare ECDH-ES without ecdh.enc; deriveSharedSecret with "ECDH-ES" and no keyLength | ERR_JWK_INVALID |
A CEK AES-KW cannot wrap; p2s under 8 bytes, p2c outside 1000 to 600 000 or a partyUInfo/partyVInfo over 1024 bytes on wrap; a malformed keyLength | ERR_INVALID_OPTION |
A wrappedKey that is not bytes, or options that is not an object | ERR_INVALID_OPTION |
An unknown alg | ERR_JWK_UNSUPPORTED |
#See also
- JWE algorithms for when to choose each key management algorithm.
- Multi-recipient JWE, which wraps one CEK for many recipients.