Skip to content

Commit 38aa1f3

Browse files
authored
crypto: avoid reusing randomInt() cache bytes
When an asynchronous randomInt() call finds the cache empty, it queues itself and starts randomFill() on the shared cache buffer. A synchronous call made before that job completes refills the same buffer with randomFillSync() and returns values from offset 0. The completion callback then reset the offset to 0, so the queued call and later calls returned the bytes that synchronous calls had already used. The threadpool job and randomFillSync() could also write the buffer at the same time. Fill a separate buffer asynchronously and copy it into the cache once the job is done. Fixes: #66595 Refs: #35110 Signed-off-by: Jiawen Geng <technicalcute@gmail.com> Assisted-by: claude:opus-5.5 PR-URL: #66597 Reviewed-By: Filip Skokan <panva.ip@gmail.com>
1 parent 49ad950 commit 38aa1f3

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

‎lib/internal/crypto/random.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525
TypedArrayPrototypeGetByteLength,
2626
TypedArrayPrototypeGetLength,
2727
TypedArrayPrototypeGetSymbolToStringTag,
28+
TypedArrayPrototypeSet,
2829
Uint8Array,
2930
} = primordials;
3031

@@ -233,6 +234,8 @@ const RAND_MAX = 0xFFFF_FFFF_FFFF;
233234
// divisible by 6 because each attempt to obtain a random int uses 6 bytes.
234235
const randomCache = new FastBuffer(6 * 1024);
235236
let randomCacheOffset = randomCache.length;
237+
// Asynchronous refills write into this buffer, never into randomCache.
238+
let asyncRandomCache;
236239
let asyncCacheFillInProgress = false;
237240
const asyncCachePendingTasks = [];
238241

@@ -313,13 +316,19 @@ function asyncRefillRandomIntCache() {
313316
return;
314317

315318
asyncCacheFillInProgress = true;
316-
randomFill(randomCache, (err) => {
319+
// Synchronous calls may refill and read randomCache while this job runs.
320+
// Filling a separate buffer and copying it in afterwards ensures that
321+
// resetting the offset never hands out bytes they have already used.
322+
asyncRandomCache ??= new FastBuffer(randomCache.length);
323+
randomFill(asyncRandomCache, (err) => {
317324
asyncCacheFillInProgress = false;
318325

319326
const tasks = asyncCachePendingTasks;
320327
const errorReceiver = err && ArrayPrototypeShift(tasks);
321-
if (!err)
328+
if (!err) {
329+
TypedArrayPrototypeSet(randomCache, asyncRandomCache);
322330
randomCacheOffset = 0;
331+
}
323332

324333
// Restart all pending tasks. If an error occurred, we only notify a single
325334
// callback (errorReceiver) about it. This way, every async call to
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
// Synchronous randomInt() calls made while an asynchronous cache refill is
8+
// pending must not cause the same random bytes to be returned twice.
9+
10+
const assert = require('assert');
11+
const { randomInt } = require('crypto');
12+
const { internalBinding } = require('internal/test/binding');
13+
const { RandomBytesJob } = internalBinding('crypto');
14+
15+
// With this range, every 6-byte draw except 0xffffffffffff is returned
16+
// unchanged, so a repeated value means repeated cache bytes.
17+
const max = 2 ** 48 - 1;
18+
const values = [];
19+
20+
// The refill job calls ondone on the main thread after the threadpool has
21+
// written its buffer. Make the synchronous calls at that point, before the
22+
// refill callback in randomInt() runs.
23+
const { run } = RandomBytesJob.prototype;
24+
RandomBytesJob.prototype.run = common.mustCall(function() {
25+
RandomBytesJob.prototype.run = run;
26+
const { ondone } = this;
27+
this.ondone = common.mustCall((...args) => {
28+
for (let i = 0; i < 3; i++)
29+
values.push(randomInt(max));
30+
ondone(...args);
31+
});
32+
run.call(this);
33+
});
34+
35+
// This is the first randomInt() call in the process, so the cache is empty.
36+
// The call is queued and an asynchronous refill starts.
37+
randomInt(max, common.mustSucceed((n) => {
38+
values.push(n);
39+
for (let i = 0; i < 3; i++)
40+
values.push(randomInt(max));
41+
assert.strictEqual(new Set(values).size, values.length,
42+
`duplicate values: ${values}`);
43+
}));

0 commit comments

Comments
 (0)