diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 55b948aac50db5..bc4885f53b3d83 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -5838,6 +5838,10 @@ added: REPLACEME random data to generate up to 128 random UUIDs. To generate a UUID without using the cache, set `disableEntropyCache` to `true`. **Default:** `false`. + * `monotonic` {boolean} When `true`, guarantees that UUIDs generated + within the same millisecond are strictly increasing by using a counter + in the `rand_a` field, as described in [RFC 9562][] Section 6.2. + **Default:** `false`. * Returns: {string} Generates a random [RFC 9562][] version 7 UUID. The UUID contains a millisecond diff --git a/lib/internal/crypto/random.js b/lib/internal/crypto/random.js index c324b2292b2fb8..c8e172fbe71ea1 100644 --- a/lib/internal/crypto/random.js +++ b/lib/internal/crypto/random.js @@ -348,6 +348,9 @@ let uuidData; let uuidNotBuffered; let uuidBatch = 0; +let v7LastTimestamp = -1; +let v7Counter = 0; + let hexBytesCache; function getHexBytes() { if (hexBytesCache === undefined) { @@ -415,36 +418,71 @@ function randomUUID(options) { return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID(); } -function writeTimestamp(buf, offset) { +function advanceV7(seed) { const now = DateNow(); - const msb = now / (2 ** 32); - buf[offset] = msb >>> 8; - buf[offset + 1] = msb; - buf[offset + 2] = now >>> 24; - buf[offset + 3] = now >>> 16; - buf[offset + 4] = now >>> 8; - buf[offset + 5] = now; + if (now > v7LastTimestamp) { + v7LastTimestamp = now; + v7Counter = seed & 0xFFF; + } else { + v7Counter++; + if (v7Counter > 0xFFF) { + v7LastTimestamp++; + v7Counter = 0; + } + } +} + +function makeUUIDv7(buf, offset, monotonic = false) { + let timestamp, randA; + if (monotonic) { + const seed = ((buf[offset + 6] & 0x0f) << 8) | buf[offset + 7]; + advanceV7(seed); + timestamp = v7LastTimestamp; + randA = v7Counter; + } else { + timestamp = DateNow(); + randA = ((buf[offset + 6] & 0x0f) << 8) | buf[offset + 7]; + } + const kHexBytes = getHexBytes(); + const msb = timestamp / (2 ** 32); + return kHexBytes[msb >>> 8] + + kHexBytes[msb & 0xff] + + kHexBytes[timestamp >>> 24] + + kHexBytes[(timestamp >>> 16) & 0xff] + + '-' + + kHexBytes[(timestamp >>> 8) & 0xff] + + kHexBytes[timestamp & 0xff] + + '-' + + kHexBytes[(randA >>> 8) | 0x70] + + kHexBytes[randA & 0xff] + + '-' + + kHexBytes[(buf[offset + 8] & 0x3f) | 0x80] + + kHexBytes[buf[offset + 9]] + + '-' + + kHexBytes[buf[offset + 10]] + + kHexBytes[buf[offset + 11]] + + kHexBytes[buf[offset + 12]] + + kHexBytes[buf[offset + 13]] + + kHexBytes[buf[offset + 14]] + + kHexBytes[buf[offset + 15]]; } -function getBufferedUUIDv7() { +function getBufferedUUIDv7(monotonic = false) { uuidData ??= secureBuffer(16 * kBatchSize); if (uuidData === undefined) throw new ERR_OPERATION_FAILED('Out of memory'); if (uuidBatch === 0) randomFillSync(uuidData); uuidBatch = (uuidBatch + 1) % kBatchSize; - const offset = uuidBatch * 16; - writeTimestamp(uuidData, offset); - return serializeUUID(uuidData, 0x70, 0x80, offset); + return makeUUIDv7(uuidData, uuidBatch * 16, monotonic); } -function getUnbufferedUUIDv7() { +function getUnbufferedUUIDv7(monotonic = false) { uuidNotBuffered ??= secureBuffer(16); if (uuidNotBuffered === undefined) throw new ERR_OPERATION_FAILED('Out of memory'); randomFillSync(uuidNotBuffered, 6); - writeTimestamp(uuidNotBuffered, 0); - return serializeUUID(uuidNotBuffered, 0x70, 0x80); + return makeUUIDv7(uuidNotBuffered, 0, monotonic); } function randomUUIDv7(options) { @@ -452,11 +490,14 @@ function randomUUIDv7(options) { validateObject(options, 'options'); const { disableEntropyCache = false, + monotonic = false, } = options || kEmptyObject; validateBoolean(disableEntropyCache, 'options.disableEntropyCache'); + validateBoolean(monotonic, 'options.monotonic'); - return disableEntropyCache ? getUnbufferedUUIDv7() : getBufferedUUIDv7(); + return disableEntropyCache ? + getUnbufferedUUIDv7(monotonic) : getBufferedUUIDv7(monotonic); } function createRandomPrimeJob(type, size, options) { diff --git a/test/parallel/test-crypto-randomuuidv7.js b/test/parallel/test-crypto-randomuuidv7.js index 99d052f356721c..83aa6aa0e73fbe 100644 --- a/test/parallel/test-crypto-randomuuidv7.js +++ b/test/parallel/test-crypto-randomuuidv7.js @@ -60,20 +60,33 @@ const { } { - let prev = randomUUIDv7(); + const opts = { monotonic: true }; + let prev = randomUUIDv7(opts); for (let i = 0; i < 100; i++) { - const curr = randomUUIDv7(); - // UUIDs with later timestamps must sort after earlier ones. - // Within the same millisecond, ordering depends on random bits, - // so we only assert >= on the timestamp portion. - const prevTs = parseInt(prev.replace(/-/g, '').slice(0, 12), 16); - const currTs = parseInt(curr.replace(/-/g, '').slice(0, 12), 16); - assert(currTs >= prevTs, - `Timestamp went backwards: ${currTs} < ${prevTs}`); + const curr = randomUUIDv7(opts); + // With a monotonic counter in rand_a, each UUID must be strictly greater + // than the previous regardless of whether the timestamp changed. + assert(curr > prev, + `UUID ordering violated: ${curr} <= ${prev}`); prev = curr; } } +// Sub-millisecond ordering: a tight synchronous burst exercises the counter +// increment path and must also produce strictly increasing UUIDs. +{ + const opts = { monotonic: true }; + const burst = []; + for (let i = 0; i < 500; i++) { + burst.push(randomUUIDv7(opts)); + } + for (let i = 1; i < burst.length; i++) { + assert(burst[i] > burst[i - 1], + `Sub-millisecond ordering violated at index ${i}: ` + + `${burst[i]} <= ${burst[i - 1]}`); + } +} + // Ensure randomUUIDv7 takes no arguments (or ignores them gracefully). { const uuid = randomUUIDv7(); @@ -92,6 +105,11 @@ const { assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex); assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex); + // monotonic: false — rand_a is random; UUIDs must still be valid but are not + // guaranteed to be strictly ordered within the same millisecond. + assert.match(randomUUIDv7({ monotonic: false }), uuidv7Regex); + assert.match(randomUUIDv7({ monotonic: false, disableEntropyCache: true }), uuidv7Regex); + assert.throws(() => randomUUIDv7(1), { code: 'ERR_INVALID_ARG_TYPE', }); @@ -99,6 +117,10 @@ const { assert.throws(() => randomUUIDv7({ disableEntropyCache: '' }), { code: 'ERR_INVALID_ARG_TYPE', }); + + assert.throws(() => randomUUIDv7({ monotonic: 1 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); } {