From f20bba311b74c79d833ee868f360e16dc77b9dba Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 27 May 2026 10:33:35 +0200 Subject: [PATCH 1/4] crypto: add WebCrypto sync job fast paths Signed-off-by: Filip Skokan From 27d6d3fefa481a57b1dd54066ef45ae3ab8e6120 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 27 May 2026 11:14:12 +0200 Subject: [PATCH 2/4] wip --- .../_webcrypto_sync_fast_path_common.js | 127 +++++++++ .../crypto/webcrypto-sync-fast-path-cipher.js | 122 +++++++++ .../crypto/webcrypto-sync-fast-path-derive.js | 65 +++++ .../crypto/webcrypto-sync-fast-path-digest.js | 66 +++++ .../crypto/webcrypto-sync-fast-path-kdf.js | 62 +++++ .../crypto/webcrypto-sync-fast-path-kem.js | 80 ++++++ .../crypto/webcrypto-sync-fast-path-keygen.js | 135 ++++++++++ .../crypto/webcrypto-sync-fast-path-mac.js | 63 +++++ .../crypto/webcrypto-sync-fast-path-rsa.js | 111 ++++++++ .../webcrypto-sync-fast-path-sign-verify.js | 73 +++++ lib/internal/crypto/aes.js | 19 +- lib/internal/crypto/cfrg.js | 7 +- lib/internal/crypto/chacha20_poly1305.js | 9 +- lib/internal/crypto/diffiehellman.js | 9 +- lib/internal/crypto/ec.js | 11 +- lib/internal/crypto/hash.js | 11 +- lib/internal/crypto/hkdf.js | 5 +- lib/internal/crypto/mac.js | 15 +- lib/internal/crypto/ml_dsa.js | 3 +- lib/internal/crypto/ml_kem.js | 8 +- lib/internal/crypto/rsa.js | 23 +- lib/internal/crypto/util.js | 39 ++- src/crypto/README.md | 33 ++- src/crypto/crypto_kem.cc | 2 +- src/crypto/crypto_keygen.h | 6 +- src/crypto/crypto_util.cc | 7 +- src/crypto/crypto_util.h | 59 +++- .../test-webcrypto-crypto-job-mode.js | 104 +++++-- ...-webcrypto-promise-prototype-pollution.mjs | 254 +++++++++++++++--- 29 files changed, 1402 insertions(+), 126 deletions(-) create mode 100644 benchmark/crypto/_webcrypto_sync_fast_path_common.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-cipher.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-derive.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-digest.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-kdf.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-kem.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-keygen.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-mac.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-rsa.js create mode 100644 benchmark/crypto/webcrypto-sync-fast-path-sign-verify.js diff --git a/benchmark/crypto/_webcrypto_sync_fast_path_common.js b/benchmark/crypto/_webcrypto_sync_fast_path_common.js new file mode 100644 index 00000000000000..4a6a31ecce361e --- /dev/null +++ b/benchmark/crypto/_webcrypto_sync_fast_path_common.js @@ -0,0 +1,127 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const fixturesKeyDir = path.resolve(__dirname, '../../test/fixtures/keys/'); + +const kWebCryptoSyncFastPathThreshold = 64 * 1024; + +const kThresholdSizeLabels = [ + 'minimal', + 'middle', + 'at-threshold', + 'after-threshold', +]; + +function ptn(size) { + const buffer = Buffer.allocUnsafe(size); + for (let i = 0; i < size; i++) { + buffer[i] = i % 0xfb; + } + return buffer; +} + +function thresholdSize(label, { + minimum = 1, + multiple = 1, + threshold = kWebCryptoSyncFastPathThreshold, +} = {}) { + switch (label) { + case 'minimal': + return minimum; + case 'middle': + return roundDown(Math.max(minimum, threshold / 2), multiple); + case 'at-threshold': + return roundDown(Math.max(minimum, threshold), multiple); + case 'after-threshold': + return roundUp(Math.max(minimum, threshold + 1), multiple); + } + throw new Error(`Unknown threshold size label: ${label}`); +} + +function roundDown(value, multiple) { + return Math.max(multiple, Math.floor(value / multiple) * multiple); +} + +function roundUp(value, multiple) { + return Math.ceil(value / multiple) * multiple; +} + +function readKey(name) { + return fs.readFileSync(path.resolve(fixturesKeyDir, `${name}.pem`), 'utf8'); +} + +function fixtureKeyPair(publicKeyName, privateKeyName) { + return { + publicKey: readKey(publicKeyName), + privateKey: readKey(privateKeyName), + }; +} + +function fixtureCryptoKeyPair( + publicKeyName, + privateKeyName, + algorithm, + publicUsages, + privateUsages, +) { + return { + publicKey: crypto.createPublicKey(readKey(publicKeyName)) + .toCryptoKey(algorithm, false, publicUsages), + privateKey: crypto.createPrivateKey(readKey(privateKeyName)) + .toCryptoKey(algorithm, false, privateUsages), + }; +} + +async function importSecretKey({ + algorithm, + usages, + length = 32, + format = 'raw-secret', + extractable = false, +}) { + return globalThis.crypto.subtle.importKey( + format, + ptn(length), + algorithm, + extractable, + usages); +} + +function isSupported(operation, algorithm) { + if (typeof SubtleCrypto.supports !== 'function') + return true; + return SubtleCrypto.supports(operation, algorithm); +} + +async function measureAsync(bench, n, mode, fn) { + if (mode === 'parallel') { + const promises = new Array(n); + bench.start(); + for (let i = 0; i < n; i++) + promises[i] = fn(i); + await Promise.all(promises); + bench.end(n); + return; + } + + bench.start(); + for (let i = 0; i < n; i++) + await fn(i); + bench.end(n); +} + +module.exports = { + fixtureCryptoKeyPair, + fixtureKeyPair, + importSecretKey, + isSupported, + kThresholdSizeLabels, + kWebCryptoSyncFastPathThreshold, + measureAsync, + ptn, + readKey, + thresholdSize, +}; diff --git a/benchmark/crypto/webcrypto-sync-fast-path-cipher.js b/benchmark/crypto/webcrypto-sync-fast-path-cipher.js new file mode 100644 index 00000000000000..1be20617828cb8 --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-cipher.js @@ -0,0 +1,122 @@ +'use strict'; + +const common = require('../common.js'); +const { + importSecretKey, + isSupported, + kThresholdSizeLabels, + measureAsync, + ptn, + thresholdSize, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keyAlgorithms = { + 'AES-CBC': { name: 'AES-CBC', length: 128 }, + 'AES-CTR': { name: 'AES-CTR', length: 128 }, + 'AES-GCM': { name: 'AES-GCM', length: 128 }, + 'AES-OCB': { name: 'AES-OCB', length: 128 }, + 'ChaCha20-Poly1305': { name: 'ChaCha20-Poly1305' }, +}; + +function supportParams(algorithm) { + switch (algorithm) { + case 'AES-CBC': + return { name: algorithm, iv: new Uint8Array(16) }; + case 'AES-CTR': + return { name: algorithm, counter: new Uint8Array(16), length: 64 }; + case 'AES-GCM': + return { name: algorithm, iv: new Uint8Array(12) }; + case 'AES-OCB': + return { name: algorithm, iv: new Uint8Array(15), tagLength: 128 }; + case 'ChaCha20-Poly1305': + return { name: algorithm, iv: new Uint8Array(12), tagLength: 128 }; + } + throw new Error(`Unknown cipher algorithm: ${algorithm}`); +} + +const cipherAlgorithms = Object.keys(keyAlgorithms) + .filter((name) => isSupported('encrypt', supportParams(name))); + +if (cipherAlgorithms.length === 0) { + console.log('no supported WebCrypto cipher algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: cipherAlgorithms, + operation: ['encrypt', 'decrypt'], + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +function cipherParams(algorithm, size) { + switch (algorithm) { + case 'AES-CBC': + return { + name: algorithm, + iv: ptn(16), + }; + case 'AES-CTR': + return { + name: algorithm, + counter: ptn(16), + length: 64, + }; + case 'AES-GCM': + return { + name: algorithm, + iv: ptn(12), + additionalData: ptn(16), + tagLength: 128, + }; + case 'AES-OCB': + return { + name: algorithm, + iv: ptn(15), + additionalData: ptn(16), + tagLength: 128, + }; + case 'ChaCha20-Poly1305': + return { + name: algorithm, + iv: ptn(12), + additionalData: ptn(16), + tagLength: 128, + }; + } + throw new Error(`Unknown cipher algorithm: ${algorithm}`); +} + +function dataSize(algorithm, size) { + switch (algorithm) { + case 'AES-CBC': + return thresholdSize(size, { minimum: 16, multiple: 16 }); + default: + return thresholdSize(size); + } +} + +async function setupCipherOperation(algorithm, operation, size) { + const key = await importSecretKey({ + algorithm: keyAlgorithms[algorithm], + usages: ['encrypt', 'decrypt'], + length: algorithm === 'ChaCha20-Poly1305' ? 32 : 16, + }); + const data = ptn(dataSize(algorithm, size)); + + const params = cipherParams(algorithm, size); + if (operation === 'encrypt') { + return () => subtle.encrypt(params, key, data); + } + + const ciphertext = await subtle.encrypt(params, key, data); + return () => subtle.decrypt(params, key, ciphertext); +} + +async function main({ n, algorithm, operation, size, mode }) { + const run = await setupCipherOperation(algorithm, operation, size); + await measureAsync(bench, n, mode, run); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-derive.js b/benchmark/crypto/webcrypto-sync-fast-path-derive.js new file mode 100644 index 00000000000000..e67bfcd74530fd --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-derive.js @@ -0,0 +1,65 @@ +'use strict'; + +const common = require('../common.js'); +const { + fixtureCryptoKeyPair, + measureAsync, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keyFixtures = { + 'ecdh-p256': { + publicKeyName: 'ec_p256_public', + privateKeyName: 'ec_p256_private', + keyAlgorithm: { name: 'ECDH', namedCurve: 'P-256' }, + deriveAlgorithmName: 'ECDH', + length: 256, + }, + 'x25519': { + publicKeyName: 'x25519_public', + privateKeyName: 'x25519_private', + keyAlgorithm: { name: 'X25519' }, + deriveAlgorithmName: 'X25519', + length: 256, + }, +}; + +if (!process.features.openssl_is_boringssl) { + keyFixtures.x448 = { + publicKeyName: 'x448_public', + privateKeyName: 'x448_private', + keyAlgorithm: { name: 'X448' }, + deriveAlgorithmName: 'X448', + length: 448, + }; +} + +const algorithms = Object.keys(keyFixtures); + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function setupDeriveOperation(algorithm) { + const fixture = keyFixtures[algorithm]; + const pair = fixtureCryptoKeyPair( + fixture.publicKeyName, + fixture.privateKeyName, + fixture.keyAlgorithm, + [], + ['deriveBits']); + const params = { + name: fixture.deriveAlgorithmName, + public: pair.publicKey, + }; + + return () => subtle.deriveBits(params, pair.privateKey, fixture.length); +} + +async function main({ n, algorithm, mode }) { + const run = await setupDeriveOperation(algorithm); + await measureAsync(bench, n, mode, run); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-digest.js b/benchmark/crypto/webcrypto-sync-fast-path-digest.js new file mode 100644 index 00000000000000..2bc6b10df2f9cf --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-digest.js @@ -0,0 +1,66 @@ +'use strict'; + +const common = require('../common.js'); +const { + isSupported, + kThresholdSizeLabels, + measureAsync, + ptn, + thresholdSize, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +function digestAlgorithm(name) { + switch (name) { + case 'cSHAKE128': + return { name, outputLength: 256 }; + case 'cSHAKE256': + return { name, outputLength: 512 }; + case 'TurboSHAKE128': + return { name, outputLength: 256 }; + case 'TurboSHAKE256': + return { name, outputLength: 512 }; + case 'KT128': + return { name, outputLength: 256 }; + case 'KT256': + return { name, outputLength: 512 }; + default: + return name; + } +} + +const algorithms = [ + 'SHA-1', + 'SHA-256', + 'SHA-384', + 'SHA-512', + 'SHA3-256', + 'SHA3-384', + 'SHA3-512', + 'cSHAKE128', + 'cSHAKE256', + 'TurboSHAKE128', + 'TurboSHAKE256', + 'KT128', + 'KT256', +].filter((name) => isSupported('digest', digestAlgorithm(name))); + +if (algorithms.length === 0) { + console.log('no supported WebCrypto digest algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function main({ n, algorithm, size, mode }) { + const data = ptn(thresholdSize(size)); + const params = digestAlgorithm(algorithm); + + await measureAsync(bench, n, mode, () => subtle.digest(params, data)); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-kdf.js b/benchmark/crypto/webcrypto-sync-fast-path-kdf.js new file mode 100644 index 00000000000000..cc436f906237ee --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-kdf.js @@ -0,0 +1,62 @@ +'use strict'; + +const common = require('../common.js'); +const { + importSecretKey, + kThresholdSizeLabels, + kWebCryptoSyncFastPathThreshold, + measureAsync, + ptn, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const kOutputBytes = 32; + +const bench = common.createBenchmark(main, { + algorithm: ['HKDF'], + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +function aggregateSize(label) { + switch (label) { + case 'minimal': + return kOutputBytes; + case 'middle': + return kWebCryptoSyncFastPathThreshold / 2; + case 'at-threshold': + return kWebCryptoSyncFastPathThreshold; + case 'after-threshold': + return kWebCryptoSyncFastPathThreshold + 1; + } + throw new Error(`Unknown HKDF size label: ${label}`); +} + +function hkdfParams(size) { + const aggregate = aggregateSize(size); + const infoLength = aggregate === kOutputBytes ? 0 : 16; + const saltLength = aggregate - infoLength - kOutputBytes; + return { + name: 'HKDF', + hash: 'SHA-256', + salt: ptn(saltLength), + info: ptn(infoLength), + }; +} + +async function main({ n, size, mode }) { + const key = await importSecretKey({ + algorithm: 'HKDF', + usages: ['deriveBits'], + length: 32, + }); + const params = hkdfParams(size); + + await measureAsync( + bench, + n, + mode, + () => subtle.deriveBits(params, key, kOutputBytes * 8)); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-kem.js b/benchmark/crypto/webcrypto-sync-fast-path-kem.js new file mode 100644 index 00000000000000..f1bc3505017281 --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-kem.js @@ -0,0 +1,80 @@ +'use strict'; + +const common = require('../common.js'); +const { hasOpenSSL } = require('../../test/common/crypto.js'); +const { + fixtureCryptoKeyPair, + measureAsync, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keyFixtures = {}; + +if (hasOpenSSL(3, 5)) { + keyFixtures['ml-kem-512'] = { + publicKeyName: 'ml_kem_512_public', + privateKeyName: 'ml_kem_512_private', + algorithm: { name: 'ML-KEM-512' }, + }; + keyFixtures['ml-kem-768'] = { + publicKeyName: 'ml_kem_768_public', + privateKeyName: 'ml_kem_768_private', + algorithm: { name: 'ML-KEM-768' }, + }; + keyFixtures['ml-kem-1024'] = { + publicKeyName: 'ml_kem_1024_public', + privateKeyName: 'ml_kem_1024_private', + algorithm: { name: 'ML-KEM-1024' }, + }; +} else if (process.features.openssl_is_boringssl) { + keyFixtures['ml-kem-768'] = { + publicKeyName: 'ml_kem_768_public', + privateKeyName: 'ml_kem_768_private_seed_only', + algorithm: { name: 'ML-KEM-768' }, + }; + keyFixtures['ml-kem-1024'] = { + publicKeyName: 'ml_kem_1024_public', + privateKeyName: 'ml_kem_1024_private_seed_only', + algorithm: { name: 'ML-KEM-1024' }, + }; +} + +const algorithms = Object.keys(keyFixtures); + +if (algorithms.length === 0) { + console.log('no supported WebCrypto ML-KEM algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + operation: ['encapsulateBits', 'decapsulateBits'], + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function setupKemOperation(algorithm, operation) { + const fixture = keyFixtures[algorithm]; + const pair = fixtureCryptoKeyPair( + fixture.publicKeyName, + fixture.privateKeyName, + fixture.algorithm, + ['encapsulateBits'], + ['decapsulateBits']); + + if (operation === 'encapsulateBits') { + return () => subtle.encapsulateBits(fixture.algorithm, pair.publicKey); + } + + const { ciphertext } = await subtle.encapsulateBits(fixture.algorithm, pair.publicKey); + return () => subtle.decapsulateBits( + fixture.algorithm, + pair.privateKey, + ciphertext); +} + +async function main({ n, algorithm, operation, mode }) { + const run = await setupKemOperation(algorithm, operation); + await measureAsync(bench, n, mode, run); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-keygen.js b/benchmark/crypto/webcrypto-sync-fast-path-keygen.js new file mode 100644 index 00000000000000..bcd767aa5ed2fd --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-keygen.js @@ -0,0 +1,135 @@ +'use strict'; + +const common = require('../common.js'); +const { + isSupported, + measureAsync, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keygenCases = { + 'aes-cbc-128': { + algorithm: { name: 'AES-CBC', length: 128 }, + usages: ['encrypt', 'decrypt'], + }, + 'aes-ctr-128': { + algorithm: { name: 'AES-CTR', length: 128 }, + usages: ['encrypt', 'decrypt'], + }, + 'aes-gcm-128': { + algorithm: { name: 'AES-GCM', length: 128 }, + usages: ['encrypt', 'decrypt'], + }, + 'aes-kw-128': { + algorithm: { name: 'AES-KW', length: 128 }, + usages: ['wrapKey', 'unwrapKey'], + }, + 'aes-ocb-128': { + algorithm: { name: 'AES-OCB', length: 128 }, + usages: ['encrypt', 'decrypt'], + }, + 'chacha20-poly1305': { + algorithm: { name: 'ChaCha20-Poly1305' }, + usages: ['encrypt', 'decrypt'], + }, + 'hmac': { + algorithm: { name: 'HMAC', hash: 'SHA-256', length: 256 }, + usages: ['sign', 'verify'], + }, + 'kmac128': { + algorithm: { name: 'KMAC128', length: 128 }, + usages: ['sign', 'verify'], + }, + 'kmac256': { + algorithm: { name: 'KMAC256', length: 256 }, + usages: ['sign', 'verify'], + }, + 'ecdsa-p256': { + algorithm: { name: 'ECDSA', namedCurve: 'P-256' }, + usages: ['sign', 'verify'], + }, + 'ecdsa-p384': { + algorithm: { name: 'ECDSA', namedCurve: 'P-384' }, + usages: ['sign', 'verify'], + }, + 'ecdsa-p521': { + algorithm: { name: 'ECDSA', namedCurve: 'P-521' }, + usages: ['sign', 'verify'], + }, + 'ecdh-p256': { + algorithm: { name: 'ECDH', namedCurve: 'P-256' }, + usages: ['deriveBits'], + }, + 'ecdh-p384': { + algorithm: { name: 'ECDH', namedCurve: 'P-384' }, + usages: ['deriveBits'], + }, + 'ecdh-p521': { + algorithm: { name: 'ECDH', namedCurve: 'P-521' }, + usages: ['deriveBits'], + }, + 'ed25519': { + algorithm: { name: 'Ed25519' }, + usages: ['sign', 'verify'], + }, + 'ed448': { + algorithm: { name: 'Ed448' }, + usages: ['sign', 'verify'], + }, + 'x25519': { + algorithm: { name: 'X25519' }, + usages: ['deriveBits'], + }, + 'x448': { + algorithm: { name: 'X448' }, + usages: ['deriveBits'], + }, + 'ml-dsa-44': { + algorithm: { name: 'ML-DSA-44' }, + usages: ['sign', 'verify'], + }, + 'ml-dsa-65': { + algorithm: { name: 'ML-DSA-65' }, + usages: ['sign', 'verify'], + }, + 'ml-dsa-87': { + algorithm: { name: 'ML-DSA-87' }, + usages: ['sign', 'verify'], + }, + 'ml-kem-512': { + algorithm: { name: 'ML-KEM-512' }, + usages: ['encapsulateBits', 'decapsulateBits'], + }, + 'ml-kem-768': { + algorithm: { name: 'ML-KEM-768' }, + usages: ['encapsulateBits', 'decapsulateBits'], + }, + 'ml-kem-1024': { + algorithm: { name: 'ML-KEM-1024' }, + usages: ['encapsulateBits', 'decapsulateBits'], + }, +}; + +const algorithms = Object.keys(keygenCases) + .filter((name) => isSupported('generateKey', keygenCases[name].algorithm)); + +if (algorithms.length === 0) { + console.log('no supported WebCrypto keygen algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function main({ n, algorithm, mode }) { + const { algorithm: keyAlgorithm, usages } = keygenCases[algorithm]; + await measureAsync( + bench, + n, + mode, + () => subtle.generateKey(keyAlgorithm, false, usages)); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-mac.js b/benchmark/crypto/webcrypto-sync-fast-path-mac.js new file mode 100644 index 00000000000000..e9396ab3e1bb2c --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-mac.js @@ -0,0 +1,63 @@ +'use strict'; + +const common = require('../common.js'); +const { + importSecretKey, + isSupported, + kThresholdSizeLabels, + measureAsync, + ptn, + thresholdSize, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keyAlgorithms = { + HMAC: { name: 'HMAC', hash: 'SHA-256' }, + KMAC128: { name: 'KMAC128' }, + KMAC256: { name: 'KMAC256' }, +}; + +const signAlgorithms = { + HMAC: { name: 'HMAC' }, + KMAC128: { name: 'KMAC128', outputLength: 256 }, + KMAC256: { name: 'KMAC256', outputLength: 256 }, +}; + +const algorithms = Object.keys(keyAlgorithms) + .filter((name) => isSupported('sign', signAlgorithms[name])); + +if (algorithms.length === 0) { + console.log('no supported WebCrypto MAC algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + operation: ['sign', 'verify'], + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function setupMacOperation(algorithm, operation, size) { + const key = await importSecretKey({ + algorithm: keyAlgorithms[algorithm], + usages: ['sign', 'verify'], + length: 32, + }); + const data = ptn(thresholdSize(size)); + const params = signAlgorithms[algorithm]; + + if (operation === 'sign') { + return () => subtle.sign(params, key, data); + } + + const signature = await subtle.sign(params, key, data); + return () => subtle.verify(params, key, signature, data); +} + +async function main({ n, algorithm, operation, size, mode }) { + const run = await setupMacOperation(algorithm, operation, size); + await measureAsync(bench, n, mode, run); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-rsa.js b/benchmark/crypto/webcrypto-sync-fast-path-rsa.js new file mode 100644 index 00000000000000..b9c57b477f4cd0 --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-rsa.js @@ -0,0 +1,111 @@ +'use strict'; + +const common = require('../common.js'); +const { + fixtureCryptoKeyPair, + kThresholdSizeLabels, + measureAsync, + ptn, + thresholdSize, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const rsaKeySizes = [ + 'fixture-2048', + 'fixture-4096', +]; + +const bench = common.createBenchmark(main, { + operation: [ + 'rsa-oaep-encrypt', + 'rsa-oaep-decrypt', + 'rsassa-pkcs1-v1_5-verify', + 'rsa-pss-verify', + ], + keySize: rsaKeySizes, + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [500], +}, { + combinationFilter({ operation, keySize, size }) { + if (operation === 'rsa-oaep-encrypt' || operation === 'rsa-oaep-decrypt') + return size === 'minimal'; + return true; + }, +}); + +function rsaAlgorithm(name) { + return { + name, + hash: 'SHA-256', + }; +} + +function fixtureNames(keySize) { + const bits = keySize.slice('fixture-'.length); + return { + publicKeyName: `rsa_public_${bits}`, + privateKeyName: `rsa_private_${bits}`, + }; +} + +async function rsaKeyPair(operation, keySize) { + const { publicKeyName, privateKeyName } = fixtureNames(keySize); + switch (operation) { + case 'rsa-oaep-encrypt': + case 'rsa-oaep-decrypt': + return fixtureCryptoKeyPair( + publicKeyName, + privateKeyName, + rsaAlgorithm('RSA-OAEP'), + ['encrypt'], + ['decrypt']); + case 'rsassa-pkcs1-v1_5-verify': + return fixtureCryptoKeyPair( + publicKeyName, + privateKeyName, + rsaAlgorithm('RSASSA-PKCS1-v1_5'), + ['verify'], + ['sign']); + case 'rsa-pss-verify': + return fixtureCryptoKeyPair( + publicKeyName, + privateKeyName, + rsaAlgorithm('RSA-PSS'), + ['verify'], + ['sign']); + } + throw new Error(`Unknown RSA operation: ${operation}`); +} + +async function setupRsaOperation(operation, keySize, size) { + const pair = await rsaKeyPair(operation, keySize); + + switch (operation) { + case 'rsa-oaep-encrypt': { + const data = ptn(32); + return () => subtle.encrypt('RSA-OAEP', pair.publicKey, data); + } + case 'rsa-oaep-decrypt': { + const data = ptn(32); + const ciphertext = await subtle.encrypt('RSA-OAEP', pair.publicKey, data); + return () => subtle.decrypt('RSA-OAEP', pair.privateKey, ciphertext); + } + case 'rsassa-pkcs1-v1_5-verify': + case 'rsa-pss-verify': { + const data = ptn(thresholdSize(size)); + const signAlgorithm = operation === 'rsa-pss-verify' ? + { name: 'RSA-PSS', saltLength: 32 } : + { name: 'RSASSA-PKCS1-v1_5' }; + const signature = await subtle.sign(signAlgorithm, pair.privateKey, data); + return () => subtle.verify(signAlgorithm, pair.publicKey, signature, data); + } + } + throw new Error(`Unknown RSA operation: ${operation}`); +} + +async function main({ n, operation, keySize, size, mode }) { + const run = await setupRsaOperation(operation, keySize, size); + await measureAsync(bench, n, mode, run); +} diff --git a/benchmark/crypto/webcrypto-sync-fast-path-sign-verify.js b/benchmark/crypto/webcrypto-sync-fast-path-sign-verify.js new file mode 100644 index 00000000000000..251f1e48f6308e --- /dev/null +++ b/benchmark/crypto/webcrypto-sync-fast-path-sign-verify.js @@ -0,0 +1,73 @@ +'use strict'; + +const common = require('../common.js'); +const { + fixtureCryptoKeyPair, + isSupported, + kThresholdSizeLabels, + measureAsync, + ptn, + thresholdSize, +} = require('./_webcrypto_sync_fast_path_common.js'); + +const { subtle } = globalThis.crypto; + +const keyFixtures = { + 'ecdsa-p256': { + publicKeyName: 'ec_p256_public', + privateKeyName: 'ec_p256_private', + keyAlgorithm: { name: 'ECDSA', namedCurve: 'P-256' }, + signAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + }, + 'ed25519': { + publicKeyName: 'ed25519_public', + privateKeyName: 'ed25519_private', + keyAlgorithm: { name: 'Ed25519' }, + signAlgorithm: { name: 'Ed25519' }, + }, + 'ed448': { + publicKeyName: 'ed448_public', + privateKeyName: 'ed448_private', + keyAlgorithm: { name: 'Ed448' }, + signAlgorithm: { name: 'Ed448' }, + }, +}; + +const algorithms = Object.keys(keyFixtures) + .filter((name) => isSupported('sign', keyFixtures[name].signAlgorithm)); + +if (algorithms.length === 0) { + console.log('no supported WebCrypto sign/verify algorithms available'); + process.exit(0); +} + +const bench = common.createBenchmark(main, { + algorithm: algorithms, + operation: ['sign', 'verify'], + size: kThresholdSizeLabels, + mode: ['serial', 'parallel'], + n: [1e3], +}); + +async function setupSignVerifyOperation(algorithm, operation, size) { + const fixture = keyFixtures[algorithm]; + const pair = fixtureCryptoKeyPair( + fixture.publicKeyName, + fixture.privateKeyName, + fixture.keyAlgorithm, + ['verify'], + ['sign']); + const data = ptn(thresholdSize(size)); + + if (operation === 'sign') { + return () => subtle.sign(fixture.signAlgorithm, pair.privateKey, data); + } + + const signature = await subtle.sign(fixture.signAlgorithm, pair.privateKey, data); + return () => subtle.verify(fixture.signAlgorithm, pair.publicKey, signature, data); +} + +async function main({ n, algorithm, operation, size, mode }) { + const run = await setupSignVerifyOperation(algorithm, operation, size); + await measureAsync(bench, n, mode, run); +} diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index 981502c51700be..bac1f88b96005a 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -7,7 +7,7 @@ const { const { AESCipherJob, - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, kKeyVariantAES_CTR_128, kKeyVariantAES_CBC_128, kKeyVariantAES_GCM_128, @@ -28,6 +28,7 @@ const { const { getUsagesMask, + getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, } = require('internal/crypto/util'); @@ -107,7 +108,7 @@ function getVariant(name, length) { function asyncAesCtrCipher(mode, key, data, algorithm) { return jobPromise(() => new AESCipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength(data.byteLength), mode, getCryptoKeyHandle(key), data, @@ -118,7 +119,7 @@ function asyncAesCtrCipher(mode, key, data, algorithm) { function asyncAesCbcCipher(mode, key, data, algorithm) { return jobPromise(() => new AESCipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength(data.byteLength), mode, getCryptoKeyHandle(key), data, @@ -128,7 +129,7 @@ function asyncAesCbcCipher(mode, key, data, algorithm) { function asyncAesKwCipher(mode, key, data) { return jobPromise(() => new AESCipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength(data.byteLength), mode, getCryptoKeyHandle(key), data, @@ -138,9 +139,11 @@ function asyncAesKwCipher(mode, key, data) { function asyncAesGcmCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; const tagByteLength = tagLength / 8; + const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; return jobPromise(() => new AESCipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength( + data.byteLength + additionalDataLength), mode, getCryptoKeyHandle(key), data, @@ -153,9 +156,11 @@ function asyncAesGcmCipher(mode, key, data, algorithm) { function asyncAesOcbCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; const tagByteLength = tagLength / 8; + const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; return jobPromise(() => new AESCipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength( + data.byteLength + additionalDataLength), mode, getCryptoKeyHandle(key), data, @@ -195,7 +200,7 @@ function aesGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new SecretKeyGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, length, { name, length }, getUsagesMask(usagesSet), diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 3e6152b1f55501..6b80642f7bcb9b 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -8,7 +8,7 @@ const { const { SignJob, - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, kSignJobModeSign, @@ -26,6 +26,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, } = require('internal/crypto/util'); @@ -131,7 +132,7 @@ function cfrgGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, nid, keyAlgorithm, getUsagesMask(publicUsages), @@ -239,7 +240,7 @@ function eddsaSignVerify(key, data, algorithm, signature) { throw lazyDOMException(`Key must be a ${type} key`, 'InvalidAccessError'); return jobPromise(() => new SignJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength(data.byteLength), mode, getCryptoKeyHandle(key), undefined, diff --git a/lib/internal/crypto/chacha20_poly1305.js b/lib/internal/crypto/chacha20_poly1305.js index 689cab59f3fbf2..3fccefe98dc232 100644 --- a/lib/internal/crypto/chacha20_poly1305.js +++ b/lib/internal/crypto/chacha20_poly1305.js @@ -7,11 +7,12 @@ const { const { ChaCha20Poly1305CipherJob, SecretKeyGenJob, - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, } = internalBinding('crypto'); const { getUsagesMask, + getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, } = require('internal/crypto/util'); @@ -38,8 +39,10 @@ function validateKeyLength(length) { } function c20pCipher(mode, key, data, algorithm) { + const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; return jobPromise(() => new ChaCha20Poly1305CipherJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength( + data.byteLength + additionalDataLength), mode, getCryptoKeyHandle(key), data, @@ -65,7 +68,7 @@ function c20pGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new SecretKeyGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, 256, { name }, getUsagesMask(usagesSet), diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index d17b06ef155f76..45456e7f9e27e8 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -19,6 +19,7 @@ const { ECDHConvertKey: _ECDHConvertKey, kCryptoJobAsync, kCryptoJobSync, + kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, } = internalBinding('crypto'); @@ -351,8 +352,14 @@ function ecdhDeriveBits(algorithm, baseKey, length) { throw lazyDOMException('Named curve mismatch', 'InvalidAccessError'); } + const jobMode = + keyAlgorithm.name === 'X25519' || + keyAlgorithm.name === 'X448' || + (keyAlgorithm.name === 'ECDH' && keyAlgorithm.namedCurve === 'P-256') ? + kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; + const bits = jobPromise(() => new DHBitsJob( - kCryptoJobWebCrypto, + jobMode, getCryptoKeyHandle(key), undefined, undefined, diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index d102b3fe05a29c..5e0e6c37dbed3c 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -10,6 +10,7 @@ const { EcKeyPairGenJob, KeyObjectHandle, SignJob, + kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, @@ -31,6 +32,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, normalizeHashName, @@ -120,7 +122,7 @@ function ecGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new EcKeyPairGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, namedCurve, undefined, keyAlgorithm, @@ -267,8 +269,13 @@ function ecdsaSignVerify(key, data, { name, hash }, signature) { if (getCryptoKeyType(key) !== type) throw lazyDOMException(`Key must be a ${type} key`, 'InvalidAccessError'); + const { namedCurve } = getCryptoKeyAlgorithm(key); + const jobMode = namedCurve === 'P-256' ? + getWebCryptoJobModeForInputLength(data.byteLength) : + kCryptoJobWebCrypto; + return jobPromise(() => new SignJob( - kCryptoJobWebCrypto, + jobMode, mode, getCryptoKeyHandle(key), undefined, diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index 5aec1614cb92e9..b6bd6c5d447ad5 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -12,13 +12,13 @@ const { Hash: _Hash, HashJob, Hmac: _Hmac, - kCryptoJobWebCrypto, oneShotDigest, TurboShakeJob, KangarooTwelveJob, } = internalBinding('crypto'); const { + getWebCryptoJobModeForInputLength, getStringOption, jobPromise, normalizeHashName, @@ -202,6 +202,9 @@ Hmac.prototype._transform = Hash.prototype._transform; function asyncDigest(algorithm, data) { validateMaxBufferLength(data, 'data'); + const outputByteLength = (algorithm.outputLength ?? 0) / 8; + const jobMode = + getWebCryptoJobModeForInputLength(data.byteLength + outputByteLength); switch (algorithm.name) { case 'SHA-1': @@ -222,7 +225,7 @@ function asyncDigest(algorithm, data) { // Fall through case 'cSHAKE256': return jobPromise(() => new HashJob( - kCryptoJobWebCrypto, + jobMode, normalizeHashName(algorithm.name), data, algorithm.outputLength)); @@ -230,7 +233,7 @@ function asyncDigest(algorithm, data) { // Fall through case 'TurboSHAKE256': return jobPromise(() => new TurboShakeJob( - kCryptoJobWebCrypto, + jobMode, algorithm.name, algorithm.domainSeparation ?? 0x1f, algorithm.outputLength / 8, @@ -239,7 +242,7 @@ function asyncDigest(algorithm, data) { // Fall through case 'KT256': return jobPromise(() => new KangarooTwelveJob( - kCryptoJobWebCrypto, + jobMode, algorithm.name, algorithm.customization, algorithm.outputLength / 8, diff --git a/lib/internal/crypto/hkdf.js b/lib/internal/crypto/hkdf.js index 73b16da6923024..6c8e44c381e08f 100644 --- a/lib/internal/crypto/hkdf.js +++ b/lib/internal/crypto/hkdf.js @@ -10,7 +10,6 @@ const { HKDFJob, kCryptoJobAsync, kCryptoJobSync, - kCryptoJobWebCrypto, } = internalBinding('crypto'); const { @@ -22,6 +21,7 @@ const { const { kMaxLength } = require('buffer'); const { + getWebCryptoJobModeForInputLength, jobPromise, normalizeHashName, toBuf, @@ -157,7 +157,8 @@ function hkdfDeriveBits(algorithm, baseKey, length) { return PromiseResolve(new ArrayBuffer(0)); return jobPromise(() => new HKDFJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength( + salt.byteLength + info.byteLength + length / 8), normalizeHashName(hash.name), getCryptoKeyHandle(baseKey), salt, diff --git a/lib/internal/crypto/mac.js b/lib/internal/crypto/mac.js index 724b2104d4b8c8..6cfe4079af7332 100644 --- a/lib/internal/crypto/mac.js +++ b/lib/internal/crypto/mac.js @@ -8,7 +8,7 @@ const { const { HmacJob, KmacJob, - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, kSignJobModeSign, kSignJobModeVerify, SecretKeyGenJob, @@ -17,6 +17,7 @@ const { const { getBlockSize, getUsagesMask, + getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, normalizeHashName, @@ -60,7 +61,7 @@ function hmacGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new SecretKeyGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, length, { name, length, hash }, getUsagesMask(usageSet), @@ -90,7 +91,7 @@ function kmacGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new SecretKeyGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, length, { name, length }, getUsagesMask(usageSet), @@ -175,8 +176,9 @@ function macImportKey( function hmacSignVerify(key, data, algorithm, signature) { const mode = signature === undefined ? kSignJobModeSign : kSignJobModeVerify; + const signatureLength = signature?.byteLength ?? 0; return jobPromise(() => new HmacJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength(data.byteLength + signatureLength), mode, normalizeHashName(getCryptoKeyAlgorithm(key).hash.name), getCryptoKeyHandle(key), @@ -186,8 +188,11 @@ function hmacSignVerify(key, data, algorithm, signature) { function kmacSignVerify(key, data, algorithm, signature) { const mode = signature === undefined ? kSignJobModeSign : kSignJobModeVerify; + const signatureLength = signature?.byteLength ?? 0; + const outputByteLength = algorithm.outputLength / 8; return jobPromise(() => new KmacJob( - kCryptoJobWebCrypto, + getWebCryptoJobModeForInputLength( + data.byteLength + signatureLength + outputByteLength), mode, getCryptoKeyHandle(key), algorithm.name, diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index e2497a2b722b97..e0528d9b90d990 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -9,6 +9,7 @@ const { const { SignJob, + kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, @@ -88,7 +89,7 @@ function mlDsaGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, nid, keyAlgorithm, getUsagesMask(publicUsages), diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index 2dea4d00af052f..66442a2eb54174 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -8,7 +8,7 @@ const { } = primordials; const { - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, KEMDecapsulateJob, KEMEncapsulateJob, kKeyFormatDER, @@ -80,7 +80,7 @@ function mlKemGenerateKey(algorithm, extractable, usages) { } return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, nid, keyAlgorithm, getUsagesMask(publicUsages), @@ -212,7 +212,7 @@ function mlKemEncapsulate(encapsulationKey) { } return jobPromise(() => new KEMEncapsulateJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, getCryptoKeyHandle(encapsulationKey), undefined, undefined, @@ -226,7 +226,7 @@ function mlKemDecapsulate(decapsulationKey, ciphertext) { } return jobPromise(() => new KEMDecapsulateJob( - kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, getCryptoKeyHandle(decapsulationKey), undefined, undefined, diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index a09dd7b9f0fda9..b4fbe357f28928 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -10,6 +10,7 @@ const { const { RSACipherJob, SignJob, + kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kSignJobModeSign, @@ -30,10 +31,12 @@ const { const { bigIntArrayToUnsignedInt, getDigestSizeInBytes, + getWebCryptoJobModeForInputLength, getUsagesMask, getUsagesUnion, hasAnyNotIn, jobPromise, + kWebCryptoRsaSyncMaxModulusLength, normalizeHashName, validateMaxBufferLength, } = require('internal/crypto/util'); @@ -95,13 +98,19 @@ function rsaOaepCipher(mode, key, data, algorithm) { 'InvalidAccessError'); } + const keyAlgorithm = getCryptoKeyAlgorithm(key); + const jobMode = + mode === kWebCryptoCipherEncrypt && + keyAlgorithm.modulusLength <= kWebCryptoRsaSyncMaxModulusLength ? + kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; + return jobPromise(() => new RSACipherJob( - kCryptoJobWebCrypto, + jobMode, mode, getCryptoKeyHandle(key), data, kKeyVariantRSA_OAEP, - normalizeHashName(getCryptoKeyAlgorithm(key).hash.name), + normalizeHashName(keyAlgorithm.hash.name), algorithm.label)); } @@ -299,9 +308,15 @@ function rsaSignVerify(key, data, { saltLength }, signature) { } } + const jobMode = + mode === kSignJobModeVerify && + algorithm.modulusLength <= kWebCryptoRsaSyncMaxModulusLength ? + getWebCryptoJobModeForInputLength(data.byteLength) : + kCryptoJobWebCrypto; + return jobPromise(() => new SignJob( - kCryptoJobWebCrypto, - signature === undefined ? kSignJobModeSign : kSignJobModeVerify, + jobMode, + mode, getCryptoKeyHandle(key), undefined, undefined, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 74d86de3f1b9e1..78cbfce1ac148b 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -36,6 +36,8 @@ const { secureHeapUsed: _secureHeapUsed, getCachedAliases, getOpenSSLSecLevelCrypto: getOpenSSLSecLevel, + kCryptoJobSyncWebCrypto, + kCryptoJobWebCrypto, EVP_PKEY_ML_DSA_44, EVP_PKEY_ML_DSA_65, EVP_PKEY_ML_DSA_87, @@ -668,19 +670,6 @@ const validateByteSource = hideStackFrames((val, name) => { val); }); -// CryptoJob constructors can synchronously throw while running their native -// AdditionalConfig hook. WebCrypto needs those operation-specific setup -// failures to reject with an OperationError. -function jobPromise(getJob) { - try { - return getJob().run(); - } catch (err) { - return PromiseReject(lazyDOMException( - 'The operation failed for an operation-specific reason', - { name: 'OperationError', cause: err })); - } -} - // Temporarily shadow inherited then accessors on WebCrypto result objects. // Promise resolution reads "then" synchronously for thenable assimilation. // Returning an own undefined data property keeps that lookup from reaching @@ -701,6 +690,27 @@ function cleanupWebCryptoResult(value) { delete value.then; } +const kWebCryptoDefaultSyncThreshold = 64 * 1024; +const kWebCryptoRsaSyncMaxModulusLength = 4096; + +function getWebCryptoJobModeForInputLength(byteLength) { + return byteLength <= kWebCryptoDefaultSyncThreshold ? + kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; +} + +// CryptoJob constructors can synchronously throw while running their native +// AdditionalConfig hook. WebCrypto needs those operation-specific setup +// failures to reject with an OperationError. +function jobPromise(getJob) { + try { + return getJob().run(); + } catch (err) { + return PromiseReject(lazyDOMException( + 'The operation failed for an operation-specific reason', + { name: 'OperationError', cause: err })); + } +} + // Resolve a WebCrypto promise while inherited then accessors are shadowed. function resolveWebCryptoResult(resolve, value) { const shouldCleanupResult = prepareWebCryptoResult(value); @@ -961,6 +971,9 @@ module.exports = { kNamedCurveAliases, kSupportedAlgorithms, + kWebCryptoDefaultSyncThreshold, + kWebCryptoRsaSyncMaxModulusLength, + getWebCryptoJobModeForInputLength, normalizeAlgorithm, normalizeHashName, hasAnyNotIn, diff --git a/src/crypto/README.md b/src/crypto/README.md index 4059ae23711b84..5973f142671db3 100644 --- a/src/crypto/README.md +++ b/src/crypto/README.md @@ -187,7 +187,9 @@ are built around the `CryptoJob` class. A `CryptoJob` encapsulates a single crypto operation that can be invoked synchronously, asynchronously, or as a Web Crypto API -Promise-based job. +Promise-based job. Web Crypto API jobs can run either asynchronously +on the libuv threadpool or synchronously on the current thread while +still returning a Promise to the JavaScript layer. The `CryptoJob` class itself is a C++ template that takes a single `CryptoJobTraits` struct as a parameter. The `CryptoJobTraits` @@ -247,10 +249,21 @@ or `undefined`, and the second is the result of the operation if successful. If the `CryptoJob` is processed as a Web Crypto API job, then -`run()` returns a Promise. Operation-specific failures are -rejected with an `OperationError`, and successful jobs resolve -with the Web Crypto API result shape expected by the JavaScript -implementation. +`run()` returns a Promise. `kCryptoJobWebCrypto` dispatches the +work to the libuv threadpool. `kCryptoJobSyncWebCrypto` performs +the work synchronously on the current thread but still resolves or +rejects the Promise using Web Crypto API semantics. This sync +Web Crypto mode is used for selected small-input operations and +selected low-cost public-key operations, plus Web Crypto symmetric +and non-RSA asymmetric key generation. +Operation-specific failures are rejected with an `OperationError`, +and successful jobs resolve with the Web Crypto API result shape +expected by the JavaScript implementation. + +The JavaScript Web Crypto layer keeps the thresholds close to the +operation-specific call sites. The default small-input threshold is +64 KiB. RSA public-key operations are only selected for sync Web Crypto +mode with moduli up to 4096 bits. For `CipherJob` types, the output is always an `ArrayBuffer`. @@ -322,6 +335,10 @@ operations use the traditional Node.js callback pattern, as illustrated in the previous `randomFill()` example. In the Web Crypto API (accessible via `globalThis.crypto`), all asynchronous single-call operations are Promise-based. +Some Web Crypto API Promise-based operations can still execute +their crypto work synchronously on the current thread for small +inputs. Those operations continue to return Promises and use Web +Crypto API result and error semantics. ```js // Example Web Crypto API asynchronous single-call operation @@ -336,9 +353,9 @@ subtle.generateKeys({ name: 'HMAC', length: 256 }, true, ['sign']) }); ``` -In nearly every case, asynchronous single-call operations make use -of the libuv threadpool to perform crypto operations off the main -event loop thread. +Most asynchronous single-call operations make use of the libuv +threadpool to perform crypto operations off the main event loop +thread. Stream-oriented operations use an object to maintain state over multiple individual synchronous steps. The steps themselves diff --git a/src/crypto/crypto_kem.cc b/src/crypto/crypto_kem.cc index 09fbf0844f48f2..7b7fec62b0a595 100644 --- a/src/crypto/crypto_kem.cc +++ b/src/crypto/crypto_kem.cc @@ -174,7 +174,7 @@ MaybeLocal KEMEncapsulateTraits::EncodeOutput( return MaybeLocal(); } - if (params.job_mode == kCryptoJobWebCrypto) { + if (IsCryptoJobWebCrypto(params.job_mode)) { Local result = Object::New(env->isolate()); if (!result ->DefineOwnProperty(env->context(), diff --git a/src/crypto/crypto_keygen.h b/src/crypto/crypto_keygen.h index 1702dfabb4af2a..f3ceba3648ec6c 100644 --- a/src/crypto/crypto_keygen.h +++ b/src/crypto/crypto_keygen.h @@ -63,7 +63,7 @@ class KeyGenJob final : public CryptoJob { } WebCryptoKeyGenConfig config; - if (mode == kCryptoJobWebCrypto) { + if (IsCryptoJobWebCrypto(mode)) { if constexpr (KeyGenTraits::kWebCryptoKeyPair) { CHECK(args[offset]->IsObject()); CHECK(args[offset + 1]->IsUint32()); @@ -132,7 +132,7 @@ class KeyGenJob final : public CryptoJob { if (status_ == KeyGenJobStatus::OK) { v8::TryCatch try_catch(env->isolate()); v8::MaybeLocal encoded = - CryptoJob::mode() == kCryptoJobWebCrypto + IsCryptoJobWebCrypto(CryptoJob::mode()) ? EncodeWebCryptoKey(env, params) : KeyGenTraits::EncodeKey(env, params); if (encoded.ToLocal(result)) { @@ -235,7 +235,7 @@ struct KeyPairGenTraits final { return v8::Nothing(); } - if (mode == kCryptoJobWebCrypto) return v8::JustVoid(); + if (IsCryptoJobWebCrypto(mode)) return v8::JustVoid(); if (!KeyObjectData::GetPublicKeyEncodingFromJs( args, offset, kKeyContextGenerate) diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 42b248d84b43e5..36d96686f6bc39 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -713,7 +713,7 @@ Maybe SetEncodedValue(Environment* env, CryptoJobMode GetCryptoJobMode(v8::Local args) { CHECK(args->IsUint32()); uint32_t mode = args.As()->Value(); - CHECK_LE(mode, kCryptoJobWebCrypto); + CHECK_LE(mode, kCryptoJobSyncWebCrypto); return static_cast(mode); } @@ -721,6 +721,10 @@ bool IsCryptoJobAsync(CryptoJobMode mode) { return mode == kCryptoJobAsync || mode == kCryptoJobWebCrypto; } +bool IsCryptoJobWebCrypto(CryptoJobMode mode) { + return mode == kCryptoJobWebCrypto || mode == kCryptoJobSyncWebCrypto; +} + MaybeLocal CreateWebCryptoJobError(Environment* env, Local cause) { Isolate* isolate = env->isolate(); @@ -839,6 +843,7 @@ void Initialize(Environment* env, Local target) { NODE_DEFINE_CONSTANT(target, kCryptoJobAsync); NODE_DEFINE_CONSTANT(target, kCryptoJobSync); NODE_DEFINE_CONSTANT(target, kCryptoJobWebCrypto); + NODE_DEFINE_CONSTANT(target, kCryptoJobSyncWebCrypto); SetMethod(context, target, "secureBuffer", SecureBuffer); SetMethodNoSideEffect(context, target, "secureHeapUsed", SecureHeapUsed); diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index 742f23b0f5e789..a1a48043a0bc59 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -249,10 +249,16 @@ class ByteSource final { : data_(data), allocated_data_(allocated_data), size_(size) {} }; -enum CryptoJobMode { kCryptoJobAsync, kCryptoJobSync, kCryptoJobWebCrypto }; +enum CryptoJobMode { + kCryptoJobAsync, + kCryptoJobSync, + kCryptoJobWebCrypto, + kCryptoJobSyncWebCrypto, +}; CryptoJobMode GetCryptoJobMode(v8::Local args); bool IsCryptoJobAsync(CryptoJobMode mode); +bool IsCryptoJobWebCrypto(CryptoJobMode mode); v8::MaybeLocal CreateWebCryptoJobError(Environment* env, v8::Local cause); @@ -274,9 +280,11 @@ class CryptoJob : public AsyncWrap, public ThreadPoolWork { ThreadPoolWork(env, "crypto"), mode_(mode), params_(std::move(params)) { - // If the CryptoJob is async, then the instance will be - // cleaned up when AfterThreadPoolWork is called. - if (mode == kCryptoJobSync) MakeWeak(); + // Async CryptoJobs are cleaned up when AfterThreadPoolWork is called. + // Sync jobs can be collected after run() returns. + if (mode == kCryptoJobSync || mode == kCryptoJobSyncWebCrypto) { + MakeWeak(); + } } bool IsNotIndicativeOfMemoryLeakAtExit() const override { @@ -401,6 +409,49 @@ class CryptoJob : public AsyncWrap, public ThreadPoolWork { return job->ScheduleWork(); } + if (job->mode() == kCryptoJobSyncWebCrypto) { + v8::Local resolver; + if (!v8::Promise::Resolver::New(env->context()).ToLocal(&resolver)) { + return; + } + + CHECK(job->resolver_.IsEmpty()); + job->resolver_.Reset(env->isolate(), resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + v8::Local err; + v8::Local result; + { + node::errors::TryCatchScope try_catch(env); + job->DoThreadPoolWork(); + if (job->ToResult(&err, &result).IsNothing()) { + CHECK(try_catch.HasCaught()); + CHECK(try_catch.CanContinue()); + err = try_catch.Exception(); + } + } + + if (!err.IsEmpty() && !err->IsUndefined()) { + job->RejectWebCrypto(err); + return; + } + + CHECK(!result.IsEmpty()); + v8::Local webcrypto_result; + { + node::errors::TryCatchScope try_catch(env); + if (!ToWebCryptoJobResult(env, result).ToLocal(&webcrypto_result)) { + CHECK(try_catch.HasCaught()); + CHECK(try_catch.CanContinue()); + job->RejectWebCrypto(try_catch.Exception()); + return; + } + } + + job->ResolveWebCrypto(webcrypto_result); + return; + } + if (job->mode() == kCryptoJobAsync) return job->ScheduleWork(); diff --git a/test/parallel/test-webcrypto-crypto-job-mode.js b/test/parallel/test-webcrypto-crypto-job-mode.js index 7fd50de44f39b6..b9df04ea9298e3 100644 --- a/test/parallel/test-webcrypto-crypto-job-mode.js +++ b/test/parallel/test-webcrypto-crypto-job-mode.js @@ -14,6 +14,8 @@ const { getCryptoKeyHandle, } = require('internal/crypto/keys'); const { + getWebCryptoJobModeForInputLength, + kWebCryptoDefaultSyncThreshold, getUsagesMask, } = require('internal/crypto/util'); const { @@ -25,6 +27,7 @@ const { EcKeyPairGenJob, HashJob, SecretKeyGenJob, + kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyVariantAES_CBC_128, kWebCryptoCipherEncrypt, @@ -61,6 +64,15 @@ async function withObjectPrototypeSetters(names, fn) { } (async function() { + { + assert.strictEqual( + getWebCryptoJobModeForInputLength(kWebCryptoDefaultSyncThreshold), + kCryptoJobSyncWebCrypto); + assert.strictEqual( + getWebCryptoJobModeForInputLength(kWebCryptoDefaultSyncThreshold + 1), + kCryptoJobWebCrypto); + } + { const promise = new HashJob( kCryptoJobWebCrypto, @@ -81,6 +93,26 @@ async function withObjectPrototypeSetters(names, fn) { assert.strictEqual(Object.hasOwn(digest, 'then'), false); } + { + const promise = new HashJob( + kCryptoJobSyncWebCrypto, + 'sha256', + Buffer.from('hello'), + undefined).run(); + + assert.strictEqual(Object.getPrototypeOf(promise), Promise.prototype); + + let settled = false; + promise.then(common.mustCall(() => { settled = true; })); + await Promise.resolve(); + assert.strictEqual(settled, true); + + const digest = await promise; + assert(digest instanceof ArrayBuffer); + assert.strictEqual(digest.byteLength, 32); + assert.strictEqual(Object.hasOwn(digest, 'then'), false); + } + { const key = await new SecretKeyGenJob( kCryptoJobWebCrypto, @@ -96,6 +128,29 @@ async function withObjectPrototypeSetters(names, fn) { assert.deepStrictEqual(key.usages, ['encrypt']); } + { + const promise = new SecretKeyGenJob( + kCryptoJobSyncWebCrypto, + 128, + { name: 'AES-CBC', length: 128 }, + getUsagesMask(new Set(['encrypt'])), + true).run(); + + assert.strictEqual(Object.getPrototypeOf(promise), Promise.prototype); + + let settled = false; + promise.then(common.mustCall(() => { settled = true; })); + await Promise.resolve(); + assert.strictEqual(settled, true); + + const key = await promise; + assert(isCryptoKey(key)); + assert(key instanceof CryptoKey); + assert.strictEqual(key.type, 'secret'); + assert.strictEqual(key.extractable, true); + assert.deepStrictEqual(key.usages, ['encrypt']); + } + { const pair = await withObjectPrototypeSetters( ['publicKey', 'privateKey'], @@ -120,6 +175,36 @@ async function withObjectPrototypeSetters(names, fn) { assert.deepStrictEqual(pair.privateKey.usages, ['sign']); } + { + const promise = new EcKeyPairGenJob( + kCryptoJobSyncWebCrypto, + 'P-256', + undefined, + { name: 'ECDSA', namedCurve: 'P-256' }, + getUsagesMask(new Set(['verify'])), + getUsagesMask(new Set(['sign'])), + true).run(); + + assert.strictEqual(Object.getPrototypeOf(promise), Promise.prototype); + + let settled = false; + promise.then(common.mustCall(() => { settled = true; })); + await Promise.resolve(); + assert.strictEqual(settled, true); + + const pair = await promise; + assert.strictEqual(Object.getPrototypeOf(pair), Object.prototype); + assert.strictEqual(Object.hasOwn(pair, 'then'), false); + assert(isCryptoKey(pair.publicKey)); + assert(isCryptoKey(pair.privateKey)); + assert(pair.publicKey instanceof CryptoKey); + assert(pair.privateKey instanceof CryptoKey); + assert.strictEqual(pair.publicKey.type, 'public'); + assert.strictEqual(pair.privateKey.type, 'private'); + assert.deepStrictEqual(pair.publicKey.usages, ['verify']); + assert.deepStrictEqual(pair.privateKey.usages, ['sign']); + } + { const key = await subtle.generateKey( { name: 'AES-CBC', length: 128 }, @@ -192,25 +277,6 @@ async function withObjectPrototypeSetters(names, fn) { 'boolean'); } - { - Object.defineProperty(CryptoKey.prototype, 'then', { - __proto__: null, - configurable: true, - get: common.mustNotCall('CryptoKey.prototype.then getter'), - }); - - try { - const key = await subtle.generateKey( - { name: 'AES-CBC', length: 128 }, - true, - ['encrypt']); - assert(isCryptoKey(key)); - assert.strictEqual(Object.hasOwn(key, 'then'), false); - } finally { - delete CryptoKey.prototype.then; - } - } - if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { const pair = await subtle.generateKey( { name: 'ML-KEM-768' }, diff --git a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs index 5c13561dc26063..c6ffcde0c26908 100644 --- a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs @@ -23,12 +23,23 @@ if (!common.hasCrypto) common.skip('missing crypto'); // The registry assertions at the bottom make missing updates fail loudly. const require = createRequire(import.meta.url); -const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { + kSupportedAlgorithms, + kWebCryptoDefaultSyncThreshold, +} = require('internal/crypto/util'); const { subtle } = globalThis.crypto; Promise.prototype.then = common.mustNotCall('Promise.prototype.then'); const data = new TextEncoder().encode('prototype pollution'); +const asyncData = new Uint8Array(kWebCryptoDefaultSyncThreshold + 1); +asyncData.set(data); +const asyncAesKwKeyData = new Uint8Array(kWebCryptoDefaultSyncThreshold + 8); + +let plaintextAssertionCount = 0; +let verificationAssertionCount = 0; +let assertPlaintextEquals; +let assertVerified; // WebCrypto methods return native promises. Re-wrapping a promise with // PromiseResolve() or chaining it with Promise.prototype.then can read @@ -369,6 +380,35 @@ async function getKeyToWrap() { return getKeyToWrap.key; } +async function getLargeKeyToWrap() { + if (getLargeKeyToWrap.key === undefined) { + getLargeKeyToWrap.key = await subtle.importKey( + 'raw-secret', + asyncAesKwKeyData, + algorithm('HMAC', { hash: 'SHA-256' }), + true, + ['sign']); + } + return getLargeKeyToWrap.key; +} + +async function getRawSecretWrapTargets() { + return [ + { + jobMode: 'sync', + key: await getKeyToWrap(), + importAlgorithm: algorithm('AES-CBC', { length: 128 }), + usages: ['encrypt'], + }, + { + jobMode: 'async', + key: await getLargeKeyToWrap(), + importAlgorithm: algorithm('HMAC', { hash: 'SHA-256' }), + usages: ['sign'], + }, + ]; +} + function addCommonKeyExportTests(fixture) { fixture.exportKey = async (ctx) => { if (fixture.rawFormat !== undefined) @@ -399,7 +439,7 @@ function secretKeyFixture(options) { const fixture = { ...options, generateKey: async (ctx) => { - ctx.key = await assertNoPromiseConstructorAccess(`generateKey ${options.name}`, () => + ctx.key = await assertCryptoKeyResult(`generateKey ${options.name}`, () => subtle.generateKey(options.generateAlgorithm, true, options.usages)); }, }; @@ -407,33 +447,86 @@ function secretKeyFixture(options) { addCommonKeyExportTests(fixture); if (options.encryptAlgorithm !== undefined) { + plaintextAssertionCount += 2; fixture.encrypt = async (ctx) => { - ctx.ciphertext = await assertNoPromiseConstructorAccess(`encrypt ${options.name}`, () => - subtle.encrypt(options.encryptAlgorithm, ctx.key, data)); + ctx.ciphertexts = { __proto__: null }; + for (const [jobMode, input] of [ + ['sync', data], + ['async', asyncData], + ]) { + ctx.ciphertexts[jobMode] = await assertNoInheritedArrayBufferThenAccess( + `encrypt ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `encrypt ${options.name} ${jobMode}`, + () => subtle.encrypt(options.encryptAlgorithm, ctx.key, input))); + } }; fixture.decrypt = async (ctx) => { - await assertNoPromiseConstructorAccess(`decrypt ${options.name}`, () => - subtle.decrypt(options.encryptAlgorithm, ctx.key, ctx.ciphertext)); + for (const [jobMode, input] of [ + ['sync', data], + ['async', asyncData], + ]) { + const plaintext = await assertNoInheritedArrayBufferThenAccess( + `decrypt ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `decrypt ${options.name} ${jobMode}`, + () => subtle.decrypt( + options.encryptAlgorithm, + ctx.key, + ctx.ciphertexts[jobMode]))); + assertPlaintextEquals(plaintext, input); + } }; } if (options.signAlgorithm !== undefined) { + verificationAssertionCount += 2; fixture.sign = async (ctx) => { - ctx.signature = await assertNoPromiseConstructorAccess(`sign ${options.name}`, () => - subtle.sign(options.signAlgorithm, ctx.key, data)); + ctx.signatures = { __proto__: null }; + for (const [jobMode, input] of [ + ['sync', data], + ['async', asyncData], + ]) { + ctx.signatures[jobMode] = await assertNoInheritedArrayBufferThenAccess( + `sign ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `sign ${options.name} ${jobMode}`, + () => subtle.sign(options.signAlgorithm, ctx.key, input))); + } }; fixture.verify = async (ctx) => { - await assertNoPromiseConstructorAccess(`verify ${options.name}`, () => - subtle.verify(options.signAlgorithm, ctx.key, ctx.signature, data)); + for (const [jobMode, input] of [ + ['sync', data], + ['async', asyncData], + ]) { + assertVerified(await assertNoPromiseConstructorAccess( + `verify ${options.name} ${jobMode}`, + () => subtle.verify( + options.signAlgorithm, + ctx.key, + ctx.signatures[jobMode], + input))); + } }; } if (options.wrapAlgorithm !== undefined) { fixture.wrapKey = async (ctx) => { - const keyToWrap = await getKeyToWrap(); - ctx.wrappedRawSecret = await assertNoPromiseConstructorAccess( - `wrapKey raw-secret ${options.name}`, - () => subtle.wrapKey('raw-secret', keyToWrap, ctx.key, options.wrapAlgorithm)); + ctx.rawSecretWrapTargets = await getRawSecretWrapTargets(); + ctx.wrappedRawSecrets = { __proto__: null }; + for (const { jobMode, key } of ctx.rawSecretWrapTargets) { + ctx.wrappedRawSecrets[jobMode] = + await assertNoInheritedArrayBufferThenAccess( + `wrapKey raw-secret ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `wrapKey raw-secret ${options.name} ${jobMode}`, + () => subtle.wrapKey( + 'raw-secret', + key, + ctx.key, + options.wrapAlgorithm))); + } + const keyToWrap = ctx.rawSecretWrapTargets[0].key; ctx.wrappedJwk = await assertNoInheritedJwkPropertyAccess( `wrapKey jwk ${options.name}`, () => assertNoInheritedToJSONAccess( @@ -444,16 +537,22 @@ function secretKeyFixture(options) { subtle.wrapKey('jwk', keyToWrap, ctx.key, options.wrapAlgorithm))))); }; fixture.unwrapKey = async (ctx) => { - await assertNoInheritedArrayBufferThenAccess(`unwrapKey raw-secret ${options.name}`, () => - assertCryptoKeyResult(`unwrapKey raw-secret ${options.name} result`, () => - subtle.unwrapKey( + for (const { + jobMode, + importAlgorithm, + usages, + } of ctx.rawSecretWrapTargets) { + await assertCryptoKeyResult( + `unwrapKey raw-secret ${options.name} ${jobMode} result`, + () => subtle.unwrapKey( 'raw-secret', - ctx.wrappedRawSecret, + ctx.wrappedRawSecrets[jobMode], ctx.key, options.wrapAlgorithm, - algorithm('AES-CBC', { length: 128 }), + importAlgorithm, true, - ['encrypt']))); + usages)); + } await assertNoUserMutableDecodeAccess(`unwrapKey jwk ${options.name}`, () => assertNoInheritedArrayBufferThenAccess(`unwrapKey jwk ${options.name}`, () => assertCryptoKeyResult(`unwrapKey jwk ${options.name} result`, () => @@ -475,8 +574,14 @@ function pairKeyFixture(options) { const fixture = { ...options, generateKey: async (ctx) => { - ctx.keyPair = await assertNoPromiseConstructorAccess(`generateKey ${options.name}`, () => - subtle.generateKey(options.generateAlgorithm, true, options.usages)); + ctx.keyPair = await assertNoInheritedObjectThenAccess( + `generateKey ${options.name}`, + () => assertNoPromiseConstructorAccess( + `generateKey ${options.name}`, + () => subtle.generateKey( + options.generateAlgorithm, + true, + options.usages))); }, exportKey: async (ctx) => { if (options.spki !== false) { @@ -552,12 +657,35 @@ function pairKeyFixture(options) { if (options.signAlgorithm !== undefined) { fixture.sign = async (ctx) => { - ctx.signature = await assertNoPromiseConstructorAccess(`sign ${options.name}`, () => - subtle.sign(options.signAlgorithm, ctx.keyPair.privateKey, data)); + ctx.signatures = { __proto__: null }; + const inputs = options.thresholdSignVerify === true ? + [ + ['sync', data], + ['async', asyncData], + ] : + [['default', data]]; + for (const [jobMode, input] of inputs) { + ctx.signatures[jobMode] = await assertNoInheritedArrayBufferThenAccess( + `sign ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess(`sign ${options.name} ${jobMode}`, () => + subtle.sign(options.signAlgorithm, ctx.keyPair.privateKey, input))); + } }; fixture.verify = async (ctx) => { - await assertNoPromiseConstructorAccess(`verify ${options.name}`, () => - subtle.verify(options.signAlgorithm, ctx.keyPair.publicKey, ctx.signature, data)); + const inputs = options.thresholdSignVerify === true ? + [ + ['sync', data], + ['async', asyncData], + ] : + [['default', data]]; + for (const [jobMode, input] of inputs) { + await assertNoPromiseConstructorAccess(`verify ${options.name} ${jobMode}`, () => + subtle.verify( + options.signAlgorithm, + ctx.keyPair.publicKey, + ctx.signatures[jobMode], + input)); + } }; } @@ -621,18 +749,31 @@ function kdfFixture(options) { ['deriveBits', 'deriveKey']); }, deriveBits: async (ctx) => { - await assertNoPromiseConstructorAccess(`deriveBits ${options.name}`, () => - subtle.deriveBits(options.deriveAlgorithm, ctx.key, 256)); + const deriveAlgorithms = options.deriveAlgorithms ?? [ + ['default', options.deriveAlgorithm], + ]; + for (const [jobMode, deriveAlgorithm] of deriveAlgorithms) { + await assertNoInheritedArrayBufferThenAccess( + `deriveBits ${options.name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `deriveBits ${options.name} ${jobMode}`, + () => subtle.deriveBits(deriveAlgorithm, ctx.key, 256))); + } }, extra: async (ctx) => { - await assertNoInheritedArrayBufferThenAccess(`deriveKey ${options.name}`, () => - assertCryptoKeyResult(`deriveKey ${options.name} result`, () => - subtle.deriveKey( - options.deriveAlgorithm, - ctx.key, - algorithm('AES-CBC', { length: 128 }), - true, - ['encrypt']))); + const deriveAlgorithms = options.deriveAlgorithms ?? [ + ['default', options.deriveAlgorithm], + ]; + for (const [jobMode, deriveAlgorithm] of deriveAlgorithms) { + await assertNoInheritedArrayBufferThenAccess(`deriveKey ${options.name} ${jobMode}`, () => + assertCryptoKeyResult(`deriveKey ${options.name} ${jobMode} result`, () => + subtle.deriveKey( + deriveAlgorithm, + ctx.key, + algorithm('AES-CBC', { length: 128 }), + true, + ['encrypt']))); + } }, }; } @@ -653,9 +794,10 @@ function kemFixture(options) { }); fixture.encapsulate = async (ctx) => { - ctx.encapsulatedBits = await assertNoPromiseConstructorAccess( - `encapsulateBits ${options.name}`, () => - subtle.encapsulateBits(algorithm(options.name), ctx.keyPair.publicKey)); + ctx.encapsulatedBits = await assertNoRawSharedKeyObjectThenAccess( + `encapsulateBits ${options.name}`, + () => assertNoPromiseConstructorAccess(`encapsulateBits ${options.name}`, () => + subtle.encapsulateBits(algorithm(options.name), ctx.keyPair.publicKey))); ctx.encapsulatedKey = await assertNoRawSharedKeyObjectThenAccess( `encapsulateKey ${options.name}`, () => assertNoPromiseConstructorAccess(`encapsulateKey ${options.name}`, () => @@ -775,6 +917,7 @@ for (const name of ['RSASSA-PKCS1-v1_5', 'RSA-PSS']) { signAlgorithm: name === 'RSA-PSS' ? algorithm(name, { saltLength: 32 }) : algorithm(name), + thresholdSignVerify: true, })); } @@ -797,6 +940,7 @@ addFixture('ECDSA', pairKeyFixture({ privateUsages: ['sign'], rawPublic: true, signAlgorithm: algorithm('ECDSA', { hash: 'SHA-256' }), + thresholdSignVerify: true, })); addFixture('ECDH', pairKeyFixture({ @@ -821,6 +965,7 @@ for (const name of ['Ed25519', 'Ed448']) { privateUsages: ['sign'], rawPublic: true, signAlgorithm: algorithm(name), + thresholdSignVerify: true, })); } @@ -880,6 +1025,18 @@ for (const name of ['HKDF', 'PBKDF2']) { salt: new Uint8Array(8), iterations: 1, }), + deriveAlgorithms: name === 'HKDF' ? [ + ['sync', algorithm(name, { + hash: 'SHA-256', + salt: new Uint8Array(8), + info: new Uint8Array(8), + })], + ['async', algorithm(name, { + hash: 'SHA-256', + salt: new Uint8Array(kWebCryptoDefaultSyncThreshold + 1), + info: new Uint8Array(8), + })], + ] : undefined, })); } @@ -931,8 +1088,16 @@ for (const name of [ addFixture(name, { name, digest: async () => { - await assertNoPromiseConstructorAccess(`digest ${name}`, () => - subtle.digest(digestAlgorithm(name), data)); + for (const [jobMode, input] of [ + ['sync', data], + ['async', asyncData], + ]) { + await assertNoInheritedArrayBufferThenAccess( + `digest ${name} ${jobMode}`, + () => assertNoPromiseConstructorAccess( + `digest ${name} ${jobMode}`, + () => subtle.digest(digestAlgorithm(name), input))); + } }, }); } @@ -1024,6 +1189,13 @@ for (const operation of Object.keys(kSupportedAlgorithms)) { } const supportedAlgorithms = getSupportedAlgorithmOperations(); +assertPlaintextEquals = common.mustCall((actual, expected) => { + assert.deepStrictEqual(new Uint8Array(actual), expected); +}, plaintextAssertionCount); +assertVerified = common.mustCall((actual) => { + assert.strictEqual(actual, true); +}, verificationAssertionCount); + for (const [name, operations] of supportedAlgorithms) { const fixture = fixtures.get(name); assert(fixture, `missing prototype pollution fixture for ${name}`); From 78c1a363e9b571ff9f495eaa3e3dd893bf4d8199 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 27 May 2026 14:02:25 +0200 Subject: [PATCH 3/4] wip --- .../_webcrypto_sync_fast_path_common.js | 43 ++++++++- lib/internal/crypto/aes.js | 32 ++++--- lib/internal/crypto/cfrg.js | 8 +- lib/internal/crypto/chacha20_poly1305.js | 14 +-- lib/internal/crypto/diffiehellman.js | 4 +- lib/internal/crypto/ec.js | 5 +- lib/internal/crypto/hkdf.js | 5 +- lib/internal/crypto/mac.js | 17 ++-- lib/internal/crypto/ml_dsa.js | 5 +- lib/internal/crypto/ml_kem.js | 11 ++- lib/internal/crypto/rsa.js | 4 +- lib/internal/crypto/util.js | 43 ++++++++- src/crypto/README.md | 5 +- src/crypto/crypto_util.h | 89 ++++++++++--------- 14 files changed, 199 insertions(+), 86 deletions(-) diff --git a/benchmark/crypto/_webcrypto_sync_fast_path_common.js b/benchmark/crypto/_webcrypto_sync_fast_path_common.js index 4a6a31ecce361e..1b0a60efad40e2 100644 --- a/benchmark/crypto/_webcrypto_sync_fast_path_common.js +++ b/benchmark/crypto/_webcrypto_sync_fast_path_common.js @@ -15,6 +15,8 @@ const kThresholdSizeLabels = [ 'after-threshold', ]; +const kParallelism = 4; + function ptn(size) { const buffer = Buffer.allocUnsafe(size); for (let i = 0; i < size; i++) { @@ -98,11 +100,44 @@ function isSupported(operation, algorithm) { async function measureAsync(bench, n, mode, fn) { if (mode === 'parallel') { - const promises = new Array(n); + const parallelism = Math.min(kParallelism, n); + let started = 0; + let completed = 0; bench.start(); - for (let i = 0; i < n; i++) - promises[i] = fn(i); - await Promise.all(promises); + await new Promise((resolve, reject) => { + let failed = false; + function fail(err) { + failed = true; + reject(err); + } + + function launch() { + if (failed) + return; + + const i = started++; + let promise; + try { + promise = fn(i); + } catch (err) { + fail(err); + return; + } + + Promise.resolve(promise).then(() => { + if (failed) + return; + if (++completed === n) { + resolve(); + } else if (started < n) { + launch(); + } + }, fail); + } + + for (let i = 0; i < parallelism; i++) + launch(); + }); bench.end(n); return; } diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index bac1f88b96005a..e952be4090313b 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -7,7 +7,7 @@ const { const { AESCipherJob, - kCryptoJobSyncWebCrypto, + kCryptoJobWebCrypto, kKeyVariantAES_CTR_128, kKeyVariantAES_CBC_128, kKeyVariantAES_GCM_128, @@ -28,9 +28,10 @@ const { const { getUsagesMask, - getWebCryptoJobModeForInputLength, + getWebCryptoJobMode, hasAnyNotIn, jobPromise, + kWebCryptoDefaultSyncThreshold, } = require('internal/crypto/util'); const { @@ -107,8 +108,10 @@ function getVariant(name, length) { } function asyncAesCtrCipher(mode, key, data, algorithm) { + const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( - getWebCryptoJobModeForInputLength(data.byteLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -118,8 +121,10 @@ function asyncAesCtrCipher(mode, key, data, algorithm) { } function asyncAesCbcCipher(mode, key, data, algorithm) { + const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( - getWebCryptoJobModeForInputLength(data.byteLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -128,8 +133,10 @@ function asyncAesCbcCipher(mode, key, data, algorithm) { } function asyncAesKwCipher(mode, key, data) { + const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( - getWebCryptoJobModeForInputLength(data.byteLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -140,10 +147,12 @@ function asyncAesGcmCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; const tagByteLength = tagLength / 8; const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; + const inputByteLength = data.byteLength + additionalDataLength; + const jobMode = inputByteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( - getWebCryptoJobModeForInputLength( - data.byteLength + additionalDataLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -157,10 +166,12 @@ function asyncAesOcbCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; const tagByteLength = tagLength / 8; const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; + const inputByteLength = data.byteLength + additionalDataLength; + const jobMode = inputByteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( - getWebCryptoJobModeForInputLength( - data.byteLength + additionalDataLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -199,8 +210,9 @@ function aesGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new SecretKeyGenJob( - kCryptoJobSyncWebCrypto, + jobMode, length, { name, length }, getUsagesMask(usagesSet), diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 6b80642f7bcb9b..b8785533a98b3f 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -8,7 +8,6 @@ const { const { SignJob, - kCryptoJobSyncWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, kSignJobModeSign, @@ -26,6 +25,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobMode, getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, @@ -131,8 +131,9 @@ function cfrgGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobSyncWebCrypto, + jobMode, nid, keyAlgorithm, getUsagesMask(publicUsages), @@ -239,8 +240,9 @@ function eddsaSignVerify(key, data, algorithm, signature) { if (getCryptoKeyType(key) !== type) throw lazyDOMException(`Key must be a ${type} key`, 'InvalidAccessError'); + const jobMode = getWebCryptoJobModeForInputLength(data.byteLength); return jobPromise(() => new SignJob( - getWebCryptoJobModeForInputLength(data.byteLength), + jobMode, mode, getCryptoKeyHandle(key), undefined, diff --git a/lib/internal/crypto/chacha20_poly1305.js b/lib/internal/crypto/chacha20_poly1305.js index 3fccefe98dc232..f69128605e36c2 100644 --- a/lib/internal/crypto/chacha20_poly1305.js +++ b/lib/internal/crypto/chacha20_poly1305.js @@ -6,15 +6,16 @@ const { const { ChaCha20Poly1305CipherJob, + kCryptoJobWebCrypto, SecretKeyGenJob, - kCryptoJobSyncWebCrypto, } = internalBinding('crypto'); const { getUsagesMask, - getWebCryptoJobModeForInputLength, + getWebCryptoJobMode, hasAnyNotIn, jobPromise, + kWebCryptoDefaultSyncThreshold, } = require('internal/crypto/util'); const { @@ -40,9 +41,11 @@ function validateKeyLength(length) { function c20pCipher(mode, key, data, algorithm) { const additionalDataLength = algorithm.additionalData?.byteLength ?? 0; + const inputByteLength = data.byteLength + additionalDataLength; + const jobMode = inputByteLength <= kWebCryptoDefaultSyncThreshold ? + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new ChaCha20Poly1305CipherJob( - getWebCryptoJobModeForInputLength( - data.byteLength + additionalDataLength), + jobMode, mode, getCryptoKeyHandle(key), data, @@ -67,8 +70,9 @@ function c20pGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new SecretKeyGenJob( - kCryptoJobSyncWebCrypto, + jobMode, 256, { name }, getUsagesMask(usagesSet), diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index 45456e7f9e27e8..feebbbb5dae28f 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -19,7 +19,6 @@ const { ECDHConvertKey: _ECDHConvertKey, kCryptoJobAsync, kCryptoJobSync, - kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, } = internalBinding('crypto'); @@ -58,6 +57,7 @@ const { const { getArrayBufferOrView, + getWebCryptoJobMode, jobPromise, jobPromiseThen, toBuf, @@ -356,7 +356,7 @@ function ecdhDeriveBits(algorithm, baseKey, length) { keyAlgorithm.name === 'X25519' || keyAlgorithm.name === 'X448' || (keyAlgorithm.name === 'ECDH' && keyAlgorithm.namedCurve === 'P-256') ? - kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; + getWebCryptoJobMode() : kCryptoJobWebCrypto; const bits = jobPromise(() => new DHBitsJob( jobMode, diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index 5e0e6c37dbed3c..e6e98e84607a36 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -10,7 +10,6 @@ const { EcKeyPairGenJob, KeyObjectHandle, SignJob, - kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, @@ -32,6 +31,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobMode, getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, @@ -121,8 +121,9 @@ function ecGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new EcKeyPairGenJob( - kCryptoJobSyncWebCrypto, + jobMode, namedCurve, undefined, keyAlgorithm, diff --git a/lib/internal/crypto/hkdf.js b/lib/internal/crypto/hkdf.js index 6c8e44c381e08f..6817a82a457d0f 100644 --- a/lib/internal/crypto/hkdf.js +++ b/lib/internal/crypto/hkdf.js @@ -156,9 +156,10 @@ function hkdfDeriveBits(algorithm, baseKey, length) { if (length === 0) return PromiseResolve(new ArrayBuffer(0)); + const jobMode = getWebCryptoJobModeForInputLength( + salt.byteLength + info.byteLength + length / 8); return jobPromise(() => new HKDFJob( - getWebCryptoJobModeForInputLength( - salt.byteLength + info.byteLength + length / 8), + jobMode, normalizeHashName(hash.name), getCryptoKeyHandle(baseKey), salt, diff --git a/lib/internal/crypto/mac.js b/lib/internal/crypto/mac.js index 6cfe4079af7332..4a589c28abf5ad 100644 --- a/lib/internal/crypto/mac.js +++ b/lib/internal/crypto/mac.js @@ -8,7 +8,6 @@ const { const { HmacJob, KmacJob, - kCryptoJobSyncWebCrypto, kSignJobModeSign, kSignJobModeVerify, SecretKeyGenJob, @@ -17,6 +16,7 @@ const { const { getBlockSize, getUsagesMask, + getWebCryptoJobMode, getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, @@ -60,8 +60,9 @@ function hmacGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new SecretKeyGenJob( - kCryptoJobSyncWebCrypto, + jobMode, length, { name, length, hash }, getUsagesMask(usageSet), @@ -90,8 +91,9 @@ function kmacGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new SecretKeyGenJob( - kCryptoJobSyncWebCrypto, + jobMode, length, { name, length }, getUsagesMask(usageSet), @@ -177,8 +179,10 @@ function macImportKey( function hmacSignVerify(key, data, algorithm, signature) { const mode = signature === undefined ? kSignJobModeSign : kSignJobModeVerify; const signatureLength = signature?.byteLength ?? 0; + const jobMode = getWebCryptoJobModeForInputLength( + data.byteLength + signatureLength); return jobPromise(() => new HmacJob( - getWebCryptoJobModeForInputLength(data.byteLength + signatureLength), + jobMode, mode, normalizeHashName(getCryptoKeyAlgorithm(key).hash.name), getCryptoKeyHandle(key), @@ -190,9 +194,10 @@ function kmacSignVerify(key, data, algorithm, signature) { const mode = signature === undefined ? kSignJobModeSign : kSignJobModeVerify; const signatureLength = signature?.byteLength ?? 0; const outputByteLength = algorithm.outputLength / 8; + const jobMode = getWebCryptoJobModeForInputLength( + data.byteLength + signatureLength + outputByteLength); return jobPromise(() => new KmacJob( - getWebCryptoJobModeForInputLength( - data.byteLength + signatureLength + outputByteLength), + jobMode, mode, getCryptoKeyHandle(key), algorithm.name, diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index e0528d9b90d990..4f81b7eaa5bcfd 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -9,7 +9,6 @@ const { const { SignJob, - kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kKeyFormatRawPublic, @@ -28,6 +27,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobMode, hasAnyNotIn, jobPromise, } = require('internal/crypto/util'); @@ -88,8 +88,9 @@ function mlDsaGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobSyncWebCrypto, + jobMode, nid, keyAlgorithm, getUsagesMask(publicUsages), diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index 66442a2eb54174..ace3f08e7dcaed 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -8,7 +8,6 @@ const { } = primordials; const { - kCryptoJobSyncWebCrypto, KEMDecapsulateJob, KEMEncapsulateJob, kKeyFormatDER, @@ -26,6 +25,7 @@ const { const { getUsagesMask, getUsagesUnion, + getWebCryptoJobMode, hasAnyNotIn, jobPromise, } = require('internal/crypto/util'); @@ -79,8 +79,9 @@ function mlKemGenerateKey(algorithm, extractable, usages) { 'SyntaxError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new NidKeyPairGenJob( - kCryptoJobSyncWebCrypto, + jobMode, nid, keyAlgorithm, getUsagesMask(publicUsages), @@ -211,8 +212,9 @@ function mlKemEncapsulate(encapsulationKey) { throw lazyDOMException(`Key must be a public key`, 'InvalidAccessError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new KEMEncapsulateJob( - kCryptoJobSyncWebCrypto, + jobMode, getCryptoKeyHandle(encapsulationKey), undefined, undefined, @@ -225,8 +227,9 @@ function mlKemDecapsulate(decapsulationKey, ciphertext) { throw lazyDOMException(`Key must be a private key`, 'InvalidAccessError'); } + const jobMode = getWebCryptoJobMode(); return jobPromise(() => new KEMDecapsulateJob( - kCryptoJobSyncWebCrypto, + jobMode, getCryptoKeyHandle(decapsulationKey), undefined, undefined, diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index b4fbe357f28928..c55ecf3e7305a5 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -10,7 +10,6 @@ const { const { RSACipherJob, SignJob, - kCryptoJobSyncWebCrypto, kCryptoJobWebCrypto, kKeyFormatDER, kSignJobModeSign, @@ -31,6 +30,7 @@ const { const { bigIntArrayToUnsignedInt, getDigestSizeInBytes, + getWebCryptoJobMode, getWebCryptoJobModeForInputLength, getUsagesMask, getUsagesUnion, @@ -102,7 +102,7 @@ function rsaOaepCipher(mode, key, data, algorithm) { const jobMode = mode === kWebCryptoCipherEncrypt && keyAlgorithm.modulusLength <= kWebCryptoRsaSyncMaxModulusLength ? - kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; + getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new RSACipherJob( jobMode, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 78cbfce1ac148b..9fe26c96fd2bc1 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -49,6 +49,10 @@ const { KmacJob, } = internalBinding('crypto'); +const { + enqueueMicrotask, +} = internalBinding('task_queue'); + const { getOptionValue } = require('internal/options'); const { @@ -692,10 +696,46 @@ function cleanupWebCryptoResult(value) { const kWebCryptoDefaultSyncThreshold = 64 * 1024; const kWebCryptoRsaSyncMaxModulusLength = 4096; +const kWebCryptoMaxSyncJobsPerTurn = 1; +const kWebCryptoParallelPressureAsyncJobs = 1024; + +let webCryptoParallelPressure = 0; +let webCryptoSyncJobsThisTurn = 0; +let webCryptoSyncJobResetScheduled = false; + +function resetWebCryptoSyncJobsThisTurn() { + webCryptoSyncJobsThisTurn = 0; + webCryptoSyncJobResetScheduled = false; +} + +function reserveWebCryptoSyncJob() { + if (webCryptoParallelPressure > 0) { + webCryptoParallelPressure--; + return false; + } + + if (webCryptoSyncJobsThisTurn >= kWebCryptoMaxSyncJobsPerTurn) { + webCryptoParallelPressure = kWebCryptoParallelPressureAsyncJobs; + return false; + } + + webCryptoSyncJobsThisTurn++; + if (!webCryptoSyncJobResetScheduled) { + webCryptoSyncJobResetScheduled = true; + enqueueMicrotask(resetWebCryptoSyncJobsThisTurn); + } + + return true; +} + +function getWebCryptoJobMode() { + return reserveWebCryptoSyncJob() ? + kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; +} function getWebCryptoJobModeForInputLength(byteLength) { return byteLength <= kWebCryptoDefaultSyncThreshold ? - kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; + getWebCryptoJobMode() : kCryptoJobWebCrypto; } // CryptoJob constructors can synchronously throw while running their native @@ -973,6 +1013,7 @@ module.exports = { kSupportedAlgorithms, kWebCryptoDefaultSyncThreshold, kWebCryptoRsaSyncMaxModulusLength, + getWebCryptoJobMode, getWebCryptoJobModeForInputLength, normalizeAlgorithm, normalizeHashName, diff --git a/src/crypto/README.md b/src/crypto/README.md index 5973f142671db3..de1322e1e561ae 100644 --- a/src/crypto/README.md +++ b/src/crypto/README.md @@ -255,7 +255,10 @@ the work synchronously on the current thread but still resolves or rejects the Promise using Web Crypto API semantics. This sync Web Crypto mode is used for selected small-input operations and selected low-cost public-key operations, plus Web Crypto symmetric -and non-RSA asymmetric key generation. +and non-RSA asymmetric key generation. Sync Web Crypto selection is +also limited by a small per-turn JavaScript budget so that sequential +`await` usage can take the fast path while same-turn fanout falls back +to asynchronous jobs. Operation-specific failures are rejected with an `OperationError`, and successful jobs resolve with the Web Crypto API result shape expected by the JavaScript implementation. diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index a1a48043a0bc59..e99b5b21267cc8 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -409,48 +409,8 @@ class CryptoJob : public AsyncWrap, public ThreadPoolWork { return job->ScheduleWork(); } - if (job->mode() == kCryptoJobSyncWebCrypto) { - v8::Local resolver; - if (!v8::Promise::Resolver::New(env->context()).ToLocal(&resolver)) { - return; - } - - CHECK(job->resolver_.IsEmpty()); - job->resolver_.Reset(env->isolate(), resolver); - args.GetReturnValue().Set(resolver->GetPromise()); - - v8::Local err; - v8::Local result; - { - node::errors::TryCatchScope try_catch(env); - job->DoThreadPoolWork(); - if (job->ToResult(&err, &result).IsNothing()) { - CHECK(try_catch.HasCaught()); - CHECK(try_catch.CanContinue()); - err = try_catch.Exception(); - } - } - - if (!err.IsEmpty() && !err->IsUndefined()) { - job->RejectWebCrypto(err); - return; - } - - CHECK(!result.IsEmpty()); - v8::Local webcrypto_result; - { - node::errors::TryCatchScope try_catch(env); - if (!ToWebCryptoJobResult(env, result).ToLocal(&webcrypto_result)) { - CHECK(try_catch.HasCaught()); - CHECK(try_catch.CanContinue()); - job->RejectWebCrypto(try_catch.Exception()); - return; - } - } - - job->ResolveWebCrypto(webcrypto_result); - return; - } + if (V8_UNLIKELY(job->mode() == kCryptoJobSyncWebCrypto)) + return RunSyncWebCrypto(job, env, args); if (job->mode() == kCryptoJobAsync) return job->ScheduleWork(); @@ -466,6 +426,51 @@ class CryptoJob : public AsyncWrap, public ThreadPoolWork { } } + static V8_NOINLINE void RunSyncWebCrypto( + CryptoJob* job, + Environment* env, + const v8::FunctionCallbackInfo& args) { + v8::Local resolver; + if (!v8::Promise::Resolver::New(env->context()).ToLocal(&resolver)) { + return; + } + + CHECK(job->resolver_.IsEmpty()); + job->resolver_.Reset(env->isolate(), resolver); + args.GetReturnValue().Set(resolver->GetPromise()); + + v8::Local err; + v8::Local result; + { + node::errors::TryCatchScope try_catch(env); + job->DoThreadPoolWork(); + if (job->ToResult(&err, &result).IsNothing()) { + CHECK(try_catch.HasCaught()); + CHECK(try_catch.CanContinue()); + err = try_catch.Exception(); + } + } + + if (!err.IsEmpty() && !err->IsUndefined()) { + job->RejectWebCrypto(err); + return; + } + + CHECK(!result.IsEmpty()); + v8::Local webcrypto_result; + { + node::errors::TryCatchScope try_catch(env); + if (!ToWebCryptoJobResult(env, result).ToLocal(&webcrypto_result)) { + CHECK(try_catch.HasCaught()); + CHECK(try_catch.CanContinue()); + job->RejectWebCrypto(try_catch.Exception()); + return; + } + } + + job->ResolveWebCrypto(webcrypto_result); + } + static void Initialize( v8::FunctionCallback new_fn, Environment* env, From ac28e2b6e45928a817f7f31038f2a4f11a05d499 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 27 May 2026 14:48:36 +0200 Subject: [PATCH 4/4] wip --- .../crypto/webcrypto-sync-fast-path-cipher.js | 36 +++++- .../crypto/webcrypto-sync-fast-path-kdf.js | 8 +- .../crypto/webcrypto-sync-fast-path-mac.js | 30 ++++- lib/internal/crypto/aes.js | 13 ++- lib/internal/crypto/hkdf.js | 4 +- lib/internal/crypto/mac.js | 4 +- lib/internal/crypto/util.js | 11 +- src/crypto/README.md | 108 +++++++++++++++--- 8 files changed, 183 insertions(+), 31 deletions(-) diff --git a/benchmark/crypto/webcrypto-sync-fast-path-cipher.js b/benchmark/crypto/webcrypto-sync-fast-path-cipher.js index 1be20617828cb8..fbcf59b3b9a5c1 100644 --- a/benchmark/crypto/webcrypto-sync-fast-path-cipher.js +++ b/benchmark/crypto/webcrypto-sync-fast-path-cipher.js @@ -5,6 +5,7 @@ const { importSecretKey, isSupported, kThresholdSizeLabels, + kWebCryptoSyncFastPathThreshold, measureAsync, ptn, thresholdSize, @@ -20,6 +21,8 @@ const keyAlgorithms = { 'ChaCha20-Poly1305': { name: 'ChaCha20-Poly1305' }, }; +const kAesCbcCtrEncryptSyncFastPathThreshold = 32 * 1024; + function supportParams(algorithm) { switch (algorithm) { case 'AES-CBC': @@ -90,22 +93,45 @@ function cipherParams(algorithm, size) { throw new Error(`Unknown cipher algorithm: ${algorithm}`); } -function dataSize(algorithm, size) { +function syncFastPathThreshold(algorithm, operation) { + return operation === 'encrypt' && + (algorithm === 'AES-CBC' || algorithm === 'AES-CTR') ? + kAesCbcCtrEncryptSyncFastPathThreshold : + kWebCryptoSyncFastPathThreshold; +} + +function measuredInputOverhead(algorithm, operation) { switch (algorithm) { - case 'AES-CBC': - return thresholdSize(size, { minimum: 16, multiple: 16 }); + case 'AES-GCM': + case 'AES-OCB': + case 'ChaCha20-Poly1305': + return operation === 'decrypt' ? 32 : 16; default: - return thresholdSize(size); + return 0; } } +function dataSize(algorithm, operation, size) { + const minimum = algorithm === 'AES-CBC' ? 16 : 1; + if (size === 'minimal') + return minimum; + + const overhead = measuredInputOverhead(algorithm, operation); + const multiple = algorithm === 'AES-CBC' ? 16 : 1; + return thresholdSize(size, { + minimum: minimum + overhead, + multiple, + threshold: syncFastPathThreshold(algorithm, operation), + }) - overhead; +} + async function setupCipherOperation(algorithm, operation, size) { const key = await importSecretKey({ algorithm: keyAlgorithms[algorithm], usages: ['encrypt', 'decrypt'], length: algorithm === 'ChaCha20-Poly1305' ? 32 : 16, }); - const data = ptn(dataSize(algorithm, size)); + const data = ptn(dataSize(algorithm, operation, size)); const params = cipherParams(algorithm, size); if (operation === 'encrypt') { diff --git a/benchmark/crypto/webcrypto-sync-fast-path-kdf.js b/benchmark/crypto/webcrypto-sync-fast-path-kdf.js index cc436f906237ee..5360176df8b467 100644 --- a/benchmark/crypto/webcrypto-sync-fast-path-kdf.js +++ b/benchmark/crypto/webcrypto-sync-fast-path-kdf.js @@ -4,13 +4,13 @@ const common = require('../common.js'); const { importSecretKey, kThresholdSizeLabels, - kWebCryptoSyncFastPathThreshold, measureAsync, ptn, } = require('./_webcrypto_sync_fast_path_common.js'); const { subtle } = globalThis.crypto; +const kHkdfSyncFastPathThreshold = 16 * 1024; const kOutputBytes = 32; const bench = common.createBenchmark(main, { @@ -25,11 +25,11 @@ function aggregateSize(label) { case 'minimal': return kOutputBytes; case 'middle': - return kWebCryptoSyncFastPathThreshold / 2; + return kHkdfSyncFastPathThreshold / 2; case 'at-threshold': - return kWebCryptoSyncFastPathThreshold; + return kHkdfSyncFastPathThreshold; case 'after-threshold': - return kWebCryptoSyncFastPathThreshold + 1; + return kHkdfSyncFastPathThreshold + 1; } throw new Error(`Unknown HKDF size label: ${label}`); } diff --git a/benchmark/crypto/webcrypto-sync-fast-path-mac.js b/benchmark/crypto/webcrypto-sync-fast-path-mac.js index e9396ab3e1bb2c..dd434f3aae7c24 100644 --- a/benchmark/crypto/webcrypto-sync-fast-path-mac.js +++ b/benchmark/crypto/webcrypto-sync-fast-path-mac.js @@ -5,6 +5,7 @@ const { importSecretKey, isSupported, kThresholdSizeLabels, + kWebCryptoSyncFastPathThreshold, measureAsync, ptn, thresholdSize, @@ -24,6 +25,8 @@ const signAlgorithms = { KMAC256: { name: 'KMAC256', outputLength: 256 }, }; +const kKmacSyncFastPathThreshold = 16 * 1024; + const algorithms = Object.keys(keyAlgorithms) .filter((name) => isSupported('sign', signAlgorithms[name])); @@ -40,13 +43,38 @@ const bench = common.createBenchmark(main, { n: [1e3], }); +function outputByteLength(algorithm) { + return algorithm === 'HMAC' ? 32 : signAlgorithms[algorithm].outputLength / 8; +} + +function syncFastPathThreshold(algorithm) { + return algorithm === 'HMAC' ? + kWebCryptoSyncFastPathThreshold : kKmacSyncFastPathThreshold; +} + +function dataSize(algorithm, operation, size) { + if (size === 'minimal') + return 1; + + let overhead = 0; + if (operation === 'verify') + overhead += outputByteLength(algorithm); + if (algorithm !== 'HMAC') + overhead += outputByteLength(algorithm); + + return thresholdSize(size, { + minimum: overhead + 1, + threshold: syncFastPathThreshold(algorithm), + }) - overhead; +} + async function setupMacOperation(algorithm, operation, size) { const key = await importSecretKey({ algorithm: keyAlgorithms[algorithm], usages: ['sign', 'verify'], length: 32, }); - const data = ptn(thresholdSize(size)); + const data = ptn(dataSize(algorithm, operation, size)); const params = signAlgorithms[algorithm]; if (operation === 'sign') { diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index e952be4090313b..be0ce26a56dd72 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -8,6 +8,7 @@ const { const { AESCipherJob, kCryptoJobWebCrypto, + kWebCryptoCipherEncrypt, kKeyVariantAES_CTR_128, kKeyVariantAES_CBC_128, kKeyVariantAES_GCM_128, @@ -34,6 +35,8 @@ const { kWebCryptoDefaultSyncThreshold, } = require('internal/crypto/util'); +const kWebCryptoAesCbcCtrEncryptSyncThreshold = 32 * 1024; + const { lazyDOMException, } = require('internal/util'); @@ -108,7 +111,10 @@ function getVariant(name, length) { } function asyncAesCtrCipher(mode, key, data, algorithm) { - const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ? + const threshold = mode === kWebCryptoCipherEncrypt ? + kWebCryptoAesCbcCtrEncryptSyncThreshold : + kWebCryptoDefaultSyncThreshold; + const jobMode = data.byteLength <= threshold ? getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( jobMode, @@ -121,7 +127,10 @@ function asyncAesCtrCipher(mode, key, data, algorithm) { } function asyncAesCbcCipher(mode, key, data, algorithm) { - const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ? + const threshold = mode === kWebCryptoCipherEncrypt ? + kWebCryptoAesCbcCtrEncryptSyncThreshold : + kWebCryptoDefaultSyncThreshold; + const jobMode = data.byteLength <= threshold ? getWebCryptoJobMode() : kCryptoJobWebCrypto; return jobPromise(() => new AESCipherJob( jobMode, diff --git a/lib/internal/crypto/hkdf.js b/lib/internal/crypto/hkdf.js index 6817a82a457d0f..91002b6ef3eae7 100644 --- a/lib/internal/crypto/hkdf.js +++ b/lib/internal/crypto/hkdf.js @@ -23,6 +23,7 @@ const { kMaxLength } = require('buffer'); const { getWebCryptoJobModeForInputLength, jobPromise, + kWebCryptoHkdfSyncThreshold, normalizeHashName, toBuf, validateByteSource, @@ -157,7 +158,8 @@ function hkdfDeriveBits(algorithm, baseKey, length) { return PromiseResolve(new ArrayBuffer(0)); const jobMode = getWebCryptoJobModeForInputLength( - salt.byteLength + info.byteLength + length / 8); + salt.byteLength + info.byteLength + length / 8, + kWebCryptoHkdfSyncThreshold); return jobPromise(() => new HKDFJob( jobMode, normalizeHashName(hash.name), diff --git a/lib/internal/crypto/mac.js b/lib/internal/crypto/mac.js index 4a589c28abf5ad..39abb8be5e44af 100644 --- a/lib/internal/crypto/mac.js +++ b/lib/internal/crypto/mac.js @@ -20,6 +20,7 @@ const { getWebCryptoJobModeForInputLength, hasAnyNotIn, jobPromise, + kWebCryptoKmacSyncThreshold, normalizeHashName, } = require('internal/crypto/util'); @@ -195,7 +196,8 @@ function kmacSignVerify(key, data, algorithm, signature) { const signatureLength = signature?.byteLength ?? 0; const outputByteLength = algorithm.outputLength / 8; const jobMode = getWebCryptoJobModeForInputLength( - data.byteLength + signatureLength + outputByteLength); + data.byteLength + signatureLength + outputByteLength, + kWebCryptoKmacSyncThreshold); return jobPromise(() => new KmacJob( jobMode, mode, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 9fe26c96fd2bc1..a6b01339522b8f 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -695,6 +695,8 @@ function cleanupWebCryptoResult(value) { } const kWebCryptoDefaultSyncThreshold = 64 * 1024; +const kWebCryptoHkdfSyncThreshold = 16 * 1024; +const kWebCryptoKmacSyncThreshold = 16 * 1024; const kWebCryptoRsaSyncMaxModulusLength = 4096; const kWebCryptoMaxSyncJobsPerTurn = 1; const kWebCryptoParallelPressureAsyncJobs = 1024; @@ -733,8 +735,11 @@ function getWebCryptoJobMode() { kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto; } -function getWebCryptoJobModeForInputLength(byteLength) { - return byteLength <= kWebCryptoDefaultSyncThreshold ? +function getWebCryptoJobModeForInputLength( + byteLength, + threshold = kWebCryptoDefaultSyncThreshold, +) { + return byteLength <= threshold ? getWebCryptoJobMode() : kCryptoJobWebCrypto; } @@ -1012,6 +1017,8 @@ module.exports = { kNamedCurveAliases, kSupportedAlgorithms, kWebCryptoDefaultSyncThreshold, + kWebCryptoHkdfSyncThreshold, + kWebCryptoKmacSyncThreshold, kWebCryptoRsaSyncMaxModulusLength, getWebCryptoJobMode, getWebCryptoJobModeForInputLength, diff --git a/src/crypto/README.md b/src/crypto/README.md index de1322e1e561ae..54d5b4df7f8382 100644 --- a/src/crypto/README.md +++ b/src/crypto/README.md @@ -252,21 +252,99 @@ If the `CryptoJob` is processed as a Web Crypto API job, then `run()` returns a Promise. `kCryptoJobWebCrypto` dispatches the work to the libuv threadpool. `kCryptoJobSyncWebCrypto` performs the work synchronously on the current thread but still resolves or -rejects the Promise using Web Crypto API semantics. This sync -Web Crypto mode is used for selected small-input operations and -selected low-cost public-key operations, plus Web Crypto symmetric -and non-RSA asymmetric key generation. Sync Web Crypto selection is -also limited by a small per-turn JavaScript budget so that sequential -`await` usage can take the fast path while same-turn fanout falls back -to asynchronous jobs. -Operation-specific failures are rejected with an `OperationError`, -and successful jobs resolve with the Web Crypto API result shape -expected by the JavaScript implementation. - -The JavaScript Web Crypto layer keeps the thresholds close to the -operation-specific call sites. The default small-input threshold is -64 KiB. RSA public-key operations are only selected for sync Web Crypto -mode with moduli up to 4096 bits. +rejects the Promise using Web Crypto API semantics. Operation-specific +failures are rejected with an `OperationError`, and successful jobs +resolve with the Web Crypto API result shape expected by the +JavaScript implementation. + +The JavaScript Web Crypto layer decides between `kCryptoJobWebCrypto` +and `kCryptoJobSyncWebCrypto` before the native job is constructed. +The decision must be made at that point because async jobs need +threadsafe copies of BufferSource input, while sync Web Crypto jobs can +borrow the caller's input for the duration of the immediate operation. +A native job cannot safely start as sync and later fall back to async +after it has been configured with sync-only input references. + +No Web Crypto operation is unconditionally sync. The sync path is only +a fast-path candidate. A candidate still becomes async when its input +is too large, when an operation-specific public-key limit rejects it, +or when the same JavaScript turn has already consumed the sync budget. + +The default small-input threshold is 64 KiB. Call sites may use lower +operation-specific thresholds when benchmarks show that parallel +throughput is more sensitive to the synchronous first job. Current +thresholds are: + +* 64 KiB by default. +* 32 KiB for AES-CBC and AES-CTR encrypt. +* 16 KiB for HKDF deriveBits. +* 16 KiB for KMAC sign and verify. + +The byte length being compared is the amount of operation input that +best predicts the native cost, not always a single Web Crypto argument: + +* Digest uses input data length plus output length. +* AES-CBC, AES-CTR, and AES-KW use input data length. +* AES-GCM, AES-OCB, and ChaCha20-Poly1305 use input data length plus + additional authenticated data length. For decrypt, input data already + includes the authentication tag. +* HMAC uses input data length plus signature length for verify. +* KMAC uses input data length plus signature length plus output length. +* HKDF uses salt length plus info length plus derived output length. +* ECDSA, EdDSA, and RSA verify use input data length. + +RSA public-key candidates are additionally limited to moduli up to +4096 bits. RSA-OAEP encrypt can be a sync candidate under that modulus +limit. RSA-OAEP decrypt, RSA sign, and RSA key generation remain +async. + +Some candidates do not have a byte-size threshold because their input +size is fixed or otherwise bounded by the operation. These include +Web Crypto symmetric key generation, non-RSA asymmetric key generation, +ML-KEM encapsulation and decapsulation, ECDH P-256 deriveBits, X25519 +deriveBits, X448 deriveBits, and RSA-OAEP encrypt with an allowed +modulus size. These are still only sync candidates; same-turn fanout +can force them async. + +The sync budget is intentionally small: at most one sync Web Crypto +candidate is allowed in a JavaScript turn. Here "turn" means the +continuous JavaScript execution slice before the next microtask +checkpoint. When the first candidate reserves the sync slot, Node.js +queues a microtask that resets the per-turn counter. Sequential +`await` usage yields to the microtask queue between operations, so it +can keep taking the sync fast path: + +```js +await subtle.digest('SHA-256', data); +await subtle.digest('SHA-256', data); +``` + +Same-turn fanout does not give the reset microtask a chance to run +between job creations: + +```js +const a = subtle.digest('SHA-256', data); +const b = subtle.digest('SHA-256', data); +await Promise.all([a, b]); +``` + +In that case the first eligible job may run sync, the second eligible +job runs async, and a short async pressure window is enabled. The +pressure window forces the next 1024 eligible candidates to async. If a +single synchronous burst creates more jobs than that, the per-turn sync +counter is still used, so the pressure window is re-armed instead of +letting another sync job through. + +The pressure window is a heuristic, not a Web Crypto API requirement. +It exists because a replenished Promise pipeline can otherwise run one +sync job from each Promise reaction while still trying to measure or +use parallel throughput. Once same-turn fanout has been observed, the +workload is treated as throughput-oriented for a while and candidates +are sent to the threadpool. This protects parallel throughput without +requiring precise active-job accounting in the native layer, but it +also means that a small fanout can make later otherwise-eligible jobs +async until the pressure counter drains. Changes to this policy should +be justified with serial and parallel Web Crypto benchmarks. For `CipherJob` types, the output is always an `ArrayBuffer`.