Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

crypto: add randomInt function #34600

Closed
wants to merge 11 commits into from
39 changes: 39 additions & 0 deletions doc/api/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,44 @@ threadpool request. To minimize threadpool task length variation, partition
large `randomFill` requests when doing so as part of fulfilling a client
request.

### `crypto.randomInt([min, ]max[, callback])`
<!-- YAML
added: REPLACEME
-->

* `min` {integer} Start of random range (inclusive). **Default**: `0`.
* `max` {integer} End of random range (exclusive).
* `callback` {Function} `function(err, n) {}`.

Return a random integer `n` such that `min <= n < max`. This
implementation avoids [modulo bias][].

The range (`max - min`) must be less than `2^48`. `min` and `max` must
be safe integers.

If the `callback` function is not provided, the random integer is
generated synchronously.

```js
// Asynchronous
crypto.randomInt(3, (err, n) => {
if (err) throw err;
console.log(`Random number chosen from (0, 1, 2): ${n}`);
});
```

```js
// Synchronous
const n = crypto.randomInt(3);
console.log(`Random number chosen from (0, 1, 2): ${n}`);
```

```js
// With `min` argument
const n = crypto.randomInt(1, 7);
console.log(`The dice rolled: ${n}`);
```

### `crypto.scrypt(password, salt, keylen[, options], callback)`
<!-- YAML
added: v10.5.0
Expand Down Expand Up @@ -3547,6 +3585,7 @@ See the [list of SSL OP Flags][] for details.
[NIST SP 800-131A]: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar1.pdf
[NIST SP 800-132]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf
[NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
[modulo bias]: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#Modulo_bias
[Nonce-Disrespecting Adversaries]: https://github.com/nonce-disrespect/nonce-disrespect
[OpenSSL's SPKAC implementation]: https://www.openssl.org/docs/man1.1.0/apps/openssl-spkac.html
[RFC 1421]: https://www.rfc-editor.org/rfc/rfc1421.txt
Expand Down
4 changes: 3 additions & 1 deletion lib/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ const {
const {
randomBytes,
randomFill,
randomFillSync
randomFillSync,
randomInt
} = require('internal/crypto/random');
const {
pbkdf2,
Expand Down Expand Up @@ -184,6 +185,7 @@ module.exports = {
randomBytes,
randomFill,
randomFillSync,
randomInt,
scrypt,
scryptSync,
sign: signOneShot,
Expand Down
80 changes: 79 additions & 1 deletion lib/internal/crypto/random.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const {
MathMin,
NumberIsNaN,
NumberIsSafeInteger
} = primordials;

const { AsyncWrap, Providers } = internalBinding('async_wrap');
Expand Down Expand Up @@ -119,6 +120,82 @@ function randomFill(buf, offset, size, cb) {
_randomBytes(buf, offset, size, wrap);
}

// Largest integer we can read from a buffer.
// e.g.: Buffer.from("ff".repeat(6), "hex").readUIntBE(0, 6);
const RAND_MAX = 0xFFFF_FFFF_FFFF;

// Generates an integer in [min, max) range where min is inclusive and max is
// exclusive.
function randomInt(min, max, cb) {
addaleax marked this conversation as resolved.
Show resolved Hide resolved
jasnell marked this conversation as resolved.
Show resolved Hide resolved
// Detect optional min syntax
// randomInt(max)
// randomInt(max, cb)
const minNotSpecified = typeof max === 'undefined' ||
typeof max === 'function';

if (minNotSpecified) {
cb = max;
max = min;
min = 0;
}

const isSync = typeof cb === 'undefined';
if (!isSync && typeof cb !== 'function') {
throw new ERR_INVALID_CALLBACK(cb);
}
if (!NumberIsSafeInteger(min)) {
throw new ERR_INVALID_ARG_TYPE('min', 'safe integer', min);
}
if (!NumberIsSafeInteger(max)) {
throw new ERR_INVALID_ARG_TYPE('max', 'safe integer', max);
}
addaleax marked this conversation as resolved.
Show resolved Hide resolved
if (!(max >= min)) {
throw new ERR_OUT_OF_RANGE('max', `>= ${min}`, max);
}

// First we generate a random int between [0..range)
const range = max - min;

if (!(range <= RAND_MAX)) {
addaleax marked this conversation as resolved.
Show resolved Hide resolved
throw new ERR_OUT_OF_RANGE(`max${minNotSpecified ? '' : ' - min'}`,
`<= ${RAND_MAX}`, range);
}
jasnell marked this conversation as resolved.
Show resolved Hide resolved

const excess = RAND_MAX % range;
const randLimit = RAND_MAX - excess;

if (isSync) {
// Sync API
while (true) {
jasnell marked this conversation as resolved.
Show resolved Hide resolved
const x = randomBytes(6).readUIntBE(0, 6);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance suggestion: allocate a buffer once with new FastBuffer(6) and use randomFillSync() to fill it up, that avoids creating potentially many throwaway buffers.

(I recognize that on average we'll get lucky 50% or more on the first try so consider this a take-it-or-leave-it suggestion, not a must.)

// If x > (maxVal - (maxVal % range)), we will get "modulo bias"
if (x > randLimit) {
// Try again
continue;
}
tniessen marked this conversation as resolved.
Show resolved Hide resolved
const n = (x % range) + min;
return n;
}
} else {
// Async API
const pickAttempt = () => {
randomBytes(6, (err, bytes) => {
if (err) return cb(err);
const x = bytes.readUIntBE(0, 6);
// If x > (maxVal - (maxVal % range)), we will get "modulo bias"
if (x > randLimit) {
// Try again
return pickAttempt();
}
const n = (x % range) + min;
cb(null, n);
});
};

pickAttempt();
}
}

function handleError(ex, buf) {
if (ex) throw ex;
return buf;
Expand All @@ -127,5 +204,6 @@ function handleError(ex, buf) {
module.exports = {
randomBytes,
randomFill,
randomFillSync
randomFillSync,
randomInt
};
180 changes: 180 additions & 0 deletions test/parallel/test-crypto-random.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,183 @@ assert.throws(
assert.strictEqual(desc.writable, true);
assert.strictEqual(desc.enumerable, false);
});


{
// Asynchronous API
const randomInts = [];
for (let i = 0; i < 100; i++) {
crypto.randomInt(3, common.mustCall((err, n) => {
assert.ifError(err);
assert.ok(n >= 0);
assert.ok(n < 3);
randomInts.push(n);
if (randomInts.length === 100) {
assert.ok(!randomInts.includes(-1));
assert.ok(randomInts.includes(0));
assert.ok(randomInts.includes(1));
assert.ok(randomInts.includes(2));
assert.ok(!randomInts.includes(3));
}
}));
}
}
{
// Synchronous API
const randomInts = [];
for (let i = 0; i < 100; i++) {
const n = crypto.randomInt(3);
assert.ok(n >= 0);
assert.ok(n < 3);
randomInts.push(n);
}

assert.ok(!randomInts.includes(-1));
assert.ok(randomInts.includes(0));
assert.ok(randomInts.includes(1));
assert.ok(randomInts.includes(2));
assert.ok(!randomInts.includes(3));
}
{
// Positive range
const randomInts = [];
for (let i = 0; i < 100; i++) {
crypto.randomInt(1, 3, common.mustCall((err, n) => {
assert.ifError(err);
assert.ok(n >= 1);
assert.ok(n < 3);
randomInts.push(n);
if (randomInts.length === 100) {
assert.ok(!randomInts.includes(0));
assert.ok(randomInts.includes(1));
assert.ok(randomInts.includes(2));
assert.ok(!randomInts.includes(3));
}
}));
}
}
{
// Negative range
const randomInts = [];
for (let i = 0; i < 100; i++) {
crypto.randomInt(-10, -8, common.mustCall((err, n) => {
assert.ifError(err);
assert.ok(n >= -10);
assert.ok(n < -8);
randomInts.push(n);
if (randomInts.length === 100) {
assert.ok(!randomInts.includes(-11));
assert.ok(randomInts.includes(-10));
assert.ok(randomInts.includes(-9));
assert.ok(!randomInts.includes(-8));
}
}));
}
}
{

['10', true, NaN, null, {}, []].forEach((i) => {
const invalidMinError = {
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
message: 'The "min" argument must be safe integer.' +
`${common.invalidArgTypeHelper(i)}`,
};
const invalidMaxError = {
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
message: 'The "max" argument must be safe integer.' +
`${common.invalidArgTypeHelper(i)}`,
};

assert.throws(
() => crypto.randomInt(i, 100),
invalidMinError
);
assert.throws(
() => crypto.randomInt(i, 100, common.mustNotCall()),
invalidMinError
);
assert.throws(
() => crypto.randomInt(i),
invalidMaxError
);
assert.throws(
() => crypto.randomInt(i, common.mustNotCall()),
invalidMaxError
);
assert.throws(
() => crypto.randomInt(0, i, common.mustNotCall()),
invalidMaxError
);
assert.throws(
() => crypto.randomInt(0, i),
invalidMaxError
);
});

const maxInt = Number.MAX_SAFE_INTEGER;
const minInt = Number.MIN_SAFE_INTEGER;

crypto.randomInt(minInt, minInt + 5, common.mustCall());
crypto.randomInt(maxInt - 5, maxInt, common.mustCall());

assert.throws(
() => crypto.randomInt(minInt - 1, minInt + 5, common.mustNotCall()),
{
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
message: 'The "min" argument must be safe integer.' +
`${common.invalidArgTypeHelper(minInt - 1)}`,
}
);

assert.throws(
() => crypto.randomInt(maxInt + 1, common.mustNotCall()),
{
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
message: 'The "max" argument must be safe integer.' +
`${common.invalidArgTypeHelper(maxInt + 1)}`,
}
);

crypto.randomInt(0, common.mustCall());
crypto.randomInt(0, 0, common.mustCall());
assert.throws(() => crypto.randomInt(-1, common.mustNotCall()), {
code: 'ERR_OUT_OF_RANGE',
name: 'RangeError',
message: 'The value of "max" is out of range. It must be >= 0. Received -1'
});

const MAX_RANGE = 0xFFFF_FFFF_FFFF;
crypto.randomInt(MAX_RANGE, common.mustCall());
crypto.randomInt(1, MAX_RANGE + 1, common.mustCall());
assert.throws(
() => crypto.randomInt(1, MAX_RANGE + 2, common.mustNotCall()),
{
code: 'ERR_OUT_OF_RANGE',
name: 'RangeError',
message: 'The value of "max - min" is out of range. ' +
`It must be <= ${MAX_RANGE}. ` +
'Received 281_474_976_710_656'
}
);

assert.throws(() => crypto.randomInt(MAX_RANGE + 1, common.mustNotCall()), {
code: 'ERR_OUT_OF_RANGE',
name: 'RangeError',
message: 'The value of "max" is out of range. ' +
`It must be <= ${MAX_RANGE}. ` +
'Received 281_474_976_710_656'
});

[true, NaN, null, {}, [], 10].forEach((i) => {
const cbError = {
code: 'ERR_INVALID_CALLBACK',
name: 'TypeError',
message: `Callback must be a function. Received ${inspect(i)}`
};
assert.throws(() => crypto.randomInt(0, 1, i), cbError);
});
}