Skip to content

refactor(foundation): remove SEED randomness path, fix modulo bias - #25103

Open
spalladino wants to merge 3 commits into
merge-train/spartanfrom
spl/kill-randomness-singleton-seed
Open

refactor(foundation): remove SEED randomness path, fix modulo bias#25103
spalladino wants to merge 3 commits into
merge-train/spartanfrom
spl/kill-randomness-singleton-seed

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Context

Foundation's randomBytes switched to a counter-based generator whenever the SEED env var was set — process-wide, inside the production code path. Everything downstream inherited it: Fr.random(), and from there admin API keys (aztec/src/cli/admin_api_key_store.ts), BLS keystore salts and AES IVs, L1→L2 message secrets (aztec.js/src/ethereum/portal_manager.ts), deployment salts, and account signing keys. With SEED=42, randomBytes(32) returns 2a0000002a0000002a.... That is what TODO(#3949) warned about.

Nothing sets SEED — not CI, helm charts, terraform, bootstrap scripts, or jest setup — and nothing outside randomBytes ever referenced the singleton, which was not exported from the package. It was introduced by #5155 in March 2024 and untouched since. Test-data regeneration uses a separate variable, AZTEC_GENERATE_TEST_DATA.

The same TODO also flagged randomInt/randomBigInt as modulo biased. The bias itself was negligible at every call site (~max / 2^48), but the fixed sample width hid a real bug: randomInt drew 6 bytes, so for max > 2^48 the reduction was a no-op and the range was silently truncated. randomInt(Number.MAX_SAFE_INTEGER) could only reach 1/32 of its range.

Approach

randomBytes now always sources from bb.js, which uses node crypto or WebCrypto depending on environment.

randomBigInt masks a draw down to ceil(log2(max)) bits and resamples on overflow. That is exactly uniform, and because the mask width is chosen so 2^(bits-1) < max <= 2^bits, acceptance stays above 1/2 and the expected draw count below 2. Scaling the draw to max also removes the truncation. randomInt delegates to it, so both are fixed in one place — node's unbiased crypto.randomInt is not an option here, since foundation also runs in the browser.

Both now reject a non-positive max (and a non-safe-integer max for randomInt) rather than returning a nonsensical value. No existing caller can reach the throw: the only two production call sites are fault-injection knobs in sequencer-client guarded by config, and everything else passes a positive literal or a MAX_*_PER_TX constant.

API changes

SEED no longer has any effect. Anyone relying on it for deterministic runs will now get real randomness; there is no replacement, as the seeded generator was not safe to leave reachable from production key derivation.

randomInt and randomBigInt now throw RangeError on a non-positive max instead of returning a garbage value, and randomInt additionally rejects non-safe-integer maxima.

Resolves the seeded-randomness and modulo-bias halves of #3949.

The SEED env var switched foundation's randomBytes to a counter-based
generator process-wide, including in production code paths that derive
admin API keys, keystore salts and IVs, L1->L2 message secrets, and
account signing keys. Nothing sets SEED — not CI, helm charts, terraform,
bootstrap scripts, or jest setup — and nothing outside randomBytes ever
referenced the singleton.

randomBytes now always sources from bb.js, which uses node crypto or
WebCrypto depending on the environment.

Fixes #3949 for the seeded-randomness half; the modulo bias in randomInt
and randomBigInt is unchanged.
randomInt and randomBigInt sampled a fixed 48 and 64 bits respectively and
reduced modulo max. Besides biasing the low end of the range, this silently
capped the result at the sample width whenever max exceeded it: randomInt
could only ever return values below 2^48, so randomInt(Number.MAX_SAFE_INTEGER)
covered 1/32 of its range.

Both now mask a draw down to ceil(log2(max)) bits and resample on overflow,
which is exactly uniform and keeps the expected draw count below 2. A
non-positive max, or a non-safe-integer max for randomInt, now throws instead
of returning a nonsensical value. No existing call site can reach the throw:
the two production call sites are fault-injection knobs guarded by config, and
the rest pass positive protocol constants.

Also converted the module to function declarations.
A max of 1 took the rejection-sampling path, where the bit count came out as 1
rather than 0 because (0n).toString(2) is "0". Acceptance was then exactly 1/2,
so the loop averaged two draws to return a value that is always zero, and the
stated bound of "above 1/2" did not hold. Returning early restores that bound
for every max the loop now sees. randomInt(1) is a live call site.
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.

1 participant