Commit 4775f2b222f for nodejs
commit 4775f2b222fa1af8380d6f982f440f7284a6813b
Author: Filip Skokan <panva.ip@gmail.com>
Date: Wed Sep 30 14:05:15 2026 +0200
crypto: remove RSA-OAEP key locking
RSA_Cipher delegates to ncrypto::Rsa with a new EVP_PKEY_CTX per
operation. Padding, digests, labels, and output buffers belong to that
context. OpenSSL and BoringSSL synchronize RSA blinding and Montgomery
caches internally.
The lock was introduced for early OpenSSL 3 key downgrading that cleared
shared provider fields. Current OpenSSL publishes a separate legacy
cache under its own lock.
Cover cold shared CryptoKeys and worker clones with concurrent
encrypt/decrypt, synchronous KeyObject aliases, and checked plaintexts.
Refs: https://github.com/nodejs/node/pull/36825
Refs: https://docs.openssl.org/3.5/man7/openssl-threads/
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
Assisted-by: Codex
PR-URL: https://github.com/nodejs/node/pull/66413
Reviewed-By: James M Snell <jasnell@gmail.com>
diff --git a/src/crypto/crypto_rsa.cc b/src/crypto/crypto_rsa.cc
index e3c594bfbc4..0a4dbd80ab8 100644
--- a/src/crypto/crypto_rsa.cc
+++ b/src/crypto/crypto_rsa.cc
@@ -210,7 +210,6 @@ WebCryptoCipherStatus RSA_Cipher(Environment* env,
const ByteSource& in,
ByteSource* out) {
CHECK_NE(key_data.GetKeyType(), kKeyTypeSecret);
- Mutex::ScopedLock lock(key_data.mutex());
const auto& m_pkey = key_data.GetAsymmetricKey();
const ncrypto::Rsa::CipherParams nparams{
.padding = params.padding,
diff --git a/test/parallel/test-webcrypto-rsa-shared-key.js b/test/parallel/test-webcrypto-rsa-shared-key.js
new file mode 100644
index 00000000000..1813b517ae9
--- /dev/null
+++ b/test/parallel/test-webcrypto-rsa-shared-key.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('assert');
+const {
+ createPrivateKey,
+ createPublicKey,
+ privateDecrypt,
+ publicEncrypt,
+} = require('crypto');
+const { once } = require('events');
+const { Worker, parentPort, workerData } = require('worker_threads');
+const fixtures = require('../common/fixtures');
+const { subtle } = globalThis.crypto;
+
+const algorithm = { name: 'RSA-OAEP', hash: 'SHA-256' };
+const label = Buffer.from('shared RSA-OAEP key');
+const plaintext = Buffer.from('concurrent key reuse');
+
+async function encrypt({ publicKey, publicCryptoKey }) {
+ const operations = Array.from({ length: 4 }, () =>
+ subtle.encrypt({ name: 'RSA-OAEP', label }, publicCryptoKey, plaintext));
+
+ // Exercise the synchronous API while the shared CryptoKey jobs are queued.
+ operations.push(publicEncrypt({
+ key: publicKey,
+ oaepHash: 'sha256',
+ oaepLabel: label,
+ }, plaintext));
+
+ const ciphertexts = await Promise.all(operations);
+ for (const ciphertext of ciphertexts)
+ assert.strictEqual(ciphertext.byteLength, 256);
+ return ciphertexts;
+}
+
+async function decrypt({ privateKey, privateCryptoKey }, ciphertexts) {
+ const operations = ciphertexts.map((ciphertext) =>
+ subtle.decrypt({ name: 'RSA-OAEP', label }, privateCryptoKey, ciphertext));
+
+ for (const ciphertext of ciphertexts) {
+ assert.deepStrictEqual(privateDecrypt({
+ key: privateKey,
+ oaepHash: 'sha256',
+ oaepLabel: label,
+ }, Buffer.from(ciphertext)), plaintext);
+ }
+
+ for (const result of await Promise.all(operations))
+ assert.deepStrictEqual(Buffer.from(result), plaintext);
+}
+
+function waitForStart(barrier, phase) {
+ parentPort.postMessage(phase);
+ Atomics.wait(barrier, phase, 0);
+}
+
+async function runWorker() {
+ const barrier = new Int32Array(workerData.barrier);
+ waitForStart(barrier, 0);
+ const ciphertexts = await encrypt(workerData.keys);
+ waitForStart(barrier, 1);
+ await decrypt(workerData.keys, ciphertexts);
+}
+
+async function runMain() {
+ const publicKey = createPublicKey(fixtures.readKey('rsa_public_2048.pem'));
+ const privateKey = createPrivateKey(fixtures.readKey('rsa_private_2048.pem'));
+ const keys = {
+ publicKey,
+ privateKey,
+ publicCryptoKey: publicKey.toCryptoKey(algorithm, false, ['encrypt']),
+ privateCryptoKey: privateKey.toCryptoKey(algorithm, false, ['decrypt']),
+ };
+ const barrier = new Int32Array(new SharedArrayBuffer(8));
+ const workers = Array.from({ length: 3 }, () => new Worker(__filename, {
+ workerData: { test: 'rsa-shared-key', keys, barrier: barrier.buffer },
+ }));
+ const exits = workers.map((worker) => once(worker, 'exit'));
+
+ // Clones retain the same backing keys. Release every worker together so
+ // their first encryption and decryption also exercise cold key state.
+ const ready = await Promise.all(
+ workers.map((worker) => once(worker, 'message')));
+ for (const [phase] of ready)
+ assert.strictEqual(phase, 0);
+ const decryptReady = workers.map((worker) => once(worker, 'message'));
+ Atomics.store(barrier, 0, 1);
+ Atomics.notify(barrier, 0);
+ const ciphertexts = await encrypt(keys);
+
+ for (const [phase] of await Promise.all(decryptReady))
+ assert.strictEqual(phase, 1);
+ Atomics.store(barrier, 1, 1);
+ Atomics.notify(barrier, 1);
+ await decrypt(keys, ciphertexts);
+
+ for (const [code] of await Promise.all(exits))
+ assert.strictEqual(code, 0);
+}
+
+if (workerData?.test === 'rsa-shared-key') {
+ runWorker().then(common.mustCall());
+} else {
+ runMain().then(common.mustCall());
+}