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 cek

A 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:

OptionTypeDefaultWhat it does
algKeyManagementAlgorithmrequiredA*KW, A*GCMKW, RSA-OAEP*, ECDH-ES*, PBES2-* or dir.
p2s, p2cUint8Array, numberrequired for PBES2Salt of 8 or more bytes; count between 1000 and 600 000.
ivUint8ArrayrandomAES-GCMKW. For tests and known vectors only; never reuse one under the same key.
ecdh.enccontent algorithmnoneRequired for bare ECDH-ES, where the derived secret is the CEK.
ecdh.ephemeralKeykey, pair or private JWKgeneratedThe sender's one-time key, on the recipient's curve.
ecdh.partyUInfo, ecdh.partyVInfoUint8ArrayemptyAgreement info mixed into the derivation, at most 1024 bytes each.

unwrapKey:

OptionTypeDefaultWhat it does
algKeyManagementAlgorithmrequiredThe algorithm the key was wrapped with.
format"cryptokey" | "raw""cryptokey"Return a CryptoKey or the raw bytes.
iv, tagbase64url or bytesnoneRequired for AES-GCMKW.
p2s, p2cbase64url or bytes, numbernoneRequired for PBES2.
minIterations, maxIterationsnumber1000, 600_000Bounds on the incoming p2c.
epk, apu, apvJWK or CryptoKey, base64url or bytesnoneECDH-ES. epk is required; apu/apv at most 1024 bytes.
enccontent algorithmnoneRequired for bare ECDH-ES.
unwrappedKeyAlgorithm, keyUsage, extractableWeb Crypto paramsAES-GCM, ["encrypt", "decrypt"], trueHow 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.

algResult
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 equal
function 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

Causecode
Any unwrap that runs and failsERR_JWE_DECRYPTION_FAILED
Missing iv/tag, p2s/p2c or epk; an incoming p2c outside the bounds; an oversized apu/apvERR_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 keyLengthERR_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 keyLengthERR_INVALID_OPTION
A wrappedKey that is not bytes, or options that is not an objectERR_INVALID_OPTION
An unknown algERR_JWK_UNSUPPORTED

#See also