Repository: nodejs/node
Pull request: crypto: implement randomUUIDv7()

Adds `crypto.randomUUIDv7()` which generates RFC 9562 version 7 UUIDs.

UUIDv7 encodes a millisecond-precision Unix timestamp in the most
significant 48 bits, making these UUIDs time-sortable and suitable
for use as database primary keys.

Layout (128 bits):
- 48 bits: unix_ts_ms (UTC milliseconds)
- 4 bits: version (0b0111)
- 12 bits: rand_a (random)
- 2 bits: variant (0b10)
- 62 bits: rand_b (random)

Fixes: #62529
Refs: https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7

Changed files:
filename: doc/api/crypto.md
patch: @@ -5826,6 +5826,25 @@ added:
 Generates a random [RFC 4122][] version 4 UUID. The UUID is generated using a
 cryptographic pseudorandom number generator.
 
+### `crypto.randomUUIDv7([options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `options` {Object}
+  * `disableEntropyCache` {boolean} By default, to improve performance,
+    Node.js generates and caches enough
+    random data to generate up to 128 random UUIDs. To generate a UUID
+    without using the cache, set `disableEntropyCache` to `true`.
+    **Default:** `false`.
+* Returns: {string}
+
+Generates a random [RFC 9562][] version 7 UUID. The UUID contains a millisecond
+precision Unix timestamp in the most significant 48 bits, followed by
+cryptographically secure random bits for the remaining fields, making it
+suitable for use as a database key with time-based sorting.
+
 ### `crypto.scrypt(password, salt, keylen[, options], callback)`
 
 <!-- YAML
@@ -6864,6 +6883,7 @@ See the [list of SSL OP Flags][] for details.
 [RFC 5280]: https://www.rfc-editor.org/rfc/rfc5280.txt
 [RFC 7517]: https://www.rfc-editor.org/rfc/rfc7517.txt
 [RFC 8032]: https://www.rfc-editor.org/rfc/rfc8032.txt
+[RFC 9562]: https://www.rfc-editor.org/rfc/rfc9562.txt
 [Web Crypto API documentation]: webcrypto.md
 [`BN_is_prime_ex`]: https://www.openssl.org/docs/man1.1.1/man3/BN_is_prime_ex.html
 [`Buffer`]: buffer.md

