Skip to content

fix(v1): carry nsecs overflow into the timestamp's high bits - #972

Merged
broofa merged 1 commit into
uuidjs:mainfrom
Jaybhade:fix/v1-nsecs-carry
Aug 6, 2026
Merged

fix(v1): carry nsecs overflow into the timestamp's high bits#972
broofa merged 1 commit into
uuidjs:mainfrom
Jaybhade:fix/v1-nsecs-carry

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

v1() and v6() can encode a timestamp 429.4967296 seconds (2^32 × 100ns) in the past, which breaks the monotonicity that v1 ordering and v6 sorting depend on, and can make a stable generator emit the same id twice.

No options are needed to reach it.

Root cause

The RFC 9562 v1 timestamp is msecs * 10000 + nsecs, in 100-nanosecond intervals since the Gregorian epoch. That needs 57+ bits, so v1Bytes() builds it as a 32-bit time_low and a 28-bit time_mid/time_high half:

const tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
...
const tmh = ((msecs / 0x100000000) * 10000) & 0xfffffff;

nsecs is added to the low half, and the low half is then reduced % 0x100000000 — so when nsecs pushes it past 2^32 it wraps, and nothing increments the high half. The high half is derived from msecs alone, so it cannot see that carry.

The wrap happens when msecs * 10000 lands within 10000 of a 2^32 boundary — one millisecond roughly every 430 seconds — and then only for the nsecs values at or above the boundary. Over a 24-hour span, 201 milliseconds are affected.

Reproducing

v1()'s internal state walks nsecs from 0 to 9999 within a millisecond, so the default no-options path hits this by simply generating enough ids inside the unlucky millisecond:

import { v1 } from 'uuid';

Date.now = () => 1785000401402;             // an affected millisecond
const ids = Array.from({ length: 4000 }, () => v1());
before: ffffffff-884d-11f1-9d1d-235b7ffcc53a  =>  2026-07-25T17:26:41.402Z
after:  00000000-884d-11f1-9d1d-235b7ffcc53a  =>  2026-07-25T17:19:31.905Z

time_low rolls over from ffffffff to 00000000 and time_mid never advances, so the timestamp goes backwards by 429.4967296 s mid-millisecond.

The same thing via explicit options, checked against the exact RFC value:

v1({ msecs: 1321645585614, nsecs: 4384 })
// timestamp encoded:  135409379561177088
// timestamp expected: 135409383856144384   (off by -4294967296)

Because the wrap maps two distinct instants onto one encoded timestamp, a generator with a stable clockseq and node can also produce a byte-identical id twice:

const fixed = { clockseq: 0x33c8, node: Uint8Array.of(0x9f, 0x68, 0xde, 0xce, 0xd8, 0x46) };
v1({ ...fixed, msecs: 1321645585615, nsecs: 0 });    // 000015f0-121e-11e1-b3c8-9f68deced846
v1({ ...fixed, msecs: 1321646015111, nsecs: 7296 }); // 000015f0-121e-11e1-b3c8-9f68deced846

Two ids 429.496 s apart, identical. I did not report this privately because neither input is externally reachable — msecs comes from the system clock and nsecs from the generator's own call counter — and v1 ids are not secrets by design. Happy to move it if you'd rather.

The fix

Compute the low half's own carry and fold it into the high half:

const t = (msecs & 0xfffffff) * 10000 + nsecs;
const tl = t >>> 0;
const tmh = (((msecs / 0x10000000) | 0) * 625 + ((t / 0x100000000) | 0)) & 0xfffffff;

msecs is split at bit 28 rather than 32 because 0x10000000 * 10000 is exactly 625 * 0x100000000. So msecs' high bits contribute only to the high half (as × 625) and its low bits only to the low half — the split is exact, and the high half no longer needs a floating-point divide to land on an integer.

Every intermediate stays well inside 2^53, and both divisions are by powers of two, so they're exact.

