Skip to content

Commit d5a5fea

Browse files
committed
crypto: add randomUUIDv7() for RFC 9562 v7 UUIDs
1 parent 86282b5 commit d5a5fea

File tree

4 files changed

+179
-0
lines changed

4 files changed

+179
-0
lines changed

doc/api/crypto.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5826,6 +5826,33 @@ added:
58265826
Generates a random [RFC 4122][] version 4 UUID. The UUID is generated using a
58275827
cryptographic pseudorandom number generator.
58285828

5829+
### `crypto.randomUUIDv7()`
5830+
5831+
<!-- YAML
5832+
added: REPLACEME
5833+
-->
5834+
5835+
* Returns: {string}
5836+
5837+
Generates a random [RFC 9562][] version 7 UUID. The UUID contains a millisecond
5838+
precision Unix timestamp in the most significant 48 bits, followed by
5839+
cryptographically secure random bits for the remaining fields, making it
5840+
suitable for use as a database key with time-based sorting.
5841+
5842+
```mjs
5843+
import { randomUUIDv7 } from 'node:crypto';
5844+
5845+
console.log(randomUUIDv7());
5846+
// e.g. '019d45ea-151f-780b-82a6-d097b39db62a'
5847+
```
5848+
5849+
```cjs
5850+
const { randomUUIDv7 } = require('node:crypto');
5851+
5852+
console.log(randomUUIDv7());
5853+
// e.g. '019d45ea-151f-780b-82a6-d097b39db62a'
5854+
```
5855+
58295856
### `crypto.scrypt(password, salt, keylen[, options], callback)`
58305857

58315858
<!-- YAML
@@ -6861,6 +6888,7 @@ See the [list of SSL OP Flags][] for details.
68616888
[RFC 4055]: https://www.rfc-editor.org/rfc/rfc4055.txt
68626889
[RFC 4122]: https://www.rfc-editor.org/rfc/rfc4122.txt
68636890
[RFC 5208]: https://www.rfc-editor.org/rfc/rfc5208.txt
6891+
[RFC 9562]: https://www.rfc-editor.org/rfc/rfc9562.txt
68646892
[RFC 5280]: https://www.rfc-editor.org/rfc/rfc5280.txt
68656893
[RFC 7517]: https://www.rfc-editor.org/rfc/rfc7517.txt
68666894
[RFC 8032]: https://www.rfc-editor.org/rfc/rfc8032.txt

