Skip to content

Commit ac28e2b

Browse files
committed
wip
1 parent 78c1a36 commit ac28e2b

8 files changed

Lines changed: 183 additions & 31 deletions

File tree

benchmark/crypto/webcrypto-sync-fast-path-cipher.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
importSecretKey,
66
isSupported,
77
kThresholdSizeLabels,
8+
kWebCryptoSyncFastPathThreshold,
89
measureAsync,
910
ptn,
1011
thresholdSize,
@@ -20,6 +21,8 @@ const keyAlgorithms = {
2021
'ChaCha20-Poly1305': { name: 'ChaCha20-Poly1305' },
2122
};
2223

24+
const kAesCbcCtrEncryptSyncFastPathThreshold = 32 * 1024;
25+
2326
function supportParams(algorithm) {
2427
switch (algorithm) {
2528
case 'AES-CBC':
@@ -90,22 +93,45 @@ function cipherParams(algorithm, size) {
9093
throw new Error(`Unknown cipher algorithm: ${algorithm}`);
9194
}
9295

93-
function dataSize(algorithm, size) {
96+
function syncFastPathThreshold(algorithm, operation) {
97+
return operation === 'encrypt' &&
98+
(algorithm === 'AES-CBC' || algorithm === 'AES-CTR') ?
99+
kAesCbcCtrEncryptSyncFastPathThreshold :
100+
kWebCryptoSyncFastPathThreshold;
101+
}
102+
103+
function measuredInputOverhead(algorithm, operation) {
94104
switch (algorithm) {
95-
case 'AES-CBC':
96-
return thresholdSize(size, { minimum: 16, multiple: 16 });
105+
case 'AES-GCM':
106+
case 'AES-OCB':
107+
case 'ChaCha20-Poly1305':
108+
return operation === 'decrypt' ? 32 : 16;
97109
default:
98-
return thresholdSize(size);
110+
return 0;
99111
}
100112
}
101113

114+
function dataSize(algorithm, operation, size) {
115+
const minimum = algorithm === 'AES-CBC' ? 16 : 1;
116+
if (size === 'minimal')
117+
return minimum;
118+
119+
const overhead = measuredInputOverhead(algorithm, operation);
120+
const multiple = algorithm === 'AES-CBC' ? 16 : 1;
121+
return thresholdSize(size, {
122+
minimum: minimum + overhead,
123+
multiple,
124+
threshold: syncFastPathThreshold(algorithm, operation),
125+
}) - overhead;
126+
}
127+
102128
async function setupCipherOperation(algorithm, operation, size) {
103129
const key = await importSecretKey({
104130
algorithm: keyAlgorithms[algorithm],
105131
usages: ['encrypt', 'decrypt'],
106132
length: algorithm === 'ChaCha20-Poly1305' ? 32 : 16,
107133
});
108-
const data = ptn(dataSize(algorithm, size));
134+
const data = ptn(dataSize(algorithm, operation, size));
109135

110136
const params = cipherParams(algorithm, size);
111137
if (operation === 'encrypt') {

benchmark/crypto/webcrypto-sync-fast-path-kdf.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ const common = require('../common.js');
44
const {
55
importSecretKey,
66
kThresholdSizeLabels,
7-
kWebCryptoSyncFastPathThreshold,
87
measureAsync,
98
ptn,
109
} = require('./_webcrypto_sync_fast_path_common.js');
1110

1211
const { subtle } = globalThis.crypto;
1312

13+
const kHkdfSyncFastPathThreshold = 16 * 1024;
1414
const kOutputBytes = 32;
1515

1616
const bench = common.createBenchmark(main, {
@@ -25,11 +25,11 @@ function aggregateSize(label) {
2525
case 'minimal':
2626
return kOutputBytes;
2727
case 'middle':
28-
return kWebCryptoSyncFastPathThreshold / 2;
28+
return kHkdfSyncFastPathThreshold / 2;
2929
case 'at-threshold':
30-
return kWebCryptoSyncFastPathThreshold;
30+
return kHkdfSyncFastPathThreshold;
3131
case 'after-threshold':
32-
return kWebCryptoSyncFastPathThreshold + 1;
32+
return kHkdfSyncFastPathThreshold + 1;
3333
}
3434
throw new Error(`Unknown HKDF size label: ${label}`);
3535
}

benchmark/crypto/webcrypto-sync-fast-path-mac.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
importSecretKey,
66
isSupported,
77
kThresholdSizeLabels,
8+
kWebCryptoSyncFastPathThreshold,
89
measureAsync,
910
ptn,
1011
thresholdSize,
@@ -24,6 +25,8 @@ const signAlgorithms = {
2425
KMAC256: { name: 'KMAC256', outputLength: 256 },
2526
};
2627

28+
const kKmacSyncFastPathThreshold = 16 * 1024;
29+
2730
const algorithms = Object.keys(keyAlgorithms)
2831
.filter((name) => isSupported('sign', signAlgorithms[name]));
2932

@@ -40,13 +43,38 @@ const bench = common.createBenchmark(main, {
4043
n: [1e3],
4144
});
4245

46+
function outputByteLength(algorithm) {
47+
return algorithm === 'HMAC' ? 32 : signAlgorithms[algorithm].outputLength / 8;
48+
}
49+
50+
function syncFastPathThreshold(algorithm) {
51+
return algorithm === 'HMAC' ?
52+
kWebCryptoSyncFastPathThreshold : kKmacSyncFastPathThreshold;
53+
}
54+
55+
function dataSize(algorithm, operation, size) {
56+
if (size === 'minimal')
57+
return 1;
58+
59+
let overhead = 0;
60+
if (operation === 'verify')
61+
overhead += outputByteLength(algorithm);
62+
if (algorithm !== 'HMAC')
63+
overhead += outputByteLength(algorithm);
64+
65+
return thresholdSize(size, {
66+
minimum: overhead + 1,
67+
threshold: syncFastPathThreshold(algorithm),
68+
}) - overhead;
69+
}
70+
4371
async function setupMacOperation(algorithm, operation, size) {
4472
const key = await importSecretKey({
4573
algorithm: keyAlgorithms[algorithm],
4674
usages: ['sign', 'verify'],
4775
length: 32,
4876
});
49-
const data = ptn(thresholdSize(size));
77+
const data = ptn(dataSize(algorithm, operation, size));
5078
const params = signAlgorithms[algorithm];
5179

5280
if (operation === 'sign') {

lib/internal/crypto/aes.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const {
88
const {
99
AESCipherJob,
1010
kCryptoJobWebCrypto,
11+
kWebCryptoCipherEncrypt,
1112
kKeyVariantAES_CTR_128,
1213
kKeyVariantAES_CBC_128,
1314
kKeyVariantAES_GCM_128,
@@ -34,6 +35,8 @@ const {
3435
kWebCryptoDefaultSyncThreshold,
3536
} = require('internal/crypto/util');
3637

38+
const kWebCryptoAesCbcCtrEncryptSyncThreshold = 32 * 1024;
39+
3740
const {
3841
lazyDOMException,
3942
} = require('internal/util');
@@ -108,7 +111,10 @@ function getVariant(name, length) {
108111
}
109112

110113
function asyncAesCtrCipher(mode, key, data, algorithm) {
111-
const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ?
114+
const threshold = mode === kWebCryptoCipherEncrypt ?
115+
kWebCryptoAesCbcCtrEncryptSyncThreshold :
116+
kWebCryptoDefaultSyncThreshold;
117+
const jobMode = data.byteLength <= threshold ?
112118
getWebCryptoJobMode() : kCryptoJobWebCrypto;
113119
return jobPromise(() => new AESCipherJob(
114120
jobMode,
@@ -121,7 +127,10 @@ function asyncAesCtrCipher(mode, key, data, algorithm) {
121127
}
122128

123129
function asyncAesCbcCipher(mode, key, data, algorithm) {
124-
const jobMode = data.byteLength <= kWebCryptoDefaultSyncThreshold ?
130+
const threshold = mode === kWebCryptoCipherEncrypt ?
131+
kWebCryptoAesCbcCtrEncryptSyncThreshold :
132+
kWebCryptoDefaultSyncThreshold;
133+
const jobMode = data.byteLength <= threshold ?
125134
getWebCryptoJobMode() : kCryptoJobWebCrypto;
126135
return jobPromise(() => new AESCipherJob(
127136
jobMode,

lib/internal/crypto/hkdf.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const { kMaxLength } = require('buffer');
2323
const {
2424
getWebCryptoJobModeForInputLength,
2525
jobPromise,
26+
kWebCryptoHkdfSyncThreshold,
2627
normalizeHashName,
2728
toBuf,
2829
validateByteSource,
@@ -157,7 +158,8 @@ function hkdfDeriveBits(algorithm, baseKey, length) {
157158
return PromiseResolve(new ArrayBuffer(0));
158159

159160
const jobMode = getWebCryptoJobModeForInputLength(
160-
salt.byteLength + info.byteLength + length / 8);
161+
salt.byteLength + info.byteLength + length / 8,
162+
kWebCryptoHkdfSyncThreshold);
161163
return jobPromise(() => new HKDFJob(
162164
jobMode,
163165
normalizeHashName(hash.name),

lib/internal/crypto/mac.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const {
2020
getWebCryptoJobModeForInputLength,
2121
hasAnyNotIn,
2222
jobPromise,
23+
kWebCryptoKmacSyncThreshold,
2324
normalizeHashName,
2425
} = require('internal/crypto/util');
2526

@@ -195,7 +196,8 @@ function kmacSignVerify(key, data, algorithm, signature) {
195196
const signatureLength = signature?.byteLength ?? 0;
196197
const outputByteLength = algorithm.outputLength / 8;
197198
const jobMode = getWebCryptoJobModeForInputLength(
198-
data.byteLength + signatureLength + outputByteLength);
199+
data.byteLength + signatureLength + outputByteLength,
200+
kWebCryptoKmacSyncThreshold);
199201
return jobPromise(() => new KmacJob(
200202
jobMode,
201203
mode,

lib/internal/crypto/util.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,8 @@ function cleanupWebCryptoResult(value) {
695695
}
696696

697697
const kWebCryptoDefaultSyncThreshold = 64 * 1024;
698+
const kWebCryptoHkdfSyncThreshold = 16 * 1024;
699+
const kWebCryptoKmacSyncThreshold = 16 * 1024;
698700
const kWebCryptoRsaSyncMaxModulusLength = 4096;
699701
const kWebCryptoMaxSyncJobsPerTurn = 1;
700702
const kWebCryptoParallelPressureAsyncJobs = 1024;
@@ -733,8 +735,11 @@ function getWebCryptoJobMode() {
733735
kCryptoJobSyncWebCrypto : kCryptoJobWebCrypto;
734736
}
735737

736-
function getWebCryptoJobModeForInputLength(byteLength) {
737-
return byteLength <= kWebCryptoDefaultSyncThreshold ?
738+
function getWebCryptoJobModeForInputLength(
739+
byteLength,
740+
threshold = kWebCryptoDefaultSyncThreshold,
741+
) {
742+
return byteLength <= threshold ?
738743
getWebCryptoJobMode() : kCryptoJobWebCrypto;
739744
}
740745

@@ -1012,6 +1017,8 @@ module.exports = {
10121017
kNamedCurveAliases,
10131018
kSupportedAlgorithms,
10141019
kWebCryptoDefaultSyncThreshold,
1020+
kWebCryptoHkdfSyncThreshold,
1021+
kWebCryptoKmacSyncThreshold,
10151022
kWebCryptoRsaSyncMaxModulusLength,
10161023
getWebCryptoJobMode,
10171024
getWebCryptoJobModeForInputLength,

src/crypto/README.md

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -252,21 +252,99 @@ If the `CryptoJob` is processed as a Web Crypto API job, then
252252
`run()` returns a Promise. `kCryptoJobWebCrypto` dispatches the
253253
work to the libuv threadpool. `kCryptoJobSyncWebCrypto` performs
254254
the work synchronously on the current thread but still resolves or
255-
rejects the Promise using Web Crypto API semantics. This sync
256-
Web Crypto mode is used for selected small-input operations and
257-
selected low-cost public-key operations, plus Web Crypto symmetric
258-
and non-RSA asymmetric key generation. Sync Web Crypto selection is
259-
also limited by a small per-turn JavaScript budget so that sequential
260-
`await` usage can take the fast path while same-turn fanout falls back
261-
to asynchronous jobs.
262-
Operation-specific failures are rejected with an `OperationError`,
263-
and successful jobs resolve with the Web Crypto API result shape
264-
expected by the JavaScript implementation.
265-
266-
The JavaScript Web Crypto layer keeps the thresholds close to the
267-
operation-specific call sites. The default small-input threshold is
268-
64 KiB. RSA public-key operations are only selected for sync Web Crypto
269-
mode with moduli up to 4096 bits.
255+
rejects the Promise using Web Crypto API semantics. Operation-specific
256+
failures are rejected with an `OperationError`, and successful jobs
257+
resolve with the Web Crypto API result shape expected by the
258+
JavaScript implementation.
259+
260+
The JavaScript Web Crypto layer decides between `kCryptoJobWebCrypto`
261+
and `kCryptoJobSyncWebCrypto` before the native job is constructed.
262+
The decision must be made at that point because async jobs need
263+
threadsafe copies of BufferSource input, while sync Web Crypto jobs can
264+
borrow the caller's input for the duration of the immediate operation.
265+
A native job cannot safely start as sync and later fall back to async
266+
after it has been configured with sync-only input references.
267+
268+
No Web Crypto operation is unconditionally sync. The sync path is only
269+
a fast-path candidate. A candidate still becomes async when its input
270+
is too large, when an operation-specific public-key limit rejects it,
271+
or when the same JavaScript turn has already consumed the sync budget.
272+
273+
The default small-input threshold is 64 KiB. Call sites may use lower
274+
operation-specific thresholds when benchmarks show that parallel
275+
throughput is more sensitive to the synchronous first job. Current
276+
thresholds are:
277+
278+
* 64 KiB by default.
279+
* 32 KiB for AES-CBC and AES-CTR encrypt.
280+
* 16 KiB for HKDF deriveBits.
281+
* 16 KiB for KMAC sign and verify.
282+
283+
The byte length being compared is the amount of operation input that
284+
best predicts the native cost, not always a single Web Crypto argument:
285+
286+
* Digest uses input data length plus output length.
287+
* AES-CBC, AES-CTR, and AES-KW use input data length.
288+
* AES-GCM, AES-OCB, and ChaCha20-Poly1305 use input data length plus
289+
additional authenticated data length. For decrypt, input data already
290+
includes the authentication tag.
291+
* HMAC uses input data length plus signature length for verify.
292+
* KMAC uses input data length plus signature length plus output length.
293+
* HKDF uses salt length plus info length plus derived output length.
294+
* ECDSA, EdDSA, and RSA verify use input data length.
295+
296+
RSA public-key candidates are additionally limited to moduli up to
297+
4096 bits. RSA-OAEP encrypt can be a sync candidate under that modulus
298+
limit. RSA-OAEP decrypt, RSA sign, and RSA key generation remain
299+
async.
300+
301+
Some candidates do not have a byte-size threshold because their input
302+
size is fixed or otherwise bounded by the operation. These include
303+
Web Crypto symmetric key generation, non-RSA asymmetric key generation,
304+
ML-KEM encapsulation and decapsulation, ECDH P-256 deriveBits, X25519
305+
deriveBits, X448 deriveBits, and RSA-OAEP encrypt with an allowed
306+
modulus size. These are still only sync candidates; same-turn fanout
307+
can force them async.
308+
309+
The sync budget is intentionally small: at most one sync Web Crypto
310+
candidate is allowed in a JavaScript turn. Here "turn" means the
311+
continuous JavaScript execution slice before the next microtask
312+
checkpoint. When the first candidate reserves the sync slot, Node.js
313+
queues a microtask that resets the per-turn counter. Sequential
314+
`await` usage yields to the microtask queue between operations, so it
315+
can keep taking the sync fast path:
316+
317+
```js
318+
await subtle.digest('SHA-256', data);
319+
await subtle.digest('SHA-256', data);
320+
```
321+
322+
Same-turn fanout does not give the reset microtask a chance to run
323+
between job creations:
324+
325+
```js
326+
const a = subtle.digest('SHA-256', data);
327+
const b = subtle.digest('SHA-256', data);
328+
await Promise.all([a, b]);
329+
```
330+
331+
In that case the first eligible job may run sync, the second eligible
332+
job runs async, and a short async pressure window is enabled. The
333+
pressure window forces the next 1024 eligible candidates to async. If a
334+
single synchronous burst creates more jobs than that, the per-turn sync
335+
counter is still used, so the pressure window is re-armed instead of
336+
letting another sync job through.
337+
338+
The pressure window is a heuristic, not a Web Crypto API requirement.
339+
It exists because a replenished Promise pipeline can otherwise run one
340+
sync job from each Promise reaction while still trying to measure or
341+
use parallel throughput. Once same-turn fanout has been observed, the
342+
workload is treated as throughput-oriented for a while and candidates
343+
are sent to the threadpool. This protects parallel throughput without
344+
requiring precise active-job accounting in the native layer, but it
345+
also means that a small fanout can make later otherwise-eligible jobs
346+
async until the pressure counter drains. Changes to this policy should
347+
be justified with serial and parallel Web Crypto benchmarks.
270348

271349
For `CipherJob` types, the output is always an `ArrayBuffer`.
272350

0 commit comments

Comments
 (0)