Verification

  • Differentially tested against a BigInt reference implementation of the RFC timestamp: 20,119,952 cases across five eras (unix epoch, 2011, 2026, year 2100, and the top of the 48-bit range), sweeping all 10,000 nsecs values exhaustively on every carry-boundary millisecond found. Current code: 52,736 mismatches. Patched: 0.
  • Three new tests, all of which fail on main and pass with the fix:
    • v1 timestamp carries nsecs into time_mid — decodes the 60-bit timestamp back out and compares it to the exact RFC value on both sides of a carry boundary.
    • v1 sort order (time_low overflow) — ordering across the boundary, reusing the existing compareV1TimeField helper.
    • sort by creation time (time_low overflow) — the v6 lexical-sort equivalent.
  • npm test 80/80, npm run test:node, and npm run lint all pass.
  • The RFC v1 example fixture (c232ab00-9414-11ec-b3c8-9f68deced846) is unchanged, as are all existing timestamp assertions.
  • Benchmarked, since this is on the hot path: I first wrote the carry with Math.floor/% and it cost ~2× on the arithmetic, so this version uses the bitwise equivalents instead. At 3×10^7 iterations the changed math runs at 9.3–10.3M ops/s versus 5.9–9.9M for the current code — no measurable regression, and uuid.v1() in test:benchmark stays within its own ±5% run-to-run noise (~193k ops/s).

I left CHANGELOG.md and the version alone for release-please. No dependency, export, or API changes.

The v1/v6 timestamp is `msecs * 10000 + nsecs` in 100-nanosecond intervals
since the Gregorian epoch. It needs 57+ bits, so `v1Bytes()` computes it as a
32-bit `time_low` and a 28-bit `time_mid`/`time_high` half. `nsecs` was added
to the low half only, and the low half was reduced mod 2^32, so a `nsecs` value
that pushed it past 2^32 wrapped without incrementing the high half.

The high half was also derived from `msecs` alone via a floating-point divide,
which cannot see that carry at all.

The result is a timestamp 2^32 * 100ns = 429.4967296 seconds in the past. It
happens whenever `msecs * 10000` lands within 10000 of a 2^32 boundary, i.e.
for one millisecond about every 430 seconds, and then only for the `nsecs`
values above the boundary. `v1()`'s internal state walks `nsecs` from 0 to 9999
within a millisecond, so it is reachable with no options at all: generating
enough ids inside that millisecond makes the timestamp jump backwards, breaking
the monotonicity that v1 ordering and v6 sorting depend on. Because the wrap
maps two distinct instants onto one encoded timestamp, a generator with a
stable `clockseq` and `node` can also emit the same id twice, 429.496s apart.

Compute the low half's own carry and fold it into the high half. `msecs` is
split at bit 28 rather than 32 because `0x10000000 * 10000` is exactly
`625 * 0x100000000`, so the split is exact and the high half needs no
floating-point division.
@broofa

broofa commented Aug 6, 2026

Copy link
Copy Markdown
Member

Great catch! Also, thanks for the detailed write up, tests, fix, and benchmark. Excellent code and PR!

Fwiw, this issue has been around since the original version of v1() landed in this library 15 years ago!

I suspect the reason this has gone undetected is that it requires extraordinary levels of v1() usage to trigger, even in the most unlikely of cases. E.g. The smallest non-zero value of msecs & 0xfffffff * 10000 % 0x100000000 is 16 (for integer values of msecs). This only happens ~3 times for any given range of 1,000,000,000 msecs values. (See code below), but even in such cases v1() has to be invoked in excess of ~16,000/sec to trigger this issue. And real-world use cases that demand such performance from a single uuid() process are exceedingly rare (... as in, I'm not aware of any.)

Regardless, this is still a good catch, as I said, so still worth merging.


let overflowCount = 0;
for (let msecs = 0; msecs < 1_000_000_000; msecs++) {
  const tl = (msecs & 0xfffffff) * 10000;
  const proximity = 0x100000000 - (tl % 0x100000000);
  if (proximity < 10000) {
    overflowCount++;
    if (proximity < 100) {
      console.log({ msecs, proximity, overflowCount });
    }
  }
}
console.log({ overflowCount });

@broofa
broofa merged commit 6adcc1d into uuidjs:main Aug 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants