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";
You probably don't need these directly.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?)
ParameterTypeRole
algKeyManagementAlgorithm (including "dir")How to wrap.
keyToWrapCryptoKey | Uint8ArrayThe CEK to be wrapped.
wrappingKeyWrappingKeyFor<alg>The recipient's key (or password for PBES2).
optionsSee belowPer-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 familyReturns
"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).

examples.ts
// 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():

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:

manual-multi.ts
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