filename: lib/crypto.js
patch: @@ -56,6 +56,7 @@ const {
   randomFillSync,
   randomInt,
   randomUUID,
+  randomUUIDv7,
 } = require('internal/crypto/random');
 const {
   argon2,
@@ -220,6 +221,7 @@ module.exports = {
   randomFillSync,
   randomInt,
   randomUUID,
+  randomUUIDv7,
   scrypt,
   scryptSync,
   sign: signOneShot,

filename: lib/internal/crypto/random.js
patch: @@ -11,6 +11,7 @@ const {
   BigIntPrototypeToString,
   DataView,
   DataViewPrototypeGetUint8,
+  DateNow,
   FunctionPrototypeBind,
   FunctionPrototypeCall,
   MathMin,
@@ -359,7 +360,7 @@ function getHexBytes() {
   return hexBytesCache;
 }
 
-function serializeUUID(buf, offset = 0) {
+function serializeUUID(buf, version, variant, offset = 0) {
   const kHexBytes = getHexBytes();
   // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
   return kHexBytes[buf[offset]] +
@@ -370,10 +371,10 @@ function serializeUUID(buf, offset = 0) {
     kHexBytes[buf[offset + 4]] +
     kHexBytes[buf[offset + 5]] +
     '-' +
-    kHexBytes[(buf[offset + 6] & 0x0f) | 0x40] +
+    kHexBytes[(buf[offset + 6] & 0x0f) | version] +
     kHexBytes[buf[offset + 7]] +
     '-' +
-    kHexBytes[(buf[offset + 8] & 0x3f) | 0x80] +
+    kHexBytes[(buf[offset + 8] & 0x3f) | variant] +
     kHexBytes[buf[offset + 9]] +
     '-' +
     kHexBytes[buf[offset + 10]] +
@@ -391,15 +392,15 @@ function getBufferedUUID() {
 
   if (uuidBatch === 0) randomFillSync(uuidData);
   uuidBatch = (uuidBatch + 1) % kBatchSize;
-  return serializeUUID(uuidData, uuidBatch * 16);
+  return serializeUUID(uuidData, 0x40, 0x80, uuidBatch * 16);
 }
 
 function getUnbufferedUUID() {
   uuidNotBuffered ??= secureBuffer(16);
   if (uuidNotBuffered === undefined)
     throw new ERR_OPERATION_FAILED('Out of memory');
   randomFillSync(uuidNotBuffered);
-  return serializeUUID(uuidNotBuffered);
+  return serializeUUID(uuidNotBuffered, 0x40, 0x80);
 }
 
 function randomUUID(options) {
@@ -414,6 +415,50 @@ function randomUUID(options) {
   return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID();
 }
 
+function writeTimestamp(buf, offset) {
+  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;
+}
+
+function getBufferedUUIDv7() {
+  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);
+}
+
+function getUnbufferedUUIDv7() {
+  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);
+}
+
+function randomUUIDv7(options) {
+  if (options !== undefined)
+    validateObject(options, 'options');
+  const {
+    disableEntropyCache = false,
+  } = options || kEmptyObject;
+
+  validateBoolean(disableEntropyCache, 'options.disableEntropyCache');
+
+  return disableEntropyCache ? getUnbufferedUUIDv7() : getBufferedUUIDv7();
+}
+
 function createRandomPrimeJob(type, size, options) {
   validateObject(options, 'options');
 
@@ -611,6 +656,7 @@ module.exports = {
   randomInt,
   getRandomValues,
   randomUUID,
+  randomUUIDv7,
   generatePrime,
   generatePrimeSync,
 };

filename: test/parallel/test-crypto-randomuuidv7.js
patch: @@ -0,0 +1,112 @@
+'use strict';
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const {
+  randomUUIDv7,
+} = require('crypto');
+
+{
+  const uuid = randomUUIDv7();
+  assert.strictEqual(typeof uuid, 'string');
+  assert.strictEqual(uuid.length, 36);
+
+  // UUIDv7 format: xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx
+  assert.match(
+    uuid,
+    /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+  );
+}
+
+{
+  const uuid = randomUUIDv7();
+
+  assert.strictEqual(
+    Buffer.from(uuid.slice(14, 16), 'hex')[0] & 0xf0, 0x70,
+  );
+
+  assert.strictEqual(
+    Buffer.from(uuid.slice(19, 21), 'hex')[0] & 0b1100_0000, 0b1000_0000,
+  );
+}
+
+{
+  const seen = new Set();
+  for (let i = 0; i < 1000; i++) {
+    const uuid = randomUUIDv7();
+    assert(!seen.has(uuid), `Duplicate UUID generated: ${uuid}`);
+    seen.add(uuid);
+  }
+}
+
+// Timestamp: the embedded timestamp should approximate Date.now().
+{
+  const before = Date.now();
+  const uuid = randomUUIDv7();
+  const after = Date.now();
+
+  // Extract the 48-bit timestamp from the UUID.
+  // Bytes 0-3 (chars 0-8) and bytes 4-5 (chars 9-13, skipping the dash).
+  const hex = uuid.replace(/-/g, '');
+  const timestampHex = hex.slice(0, 12); // first 48 bits = 12 hex chars
+  const timestamp = parseInt(timestampHex, 16);
+
+  assert(timestamp >= before, `Timestamp ${timestamp} < before ${before}`);
+  assert(timestamp <= after, `Timestamp ${timestamp} > after ${after}`);
+}
+
+{
+  let prev = randomUUIDv7();
+  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}`);
+    prev = curr;
+  }
+}
+
+// Ensure randomUUIDv7 takes no arguments (or ignores them gracefully).
+{
+  const uuid = randomUUIDv7();
+  assert.match(
+    uuid,
+    /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+  );
+}
+
+{
+  const uuidv7Regex =
+    /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+  assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);
+  assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);
+  assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);
+  assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);
+
+  assert.throws(() => randomUUIDv7(1), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+
+  assert.throws(() => randomUUIDv7({ disableEntropyCache: '' }), {
+    code: 'ERR_INVALID_ARG_TYPE',
+  });
+}
+
+{
+  for (let n = 0; n < 130; n++) {
+    const uuid = randomUUIDv7();
+    assert.match(
+      uuid,
+      /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+    );
+  }
+}