Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
d0493789cc fix(crypto): encrypt serialized CryptoKey material with per-process AES key
Previously, wrapSerializedCryptoKey/unwrapSerializedCryptoKey were no-ops
inherited from WebKit's OpenSSL port (WebKit bug #173883). This meant that
when CryptoKey objects were serialized via structuredClone or postMessage,
the private key material was stored in plaintext.

Implement actual AES key wrapping (RFC 5649) using a per-process 256-bit
random master key generated via RAND_bytes. This ensures serialized
CryptoKey data is encrypted in memory, protecting against accidental
exposure through logs, crash dumps, or storage of serialized data.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 04:47:39 +00:00
5 changed files with 199 additions and 46 deletions

View File

@@ -9,6 +9,10 @@
#include "EventLoopTask.h"
#include "BunBroadcastChannelRegistry.h"
#include <wtf/LazyRef.h>
#if ENABLE(WEB_CRYPTO)
#include "SerializedCryptoKeyWrap.h"
#endif
extern "C" void Bun__startLoop(us_loop_t* loop);
namespace WebCore {
@@ -396,4 +400,22 @@ extern "C" JSC::JSGlobalObject* ScriptExecutionContextIdentifier__getGlobalObjec
return context->globalObject();
}
#if ENABLE(WEB_CRYPTO)
bool ScriptExecutionContext::wrapCryptoKey(const Vector<uint8_t>& key, Vector<uint8_t>& wrappedKey)
{
auto masterKey = defaultWebCryptoMasterKey();
if (!masterKey)
return false;
return wrapSerializedCryptoKey(*masterKey, key, wrappedKey);
}
bool ScriptExecutionContext::unwrapCryptoKey(const Vector<uint8_t>& wrappedKey, Vector<uint8_t>& key)
{
auto masterKey = defaultWebCryptoMasterKey();
if (!masterKey)
return false;
return unwrapSerializedCryptoKey(*masterKey, wrappedKey, key);
}
#endif
} // namespace WebCore

View File

@@ -94,15 +94,11 @@ public:
// }
#if ENABLE(WEB_CRYPTO)
// These two methods are used when CryptoKeys are serialized into IndexedDB. As a side effect, it is also
// used for things that utilize the same structure clone algorithm, for example, message passing between
// worker and document.
// For now these will return false. In the future, we will want to implement these similar to how WorkerGlobalScope.cpp does.
// virtual bool wrapCryptoKey(const Vector<uint8_t>& key, Vector<uint8_t>& wrappedKey) = 0;
// virtual bool unwrapCryptoKey(const Vector<uint8_t>& wrappedKey, Vector<uint8_t>& key) = 0;
bool wrapCryptoKey(const Vector<uint8_t>& key, Vector<uint8_t>& wrappedKey) { return false; }
bool unwrapCryptoKey(const Vector<uint8_t>& wrappedKey, Vector<uint8_t>& key) { return false; }
// These two methods are used when CryptoKeys are serialized via the structured clone algorithm,
// including structuredClone(), postMessage to workers, and IndexedDB storage.
// They wrap/unwrap the serialized key material using a per-process AES master key.
bool wrapCryptoKey(const Vector<uint8_t>& key, Vector<uint8_t>& wrappedKey);
bool unwrapCryptoKey(const Vector<uint8_t>& wrappedKey, Vector<uint8_t>& key);
#endif
WEBCORE_EXPORT static bool postTaskTo(ScriptExecutionContextIdentifier identifier, Function<void(ScriptExecutionContext&)>&& task);

View File

