Two keys, generated for two different jobs
"Generate KEK" creates a real 2048-bit RSA-OAEP key pair, marked wrapKey/unwrapKey only — standing in for a KMS's master key. "Generate DEK" creates a real AES-256-GCM key, marked extractable, standing in for the fast, local, per-object data key the envelope-encryption use case describes.
Notice the asymmetry in what gets displayed: this tool shows the DEK's raw bytes (labeled as something a real system would never expose) and the KEK's public key (harmless to show — public keys aren't secrets), but never the KEK's private key, in any form, at any point. That's not an inconsistency — it's the entire model. A DEK is meant to exist briefly outside the KMS boundary, wrapped; a KEK is meant to never leave it at all.
const kek = await crypto.subtle.generateKey(
{ name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
true,
["wrapKey", "unwrapKey"] // not ["encrypt", "decrypt"] — this key pair only ever wraps other keys
);
const dek = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// Only the public half is ever exported and displayed:
const kekPublicBytes = await crypto.subtle.exportKey("spki", kek.publicKey);
// kek.privateKey is never passed to exportKey() anywhere in this toolThe KEK's usages are wrapKey/unwrapKey specifically, not encrypt/decrypt — WebCrypto tracks this as a distinct capability, and a key created this way can't be used to directly encrypt data even by mistake. extractable: true on the KEK only governs whether the PUBLIC key can be exported here — WebCrypto still lets the private key be exported too if code asked for it, so the real guarantee is simply that this tool's own code never calls exportKey() on kek.privateKey, not that the API forbids it.
Wrapping: one Web Crypto call, not a manual RSA step
"Wrap DEK" calls crypto.subtle.wrapKey directly — it exports the DEK's raw bytes and RSA-OAEP-encrypts them in a single atomic operation, so the DEK's plaintext bytes never separately exist in a variable your own code could accidentally log or leak. The result is exactly the size the RSA & public-key module's arithmetic predicts: a 2048-bit modulus produces a 256-byte wrapped output, regardless of the 256-bit DEK's own size.
const wrapped = await crypto.subtle.wrapKey(
"raw", // export format used internally before encrypting
dek, // the CryptoKey being wrapped — its raw bytes are never returned to JS
kek.publicKey,
{ name: "RSA-OAEP" }
);
// wrapped is 256 bytes — RSA-OAEP's output size depends only on the KEK's
// modulus, never on the wrapped key's own sizewrapKey() is exportKey() and encrypt() fused into one call specifically so the exported plaintext key never exists as a separate, inspectable value in this tool's code — the body text's point made literal.
Encrypting data locally, and unwrapping to read it back
"Encrypt" runs ordinary AES-256-GCM with the plaintext DEK, exactly like the AES-GCM tool. "Unwrap & decrypt" reverses the whole path: crypto.subtle.unwrapKey RSA-OAEP-decrypts the wrapped bytes and reconstructs a usable AES-GCM CryptoKey in one step, which then decrypts the stored ciphertext — the KMS-equivalent key (the KEK) never touches the actual data at any point.
const unwrappedDek = await crypto.subtle.unwrapKey(
"raw",
wrappedBytes,
kek.privateKey,
{ name: "RSA-OAEP" }, // how to decrypt the wrapped bytes
{ name: "AES-GCM", length: 256 }, // what kind of key the decrypted bytes become
false,
["decrypt"]
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
unwrappedDek,
ciphertext
);unwrapKey() takes two algorithm objects — one for unwrapping the bytes, one describing the key those bytes become — which is why it can hand back a ready-to-use AES-GCM CryptoKey in a single call rather than raw bytes you'd have to importKey() yourself.
What tampering the wrapped DEK actually breaks
"Tamper with wrapped DEK" flips one byte of the wrapped bytes before unwrapping. RSA-OAEP's own structural checks (covered in the RSA-OAEP tool) fail on the corrupted decryption output, and unwrapKey throws outright — there's no partial or garbled key recovered, just a clean failure.
const bytes = wrappedBytes.slice();
if (tamper) bytes[0] = bytes[0] ^ 0xff;
try {
const unwrappedDek = await crypto.subtle.unwrapKey(
"raw", bytes, kek.privateKey,
{ name: "RSA-OAEP" }, { name: "AES-GCM", length: 256 }, false, ["decrypt"]
);
// success — proceeds to decrypt
} catch {
// OperationError — RSA-OAEP's structural checks failed inside unwrapKey();
// no key object is ever produced from corrupted wrapped bytes
}This is the same OAEP structural-check failure covered in the RSA-OAEP tool, just reached through wrapKey/unwrapKey instead of encrypt/decrypt directly — the underlying RSA-OAEP operation is identical either way.
The symmetric alternative: AES Key Wrap
The second panel wraps the same kind of AES key using AES-KW (RFC 3394) instead of RSA-OAEP — a completely different Web Crypto algorithm, requiring a shared symmetric wrapping key rather than a public/private pair. Watch the byte count: a 256-bit (32-byte) key wrapped this way always comes out exactly 40 bytes, the fixed 8-byte integrity-check overhead the key-wrapping use case describes, verified live rather than just asserted.
const kw = await crypto.subtle.generateKey({ name: "AES-KW", length: 256 }, true, ["wrapKey", "unwrapKey"]);
const wrapped = await crypto.subtle.wrapKey("raw", dek, kw, "AES-KW");
// wrapped.byteLength is always 40 for a 32-byte key: no padding math to get
// wrong, unlike RSA-OAEP's modulus-dependent output size above
const unwrapped = await crypto.subtle.unwrapKey(
"raw", wrapped, kw, "AES-KW", { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]
);"AES-KW" is passed as a bare string to wrapKey/unwrapKey here, not an object — WebCrypto accepts either form when there are no extra parameters to specify, which AES-KW doesn't need.