Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 40 additions & 24 deletions src/jsc/bindings/webcrypto/CryptoKeyRSAOpenSSL.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
#include "CryptoKeyPair.h"
#include "CryptoKeyRSAComponents.h"
#include "OpenSSLUtilities.h"
#include "PhonyWorkQueue.h"
#include "ScriptExecutionContext.h"
#include <JavaScriptCore/TypedArrayInlines.h>
#include <openssl/x509.h>
#include <openssl/evp.h>
Expand Down Expand Up @@ -189,43 +191,57 @@ static std::optional<uint32_t> exponentVectorToUInt32(const Vector<uint8_t>& exp
return result;
}

void CryptoKeyRSA::generatePair(CryptoAlgorithmIdentifier algorithm, CryptoAlgorithmIdentifier hash, bool hasHash, unsigned modulusLength, const Vector<uint8_t>& publicExponent, bool extractable, CryptoKeyUsageBitmap usages, KeyPairCallback&& callback, VoidCallback&& failureCallback, ScriptExecutionContext*)
{
// OpenSSL doesn't report an error if the exponent is smaller than three or even.
auto e = exponentVectorToUInt32(publicExponent);
if (!e || *e < 3 || !(*e & 0x1)) {
failureCallback();
return;
}
struct PlatformRSAKeyPair {
EvpPKeyPtr publicKey;
EvpPKeyPtr privateKey;
};

// Returns null keys on failure.
static PlatformRSAKeyPair generatePlatformKeyPair(unsigned modulusLength, const Vector<uint8_t>& publicExponent)
{
auto exponent = convertToBigNumber(publicExponent);
auto privateRSA = RSAPtr(RSA_new());
if (!exponent || RSA_generate_key_ex(privateRSA.get(), modulusLength, exponent.get(), nullptr) <= 0) {
failureCallback();
return;
}
if (!exponent || !privateRSA || RSA_generate_key_ex(privateRSA.get(), modulusLength, exponent.get(), nullptr) <= 0)
return {};

auto publicRSA = RSAPtr(RSAPublicKey_dup(privateRSA.get()));
if (!publicRSA) {
failureCallback();
return;
}
if (!publicRSA)
return {};

auto privatePKey = EvpPKeyPtr(EVP_PKEY_new());
if (EVP_PKEY_set1_RSA(privatePKey.get(), privateRSA.get()) <= 0) {
failureCallback();
return;
}
if (!privatePKey || EVP_PKEY_set1_RSA(privatePKey.get(), privateRSA.get()) <= 0)
return {};

auto publicPKey = EvpPKeyPtr(EVP_PKEY_new());
if (EVP_PKEY_set1_RSA(publicPKey.get(), publicRSA.get()) <= 0) {
if (!publicPKey || EVP_PKEY_set1_RSA(publicPKey.get(), publicRSA.get()) <= 0)
return {};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { WTF::move(publicPKey), WTF::move(privatePKey) };
}

void CryptoKeyRSA::generatePair(CryptoAlgorithmIdentifier algorithm, CryptoAlgorithmIdentifier hash, bool hasHash, unsigned modulusLength, const Vector<uint8_t>& publicExponent, bool extractable, CryptoKeyUsageBitmap usages, KeyPairCallback&& callback, VoidCallback&& failureCallback, ScriptExecutionContext* context)
{
// OpenSSL doesn't report an error if the exponent is smaller than three or even.
auto e = exponentVectorToUInt32(publicExponent);
if (!e || *e < 3 || !(*e & 0x1)) {
failureCallback();
return;
}

auto publicKey = CryptoKeyRSA::create(algorithm, hash, hasHash, CryptoKeyType::Public, WTF::move(publicPKey), true, usages);
auto privateKey = CryptoKeyRSA::create(algorithm, hash, hasHash, CryptoKeyType::Private, WTF::move(privatePKey), extractable, usages);
callback(CryptoKeyPair { WTF::move(publicKey), WTF::move(privateKey) });
// The CryptoKey wrappers must be created on the context's thread.
auto workQueue = Bun::PhonyWorkQueue::create("RSA key generation"_s);
workQueue->dispatch(context->globalObject(), [algorithm, hash, hasHash, modulusLength, publicExponent = publicExponent, extractable, usages, callback = WTF::move(callback), failureCallback = WTF::move(failureCallback), contextIdentifier = context->identifier()]() mutable {
auto platformKeys = generatePlatformKeyPair(modulusLength, publicExponent);
ScriptExecutionContext::postTaskTo(contextIdentifier, [algorithm, hash, hasHash, extractable, usages, publicPKey = WTF::move(platformKeys.publicKey), privatePKey = WTF::move(platformKeys.privateKey), callback = WTF::move(callback), failureCallback = WTF::move(failureCallback)](auto&) mutable {
if (!publicPKey || !privatePKey) {
failureCallback();
return;
}
auto publicKey = CryptoKeyRSA::create(algorithm, hash, hasHash, CryptoKeyType::Public, WTF::move(publicPKey), true, usages);
auto privateKey = CryptoKeyRSA::create(algorithm, hash, hasHash, CryptoKeyType::Private, WTF::move(privatePKey), extractable, usages);
callback(CryptoKeyPair { WTF::move(publicKey), WTF::move(privateKey) });
});
});
}

RefPtr<CryptoKeyRSA> CryptoKeyRSA::importSpki(CryptoAlgorithmIdentifier identifier, std::optional<CryptoAlgorithmIdentifier> hash, Vector<uint8_t>&& keyData, bool extractable, CryptoKeyUsageBitmap usages, bool* keyTypeMismatch)
Expand Down
140 changes: 140 additions & 0 deletions test/js/web/crypto/web-crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,146 @@ describe("crypto.getRandomValues argument types", () => {
}
});

// RSA key generation runs in the work pool instead of synchronously on the JS
// thread. Before, generateKey returned an already-resolved promise: the keygen
// (tens to hundreds of ms for 2048 bits) blocked the event loop and serialized
// concurrent calls.
describe("RSA generateKey", () => {
const rsaParams: RsaHashedKeyGenParams = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
};

it("resolves asynchronously instead of inside the generateKey call", async () => {
const order: string[] = [];
const pending = crypto.subtle.generateKey(rsaParams, true, ["sign", "verify"]);
let settled = false;
pending.then(() => {
settled = true;
order.push("keygen");
});
// With synchronous keygen the promise is already fulfilled when the call
// returns, so its reaction runs at this microtask checkpoint. Off-thread
// keygen cannot resolve during a microtask drain; it needs a full
// event-loop turn to deliver the result.
await Promise.resolve();
expect(settled).toBe(false);

// A 0ms timer armed right after the call must fire before the keygen
// resolves. A synchronous keygen that merely posted its resolution as a
// task would have enqueued the completion before this timer existed and
// would flip the order.
await new Promise<void>(resolve =>
setTimeout(() => {
order.push("timer");
resolve();
}, 0),
);
const { publicKey, privateKey } = (await pending) as CryptoKeyPair;
expect(order).toEqual(["timer", "keygen"]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const data = new Uint8Array([1, 2, 3]);
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, data);
expect(await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signature, data)).toBe(true);
});

it("keeps the process alive until pending key generation resolves", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const pair = await crypto.subtle.generateKey({ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) }, true, ["encrypt", "decrypt"]); console.log(pair.publicKey.type, pair.privateKey.type);`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("public private\n");
expect(exitCode).toBe(0);
});

it("keeps the process alive for a floating generateKey promise", async () => {
// No top-level await: the process lifetime rides entirely on the work-pool
// task's event-loop ref. If that ref were lost, the process would exit
// silently before the keygen completes.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`crypto.subtle.generateKey({ name: "RSA-PSS", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) }, true, ["sign", "verify"]).then(pair => console.log(pair.publicKey.type));`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("public\n");
expect(exitCode).toBe(0);
});

it("still rejects empty usages after generating the pair", async () => {
const result = await crypto.subtle.generateKey(rsaParams, true, []).then(
() => "resolved",
e => `${e.name}: ${e.message}`,
);
expect(result).toBe("SyntaxError: Usages cannot be empty when creating a key.");
});

it("rejects invalid generation parameters with OperationError", async () => {
const probe = (params: RsaHashedKeyGenParams) =>
crypto.subtle.generateKey(params, true, ["encrypt", "decrypt"]).then(
() => "resolved",
e => e.name,
);
expect({
// Rejected synchronously before any keygen is dispatched.
evenExponent: await probe({ ...rsaParams, name: "RSA-OAEP", publicExponent: new Uint8Array([4]) }),
// Makes RSA_generate_key_ex itself fail, on the failure path of the
// dispatched keygen.
tinyModulus: await probe({ ...rsaParams, name: "RSA-OAEP", modulusLength: 8 }),
}).toEqual({
evenExponent: "OperationError",
tinyModulus: "OperationError",
});
});

it("generates keys inside a Worker", async () => {
const workerUrl = URL.createObjectURL(
new Blob(
[
`(async () => {
const pair = await crypto.subtle.generateKey(
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
true,
["sign", "verify"],
);
const data = new Uint8Array([1, 2, 3]);
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", pair.privateKey, data);
postMessage({ verified: await crypto.subtle.verify("RSASSA-PKCS1-v1_5", pair.publicKey, signature, data) });
})().catch(e => postMessage({ error: String(e) }));`,
],
{ type: "application/javascript" },
),
);
let worker: Worker | undefined;
try {
worker = new Worker(workerUrl);
const { promise, resolve, reject } = Promise.withResolvers<any>();
worker.onmessage = e => resolve(e.data);
worker.onerror = e => reject(new Error(e.message));
expect(await promise).toEqual({ verified: true });
Comment thread
robobun marked this conversation as resolved.
} finally {
worker?.terminate();
URL.revokeObjectURL(workerUrl);
}
});
});

describe("exception scope discipline", () => {
// BUN_JSC_validateExceptionChecks=1 aborts on the first unchecked throw scope, so the
// fixture (every SubtleCrypto op, success and normalize-failure) only produces the full
Expand Down