← Back to the tool

Symmetric-key · Under the hood

AES Key Wrap (RFC 3394) wrap & unwrap

What each button in the tool actually computes, step by step — with the real formulas.

Why a dedicated mode for keys

Encrypting a key is the same as encrypting any data, except for two things: the plaintext is already uniformly random, and the cost of a silent error is high, since a corrupted key decrypts everything wrongly. AES Key Wrap (NIST's design, published by the IETF as RFC 3394 in 2002 and standardized as KW in NIST SP 800-38F) uses those facts. It needs no IV or nonce, so it's deterministic, and it carries a 64-bit integrity check, so unwrapping with the wrong KEK or a modified blob fails outright instead of returning garbage.

It's JWE's A128KW / A256KW, CMS's AES key wrap (RFC 3565), and PKCS #11's CKM_AES_KEY_WRAP; its padded sibling KWP (RFC 5649) is the inner layer of the RSA-AES key import schemes that cloud KMSs and HSMs use. The KMS key wrapping use case covers where it sits.

Wrapping: 6·n AES operations

The key data is split into n 64-bit blocks R[1] … R[n], and a 64-bit register A starts at the fixed value A6A6A6A6A6A6A6A6. Each step encrypts the 128-bit block A ‖ R[i] under the KEK, puts the left half (XORed with a step counter t) back into A, and the right half into R[i]. Six passes over all n blocks give 6·n steps; the output is the final A followed by the final R blocks, 8 bytes longer than the input.

The counter t = n·j + i makes every step different, so identical key blocks don't produce identical intermediate values. The trace table shows A and R[i] after every step, and its last row's A is the first 8 bytes of the wrapped key.

The actual WebCrypto calls
const kek = await crypto.subtle.importKey("raw", kekBytes, { name: "AES-KW" }, false, ["wrapKey"]);

// wrapKey() wraps a CryptoKey, not bytes, so the key data is imported first.
// HMAC is the one key type Web Crypto imports at any length, which lets the
// tool wrap any multiple of 8 bytes, not only 16- or 32-byte AES keys.
const carrier = await crypto.subtle.importKey(
  "raw", keyDataBytes, { name: "HMAC", hash: "SHA-256" }, true, ["sign"]
);
const wrapped = await crypto.subtle.wrapKey("raw", carrier, kek, { name: "AES-KW" });

The algorithm of the carrier key doesn't affect the output: wrapKey('raw', …) exports the raw bytes and wraps them. In a real application you would wrap the actual AES or HMAC key you mean to move, with extractable set as narrowly as you can.

B=AESK(A ∥ R[i])A=MSB64(B)⊕tR[i]=LSB64(B),t=n⋅j+iB = \mathrm{AES}_K(A \,\|\, R[i]) \qquad A = \mathrm{MSB}_{64}(B) \oplus t \qquad R[i] = \mathrm{LSB}_{64}(B), \quad t = n \cdot j + i

Showing the steps: AES one block at a time

Web Crypto's AES-KW returns only the final result, so the trace is a second, from-scratch run of the same RFC 3394 algorithm. It needs raw single-block AES, which Web Crypto doesn't expose either; the tool gets it from AES-CBC with a zero IV, keeping the first 16 bytes. Decryption appends one extra block crafted to decrypt to a full PKCS#7 pad block, so subtle.decrypt() strips exactly that. The banner after wrapping confirms that the trace's output equals Web Crypto's, and the library is tested against all six RFC 3394 vectors and OpenSSL.

One AES block from AES-CBC
const aes = await crypto.subtle.importKey("raw", kekBytes, "AES-CBC", false, ["encrypt", "decrypt"]);

// AES_K(block): CBC with a zero IV encrypts block XOR 0 = block; drop the padding block
const out = await crypto.subtle.encrypt({ name: "AES-CBC", iv: new Uint8Array(16) }, aes, block);
const encrypted = new Uint8Array(out).slice(0, 16);

This is the same technique the TR-31 tool uses for AES-CMAC. It's only for display here: the wrapped key you copy comes from wrapKey().

Unwrapping and the integrity check

Unwrapping runs the steps in reverse with AES decryption: t counts down, each step decrypts (A ⊕ t) ‖ R[i], and at the end A must equal A6A6A6A6A6A6A6A6 again. If the KEK is wrong or any bit of the wrapped key changed, every intermediate value scrambles and A comes out as something else, with probability 1 − 2⁻⁶⁴. Web Crypto then rejects the promise and releases nothing; the trace shows the A value it got instead.

The check is an integrity check, not a MAC over arbitrary data: RFC 3394 is meant for wrapping keys, where the plaintext is high-entropy and short. For wrapping data of any length, RFC 5649 (KWP, with padding) extends it, and newer designs often use AES-GCM or AES-SIV instead.

The actual WebCrypto call
const kek = await crypto.subtle.importKey("raw", kekBytes, { name: "AES-KW" }, false, ["unwrapKey"]);
try {
  const key = await crypto.subtle.unwrapKey(
    "raw", wrappedBytes, kek, { name: "AES-KW" },
    { name: "HMAC", hash: "SHA-256" }, true, ["sign"]
  );
  const keyData = await crypto.subtle.exportKey("raw", key);
} catch {
  // integrity check failed: wrong KEK, or the wrapped bytes were changed
}

Unlike verify(), unwrapKey() throws on failure, the same way AES-GCM decrypt() does. Chromium-based browsers don't accept 192-bit AES keys in Web Crypto, so the RFC's AES-192 vectors (§4.2 and §4.4) only run in the unit tests.

Afinal=?A6A6A6A6A6A6A6A6A_{\text{final}} \stackrel{?}{=} \mathtt{A6A6A6A6A6A6A6A6}

Now try it

Back to AES Key Wrap (RFC 3394) wrap & unwrap →

Wrap a key under a key-encrypting key with real AES-KW, check the RFC 3394 answer, step through all 6·n AES operations, then flip one bit or use the wrong KEK and watch the integrity check fail.