lib/crypto.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const {
5656
randomFillSync,
5757
randomInt,
5858
randomUUID,
59+
randomUUIDv7,
5960
} = require('internal/crypto/random');
6061
const {
6162
argon2,
@@ -220,6 +221,7 @@ module.exports = {
220221
randomFillSync,
221222
randomInt,
222223
randomUUID,
224+
randomUUIDv7,
223225
scrypt,
224226
scryptSync,
225227
sign: signOneShot,

lib/internal/crypto/random.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
BigIntPrototypeToString,
1212
DataView,
1313
DataViewPrototypeGetUint8,
14+
DateNow,
1415
FunctionPrototypeBind,
1516
FunctionPrototypeCall,
1617
MathMin,
@@ -414,6 +415,62 @@ function randomUUID(options) {
414415
return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID();
415416
}
416417

418+
// Implements an RFC 9562 version 7 UUID (time-ordered).
419+
// Layout (128 bits total):
420+
// 48 bits - unix_ts_ms: UTC timestamp in milliseconds
421+
// 4 bits - version: 0b0111 (7)
422+
// 12 bits - rand_a: random
423+
// 2 bits - variant: 0b10
424+
// 62 bits - rand_b: random
425+
426+
let uuidV7Buffer;
427+
428+
function randomUUIDv7() {
429+
uuidV7Buffer ??= secureBuffer(16);
430+
if (uuidV7Buffer === undefined)
431+
throw new ERR_OPERATION_FAILED('Out of memory');
432+
433+
// Fill all 16 bytes with random data first.
434+
randomFillSync(uuidV7Buffer);
435+
436+
// Write 48-bit timestamp (ms since Unix epoch) into bytes 0-5, big-endian.
437+
const now = DateNow();
438+
uuidV7Buffer[0] = (now / 0x10000000000) & 0xff;
439+
uuidV7Buffer[1] = (now / 0x100000000) & 0xff;
440+
uuidV7Buffer[2] = (now / 0x1000000) & 0xff;
441+
uuidV7Buffer[3] = (now / 0x10000) & 0xff;
442+
uuidV7Buffer[4] = (now / 0x100) & 0xff;
443+
uuidV7Buffer[5] = now & 0xff;
444+
445+
// Set version (7) in byte 6 high nibble, keep rand_a in low nibble.
446+
uuidV7Buffer[6] = (uuidV7Buffer[6] & 0x0f) | 0x70;
447+
448+
// Set variant (0b10) in byte 8 high 2 bits, keep rand_b in low 6 bits.
449+
uuidV7Buffer[8] = (uuidV7Buffer[8] & 0x3f) | 0x80;
450+
451+
const kHexBytes = getHexBytes();
452+
return kHexBytes[uuidV7Buffer[0]] +
453+
kHexBytes[uuidV7Buffer[1]] +
454+
kHexBytes[uuidV7Buffer[2]] +
455+
kHexBytes[uuidV7Buffer[3]] +
456+
'-' +
457+
kHexBytes[uuidV7Buffer[4]] +
458+
kHexBytes[uuidV7Buffer[5]] +
459+
'-' +
460+
kHexBytes[uuidV7Buffer[6]] +
461+
kHexBytes[uuidV7Buffer[7]] +
462+
'-' +
463+
kHexBytes[uuidV7Buffer[8]] +
464+
kHexBytes[uuidV7Buffer[9]] +
465+
'-' +
466+
kHexBytes[uuidV7Buffer[10]] +
467+
kHexBytes[uuidV7Buffer[11]] +
468+
kHexBytes[uuidV7Buffer[12]] +
469+
kHexBytes[uuidV7Buffer[13]] +
470+
kHexBytes[uuidV7Buffer[14]] +
471+
kHexBytes[uuidV7Buffer[15]];
472+
}
473+
417474
function createRandomPrimeJob(type, size, options) {
418475
validateObject(options, 'options');
419476

@@ -611,6 +668,7 @@ module.exports = {
611668
randomInt,
612669
getRandomValues,
613670
randomUUID,
671+
randomUUIDv7,
614672
generatePrime,
615673
generatePrimeSync,
616674
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
5+
if (!common.hasCrypto)
6+
common.skip('missing crypto');
7+
8+
const assert = require('assert');
9+
const {
10+
randomUUIDv7,
11+
} = require('crypto');
12+
13+
// Basic return type and format checks.
14+
{
15+
const uuid = randomUUIDv7();
16+
assert.strictEqual(typeof uuid, 'string');
17+
assert.strictEqual(uuid.length, 36);
18+
19+
// UUIDv7 format: xxxxxxxx-xxxx-7xxx-[89ab]xxx-xxxxxxxxxxxx
20+
assert.match(
21+
uuid,
22+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
23+
);
24+
}
25+
26+
// Check version and variant bits.
27+
{
28+
const uuid = randomUUIDv7();
29+
30+
// Version 7: byte 6 high nibble should be 0x7.
31+
assert.strictEqual(
32+
Buffer.from(uuid.slice(14, 16), 'hex')[0] & 0xf0, 0x70,
33+
);
34+
35+
// Variant: byte 8 high 2 bits should be 0b10.
36+
assert.strictEqual(
37+
Buffer.from(uuid.slice(19, 21), 'hex')[0] & 0b1100_0000, 0b1000_0000,
38+
);
39+
}
40+
41+
// Uniqueness: generate many UUIDs and verify no duplicates.
42+
{
43+
const seen = new Set();
44+
for (let i = 0; i < 1000; i++) {
45+
const uuid = randomUUIDv7();
46+
assert(!seen.has(uuid), `Duplicate UUID generated: ${uuid}`);
47+
seen.add(uuid);
48+
}
49+
}
50+
51+
// Timestamp: the embedded timestamp should approximate Date.now().
52+
{
53+
const before = Date.now();
54+
const uuid = randomUUIDv7();
55+
const after = Date.now();
56+
57+
// Extract the 48-bit timestamp from the UUID.
58+
// Bytes 0-3 (chars 0-8) and bytes 4-5 (chars 9-13, skipping the dash).
59+
const hex = uuid.replace(/-/g, '');
60+
const timestampHex = hex.slice(0, 12); // first 48 bits = 12 hex chars
61+
const timestamp = parseInt(timestampHex, 16);
62+
63+
assert(timestamp >= before, `Timestamp ${timestamp} < before ${before}`);
64+
assert(timestamp <= after, `Timestamp ${timestamp} > after ${after}`);
65+
}
66+
67+
// Monotonicity: UUIDs generated in sequence should sort lexicographically
68+
// within the same millisecond or across milliseconds.
69+
{
70+
let prev = randomUUIDv7();
71+
for (let i = 0; i < 100; i++) {
72+
const curr = randomUUIDv7();
73+
// UUIDs with later timestamps must sort after earlier ones.
74+
// Within the same millisecond, ordering depends on random bits,
75+
// so we only assert >= on the timestamp portion.
76+
const prevTs = parseInt(prev.replace(/-/g, '').slice(0, 12), 16);
77+
const currTs = parseInt(curr.replace(/-/g, '').slice(0, 12), 16);
78+
assert(currTs >= prevTs,
79+
`Timestamp went backwards: ${currTs} < ${prevTs}`);
80+
prev = curr;
81+
}
82+
}
83+
84+
// Ensure randomUUIDv7 takes no arguments (or ignores them gracefully).
85+
{
86+
const uuid = randomUUIDv7();
87+
assert.match(
88+
uuid,
89+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
90+
);
91+
}

0 commit comments

Comments
 (0)