@@ -1843,18 +1843,10 @@ private:
serializedKey, SerializationContext::Default, dummySharedBuffers, m_forStorage, m_forTransfer);
rawKeySerializer.write(key);
Vector<uint8_t> wrappedKey;
if (!wrapCryptoKey(m_lexicalGlobalObject, serializedKey, wrappedKey))
return false;
// Wrapping isn't required
// https://github.com/WebKit/WebKit/blob/c0902fc4dd3abf5d2d5e008eb0b008aeae837953/Source/WebCore/crypto/SerializedCryptoKeyWrap.h#L35-L40
//
// and doesn't do anything currently, so we skip it.
// https://github.com/WebKit/WebKit/blob/c0902fc4dd3abf5d2d5e008eb0b008aeae837953/Source/WebCore/crypto/gcrypt/SerializedCryptoKeyWrapGCrypt.cpp#L49
// https://github.com/WebKit/WebKit/blob/c0902fc4dd3abf5d2d5e008eb0b008aeae837953/Source/WebCore/crypto/openssl/SerializedCryptoKeyWrapOpenSSL.cpp#L51
//
// if (!wrapCryptoKey(m_lexicalGlobalObject, serializedKey, wrappedKey))
// return false;
write(serializedKey);
write(wrappedKey);
return true;
}
#endif
@@ -5118,22 +5110,18 @@ private:
}
#if ENABLE(WEB_CRYPTO)
case CryptoKeyTag: {
Vector<uint8_t> serializedKey;
if (!read(serializedKey)) {
Vector<uint8_t> wrappedKey;
if (!read(wrappedKey)) {
fail();
return JSValue();
}
// See CryptoKey serialization for why we don't wrap
//
// Vector<uint8_t> serializedKey;
// if (!unwrapCryptoKey(m_lexicalGlobalObject, wrappedKey, serializedKey)) {
// fail();
// return JSValue();
// }
Vector<uint8_t> serializedKey;
if (!unwrapCryptoKey(m_lexicalGlobalObject, wrappedKey, serializedKey)) {
fail();
return JSValue();
}
JSValue cryptoKey;
// Vector<RefPtr<MessagePort>> dummyMessagePorts;
// CloneDeserializer rawKeyDeserializer(m_lexicalGlobalObject, m_globalObject, dummyMessagePorts, nullptr, {}, serializedKey);
CloneDeserializer rawKeyDeserializer(m_lexicalGlobalObject, m_globalObject, {}, nullptr, serializedKey);
if (!rawKeyDeserializer.readCryptoKey(cryptoKey)) {
fail();

View File

@@ -26,40 +26,74 @@
#include "config.h"
#include "SerializedCryptoKeyWrap.h"
#include "CryptoAlgorithmAES_CTR.h"
#include "OpenSSLUtilities.h"
#include <openssl/rand.h>
#if ENABLE(WEB_CRYPTO)
// #include "NotImplemented.h"
namespace WebCore {
static constexpr size_t masterKeySize = 32; // 256-bit AES key
static Vector<uint8_t>& getPerProcessMasterKey()
{
static Vector<uint8_t> masterKey;
static std::once_flag flag;
std::call_once(flag, [] {
masterKey.resize(masterKeySize);
RAND_bytes(masterKey.begin(), masterKeySize);
});
return masterKey;
}
std::optional<Vector<uint8_t>> defaultWebCryptoMasterKey()
{
// notImplemented();
return std::nullopt;
return getPerProcessMasterKey();
}
// Initially these helper functions were intended to perform KEK wrapping and unwrapping,
// but this is not required anymore, despite the function names and the Mac implementation
// still indicating otherwise.
// See https://bugs.webkit.org/show_bug.cgi?id=173883 for more info.
bool deleteDefaultWebCryptoMasterKey()
{
return true;
}
bool wrapSerializedCryptoKey(const Vector<uint8_t>& masterKey, const Vector<uint8_t>& key, Vector<uint8_t>& result)
{
UNUSED_PARAM(masterKey);
if (masterKey.size() < masterKeySize || key.isEmpty())
return false;
// No wrapping performed -- the serialized key data is copied into the `result` variable.
result = Vector<uint8_t>(key);
AESKey aesKey;
if (!aesKey.setKey(masterKey, AES_ENCRYPT))
return false;
// AES_wrap_key_padded (RFC 5649) handles arbitrary-length input.
// Maximum output size is input size rounded up to 8-byte boundary plus 8 bytes for the IV.
size_t maxOutputSize = ((key.size() + 7) & ~static_cast<size_t>(7)) + 8;
result.resize(maxOutputSize);
size_t outLen = 0;
if (!AES_wrap_key_padded(aesKey.key(), result.begin(), &outLen, maxOutputSize, key.begin(), key.size()))
return false;
result.shrink(outLen);
return true;
}
bool unwrapSerializedCryptoKey(const Vector<uint8_t>& masterKey, const Vector<uint8_t>& wrappedKey, Vector<uint8_t>& key)
{
UNUSED_PARAM(masterKey);
if (masterKey.size() < masterKeySize || wrappedKey.isEmpty())
return false;
// No unwrapping performed -- the serialized key data is copied into the `key` variable.
key = Vector<uint8_t>(wrappedKey);
AESKey aesKey;
if (!aesKey.setKey(masterKey, AES_DECRYPT))
return false;
// Output size is at most the wrapped size (minus 8 bytes for the IV/padding header).
size_t maxOutputSize = wrappedKey.size();
key.resize(maxOutputSize);
size_t outLen = 0;
if (!AES_unwrap_key_padded(aesKey.key(), key.begin(), &outLen, maxOutputSize, wrappedKey.begin(), wrappedKey.size()))
return false;
key.shrink(outLen);
return true;
}

View File

@@ -0,0 +1,113 @@
import { deserialize, serialize } from "bun:jsc";
import { describe, expect, test } from "bun:test";
describe("CryptoKey serialization", () => {
test("structuredClone preserves AES-GCM key", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const cloned = structuredClone(key);
const original = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const clonedExport = new Uint8Array(await crypto.subtle.exportKey("raw", cloned));
expect(Buffer.from(clonedExport)).toEqual(Buffer.from(original));
});
test("structuredClone preserves HMAC key", async () => {
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, true, ["sign", "verify"]);
const cloned = structuredClone(key);
const original = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const clonedExport = new Uint8Array(await crypto.subtle.exportKey("raw", cloned));
expect(Buffer.from(clonedExport)).toEqual(Buffer.from(original));
});
test("structuredClone preserves RSA-OAEP key pair", async () => {
const keyPair = await crypto.subtle.generateKey(
{ name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
true,
["encrypt", "decrypt"],
);
const clonedPrivate = structuredClone(keyPair.privateKey);
const clonedPublic = structuredClone(keyPair.publicKey);
const origPrivate = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
const clonedPrivateExport = new Uint8Array(await crypto.subtle.exportKey("pkcs8", clonedPrivate));
const origPublic = new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey));
const clonedPublicExport = new Uint8Array(await crypto.subtle.exportKey("spki", clonedPublic));
expect(Buffer.from(clonedPrivateExport)).toEqual(Buffer.from(origPrivate));
expect(Buffer.from(clonedPublicExport)).toEqual(Buffer.from(origPublic));
});
test("structuredClone preserves ECDSA key pair", async () => {
const keyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const clonedPrivate = structuredClone(keyPair.privateKey);
const origPrivate = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
const clonedPrivateExport = new Uint8Array(await crypto.subtle.exportKey("pkcs8", clonedPrivate));
expect(Buffer.from(clonedPrivateExport)).toEqual(Buffer.from(origPrivate));
});
test("structuredClone preserves non-extractable key usages and algorithm", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 128 }, false, ["encrypt"]);
const cloned = structuredClone(key);
expect(cloned.extractable).toBe(false);
expect(cloned.algorithm.name).toBe("AES-GCM");
expect((cloned.algorithm as AesKeyAlgorithm).length).toBe(128);
expect(cloned.usages).toEqual(["encrypt"]);
});
test("bun:jsc serialize/deserialize round-trips AES key", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const serialized = serialize(key);
const deserialized = deserialize(serialized) as CryptoKey;
const original = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const restored = new Uint8Array(await crypto.subtle.exportKey("raw", deserialized));
expect(Buffer.from(restored)).toEqual(Buffer.from(original));
});
test("serialized CryptoKey data is wrapped (raw key bytes not present in serialized form)", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const rawKeyBytes = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const rawHex = Buffer.from(rawKeyBytes).toString("hex");
const serialized = serialize(key);
const serializedHex = Buffer.from(serialized).toString("hex");
// The raw key bytes should NOT appear verbatim in the serialized data
// because the wrapping function encrypts them with a per-process master key.
expect(serializedHex.includes(rawHex)).toBe(false);
});
test("cloned key can be used for encrypt/decrypt", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const cloned = structuredClone(key);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode("Hello, World!");
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cloned, ciphertext);
expect(new Uint8Array(decrypted)).toEqual(plaintext);
});
test("cloned HMAC key can be used for sign/verify", async () => {
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, true, ["sign", "verify"]);
const cloned = structuredClone(key);
const data = new TextEncoder().encode("test data");
const signature = await crypto.subtle.sign("HMAC", key, data);
const valid = await crypto.subtle.verify("HMAC", cloned, signature, data);
expect(valid).toBe(true);
});
});