Key wrapping
encrypt()/decrypt() wrap and unwrap the Content Encryption Key internally. When you need the building blocks — for custom hybrid protocols, custom JWE serializations, or interop with other systems — unjwt exposes them directly:
import { wrapKey, unwrapKey } from "unjwt/jwk";
encrypt() and decrypt() handle wrapping for every JWE algorithm. Reach for wrapKey/unwrapKey only when building a JSON Serialization variant by hand, implementing a custom cipher protocol, or testing at the primitive level.wrapKey
wrapKey(alg, keyToWrap, wrappingKey, options?)
| Parameter | Type | Role |
|---|---|---|
alg | KeyManagementAlgorithm (including "dir") | How to wrap. |
keyToWrap | CryptoKey | Uint8Array | The CEK to be wrapped. |
wrappingKey | WrappingKeyFor<alg> | The recipient's key (or password for PBES2). |
options | See below | Per-algorithm extras (IV, salt, ephemeral, etc.). |
WrappingKeyFor<alg> narrows the wrappingKey to the shape legal for the selected algorithm — e.g. WrappingKeyFor<"A128KW"> is CryptoKey | JWK_oct<"A128KW">, WrappingKeyFor<"RSA-OAEP-256"> is CryptoKey | JWK_RSA_Public<"RSA-OAEP-256">, and WrappingKeyFor<"PBES2-HS256+A128KW"> is string | Uint8Array | JWK_oct<"PBES2-HS256+A128KW">. AES-GCMKW additionally accepts the bare A*GCM counterpart to match the runtime aliasing rule. unwrapKey's unwrappingKey parameter uses the symmetric UnwrappingKeyFor<alg> with the _Private variants on the asymmetric branches.
The return shape depends on alg:
alg family | Returns |
|---|---|
"dir", "A*KW", "RSA-OAEP*" | { encryptedKey: Uint8Array } |
"PBES2-*" | { encryptedKey, p2s, p2c } |
"A*GCMKW" | { encryptedKey, iv, tag } |
"ECDH-ES" | { encryptedKey (empty), epk, apu?, apv? } |
"ECDH-ES+A*KW" | { encryptedKey, epk, apu?, apv? } |
For ECDH-ES direct (no key wrap), encryptedKey is an empty Uint8Array — the derived secret is the CEK, so there's nothing to ship (per RFC 7516 §4.6).
// AES Key Wrap
const { encryptedKey } = await wrapKey("A256KW", cek, aesKey);
// RSA-OAEP
const { encryptedKey: ek } = await wrapKey("RSA-OAEP-256", cek, rsaPublicJwk);
// ECDH-ES with key wrap
const { encryptedKey, epk } = await wrapKey("ECDH-ES+A256KW", cek, recipientPublicKey);
// ECDH-ES direct (no wrap — derived secret IS the CEK)
const { epk, apu, apv } = await wrapKey("ECDH-ES", rawCek, recipientPublicKey, {
ecdh: { enc: "A256GCM" },
});
Options
interface WrapKeyOptions {
iv?: Uint8Array; // AES-GCMKW
p2s?: Uint8Array; // PBES2 salt (defaults: 16 random bytes)
p2c?: number; // PBES2 iterations (default: 600_000)
ecdh?: {
ephemeralKey?: CryptoKey | JWK_EC_Private | CryptoKeyPair;
partyUInfo?: Uint8Array;
partyVInfo?: Uint8Array;
enc?: ContentEncryptionAlgorithm; // required for bare "ECDH-ES"
};
}
unwrapKey
unwrapKey(alg, wrappedKey, unwrappingKey, options?)
Reverses wrapKey. Returns a CryptoKey by default, or raw bytes with format: "raw":
// Returns CryptoKey (default)
const cek = await unwrapKey("A256KW", encryptedKey, aesKey);
// Returns Uint8Array
const raw = await unwrapKey("A256KW", encryptedKey, aesKey, { format: "raw" });
Format & CryptoKey import
When format: "cryptokey" (default), unjwt imports the unwrapped bytes as a Web Crypto CryptoKey:
const cek = await unwrapKey("A256KW", encryptedKey, aesKey, {
format: "cryptokey", // default
unwrappedKeyAlgorithm: { name: "AES-GCM", length: 256 },
keyUsage: ["encrypt", "decrypt"],
extractable: false,
});
When format: "raw", you get the Uint8Array and decide how to use it (pass to encrypt() as a dir key, pipe into another cipher, etc.).
PBES2 iteration bounds
unwrapKey enforces the same DoS-protection bounds as decrypt():
minIterations— default1000(RFC 7518 §4.8.1.2).maxIterations— default1_000_000.
Set explicitly if your deployment uses unusual values.
ECDH-ES inputs
For ECDH-ES* unwrap, pass the header fields that were in the original token:
const cek = await unwrapKey("ECDH-ES+A256KW", encryptedKey, myPrivateKey, {
epk: tokenHeader.epk,
apu: tokenHeader.apu, // base64url string or Uint8Array
apv: tokenHeader.apv,
enc: tokenHeader.enc, // required for bare "ECDH-ES"
});
Typical use — building a manual multi-recipient envelope
Before encryptMulti existed, this was the manual pattern for "one ciphertext, many recipients" — and it's still useful as a teaching example of how the spec composes:
import { wrapKey, unwrapKey } from "unjwt/jwk";
import { secureRandomBytes } from "unjwt/utils";
import { encrypt, decrypt } from "unjwt/jwe";
const enc = "A256GCM";
const cek = secureRandomBytes(32);
// 1. Encrypt payload once with the CEK
const ciphertext = await encrypt({ msg: "x" }, cek, { alg: "dir", enc });
// 2. Wrap the CEK per recipient
const wrapped = await Promise.all(
recipients.map(async ({ publicKey }) => {
const { encryptedKey, epk } = await wrapKey("ECDH-ES+A256KW", cek, publicKey);
return { encryptedKey, epk };
}),
);
// 3. Recipient unwraps their own entry, then decrypts
const mine = wrapped[myIndex];
const myCek = await unwrapKey("ECDH-ES+A256KW", mine.encryptedKey, myPrivateKey, {
format: "raw",
epk: mine.epk,
enc,
});
const { payload } = await decrypt(ciphertext, myCek);
In practice, use encryptMulti/decryptMulti instead — they produce a proper RFC 7516 §7.2 General JSON Serialization with all the envelope fields in the right places.
See also
- Multi-recipient → — the high-level API.
- ECDH-ES → — including
deriveSharedSecret. - JWE algorithms →.