Files
bitlogin/docs/entropy-audit-2026-07-31.md
thePR0M3TH3ANandClaude Opus 5 98b2967116 Ban Math.random package-wide and enforce it in CI
Neither remaining Math.random call was a secret. The backup-quiz picker
chooses which three recovery words to re-type -- an attacker who cannot see
the phrase gains nothing from predicting them -- and the relay subscription
id is a public wire identifier scoped to one socket, where collision is the
only failure mode.

They are changed anyway, because the point is the rule rather than these two
call sites. A Math.random ban that carries a standing exception is worth
much less than one that does not: the exception is what a future misuse
hides behind, and reviewers stop reading a warning they have already agreed
to ignore twice. Both now use the CSPRNG (randomUniformInt, and
bytesToHex(randomBytes(8)) for the subscription id, same 16-hex-character
shape as before), which costs nothing here.

With no exception left, the rule can be absolute. .semgrep.yml adds three
rules over packages/*/src/**, run in CI as a separate step after
--config auto:

- bitlogin-no-math-random: no Math.random anywhere, secret or not
- bitlogin-no-crypto-fallback: no `?? fallback`, `|| fallback`, or
  catch-and-substitute around getRandomValues -- the COLDCARD defect class
- bitlogin-no-time-or-id-seeded-secrets: no hashing Date.now() or
  performance.now() into a key

Verified the rules fire rather than merely passing, which is the failure
mode of a lint rule nobody tests: reintroducing the old
Math.floor(Math.random() * pool.length) produces 1 blocking finding and exit
1, and the tree is clean at 0 findings with it restored. Path patterns are
anchored (/packages/...) so semgrep's upcoming Semgrepignore v2 change does
not silently alter their scope.

Typecheck and the full suite pass (185 tests).

This closes the entropy audit for this repo. The only gate remaining is an
independent review of the RNG path by someone other than the author.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 17:30:24 -04:00

265 lines
12 KiB
Markdown

# BitLogin — randomness and RNG-integration audit, 2026-07-31
Audit of every security-sensitive random value in `packages/`, against a
Coldcard-class checklist: where each bit of unpredictability originates, whether
that source is present in the production build, and what happens when it fails.
## Verdict
**No predictable-fallback defect exists.** `packages/core/src/crypto/random.ts`
is the single chokepoint for every secret this codebase mints, and it is correct:
- It throws when `crypto.getRandomValues` is unavailable rather than falling
back to anything (`random.ts:5-9`). There is no second code path.
- `randomUniformInt` (`random.ts:36-49`) uses **rejection sampling**, so
wordlist and charset selection carry no modulo bias.
- `generatePrivateKey` (`crypto/secp256k1.ts:16-21`) rejection-samples until the
scalar is in range rather than clamping or reducing.
- Nothing seeds a CSPRNG manually; nothing uses timestamps, PIDs, device IDs or
counters as entropy.
- Crypto dependencies are exact-pinned, not caret-ranged (`@noble/curves`
1.9.7, `@noble/hashes` 1.8.0, `@scure/bip39` 1.6.0, `@scure/base` 1.2.6), and
CI emits a CycloneDX SBOM plus per-artifact SHA256SUMS.
Entropy accounting is sound: 128 bits into a 12-word BIP-39 phrase
(`randomEntropy128`), 256-bit connection-vault root and sudo key, 128-bit
account id, ~77 bits for the default six-word EFF passphrase with a hard floor
at 64 bits (`account/passphrase.ts:28-30`). No source is truncated before use,
and no transformation is treated as creating entropy.
The work below is hardening, not repair.
---
## Open items
### M1 — Nonce-injection hooks are reachable from the shipped public API — **DONE 2026-07-31**
`nip44Encrypt` and `nip04Encrypt` no longer take a nonce or IV at all. The
capability moved to `packages/core/src/crypto/testing.ts` as
`__unsafeNip44EncryptWithNonce` / `__unsafeNip04EncryptWithIv`, and
`crypto/index.ts` now re-exports explicitly instead of `export *`, so the
underlying `encryptWithNonce` / `encryptWithIv` stay off the package surface.
It turned out only **one** test ever used an override — a nonce-length
validation check — and `ivOverride` had no callers at all. The official NIP-44
vectors do not need it. So nothing was lost by closing this.
Verified from a real consumer package linking the built `@bitlogin/core`, not
from source:
| Attempt | Result |
| --- | --- |
| `import "@bitlogin/core/crypto/testing.js"` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
| `import "@bitlogin/core/dist/crypto/testing.js"` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
| `import "@bitlogin/core/crypto/nip44.js"` | `ERR_PACKAGE_PATH_NOT_EXPORTED` |
| `"encryptWithNonce" in cryptoSurface` | `false` |
| `nip44Encrypt.length` / `nip04Encrypt.length` | `2` / `3` — no nonce or IV parameter |
Four regression tests pin the closed surface, including a fresh-nonce check
across 64 calls. `knownAnswer.test.ts` stayed green throughout, which is the
signal that matters: the derivation chain is untouched and no account is
affected.
The original problem statement follows.
### M1 (original) — Nonce-injection hooks are reachable from the shipped public API
`nip44Encrypt` and `nip04Encrypt` accept caller-supplied nonces:
```ts
// packages/core/src/crypto/nip44.ts:124
export function nip44Encrypt(conversationKey: Uint8Array, plaintext: string, nonceOverride?: Uint8Array): string {
const nonce = nonceOverride ?? randomBytes(32);
// packages/core/src/crypto/nip04.ts:24
ivOverride?: Uint8Array
): string {
const iv = ivOverride ?? randomBytes(16);
```
These exist for a legitimate reason — the official NIP-44 v2 test vectors pin
the nonce, and `crypto.test.ts` cannot verify against them otherwise. No
production caller passes either parameter; the only other references are the
generated `.d.ts` declarations.
The problem is the surface, not the current callers. Both functions are exported
from `@bitlogin/core/crypto`, so the override is part of what this package
advertises. Nonce reuse in NIP-44 (ChaCha20) and NIP-04 (AES-CBC) is not a
gradual weakening — it is immediate plaintext recovery, and for CBC a repeated
IV leaks the first block relationship directly. A parameter that silently
accepts a fixed nonce should not be one autocomplete away from application code.
**Fix.** Keep the vectors working, remove the footgun:
1. Rename the parameterised forms to `__unsafeNip44EncryptWithNonce` /
`__unsafeNip04EncryptWithIv` and move them into a `crypto/testing.ts` module
that is **not** listed in the `exports` map of `packages/core/package.json`.
2. Leave `nip44Encrypt` / `nip04Encrypt` with two- and three-argument
signatures that always call `randomBytes`.
3. Point `crypto.test.ts` and `knownAnswer.test.ts` at the new module via a
relative import.
**Acceptance.** `crypto.test.ts` still passes the official NIP-44 vectors;
`import { nip44Encrypt } from "@bitlogin/core/crypto"` exposes no nonce
parameter in its type signature; `npm run security:ci` is green.
**Care required.** This touches the derivation chain's neighbourhood but not the
chain itself — no capsule bytes change. `knownAnswer.test.ts` must stay green
without its expected values being edited. If it goes red, stop: per the standing
rule in `TODO.md`, that means the change is account-breaking.
---
### M2 — Nothing tests that the RNG fails closed — **DONE 2026-07-31**
Landed in `packages/core/src/crypto/crypto.test.ts` as two new blocks, nine
tests, bringing that file to 59 and the suite to 181.
Both guards were mutation-verified rather than assumed:
| Mutation | Result |
| --- | --- |
| `webcrypto()` returns a zero-filling stub instead of throwing | 5 fail-closed tests red |
| `randomUniformInt` uses bare `value % maxExclusive` | chi-square **5614** against a threshold of 147 |
The second number is the point of the exercise: a modulo-biased generator
still produces different-looking values every call and passes every
self-consistency test in the suite. Only a distribution test sees it.
The original problem statement follows.
This is the gap the whole exercise turns on. The crypto suite is genuinely good
— known-answer coverage for HKDF, ScalarExpand, the Argon2id profile, BIP-39
phrase and seed, Schnorr, AES-GCM tamper-detection, JCS ordering, padding
buckets. **None of it asserts what happens when randomness is unavailable.**
`random.ts:5-9` is correct today. Nothing in CI would report it if a future
refactor made it return zeros, or added an `?? fallback`, or moved the guard
behind a condition that no longer fires. A deterministic stream passes every
self-consistency test in the suite.
**Fix.** Add to `packages/core/src/crypto/crypto.test.ts`:
```ts
describe("RNG fails closed (§11.1)", () => {
it("throws when no secure random source exists", () => {
const saved = Object.getOwnPropertyDescriptor(globalThis, "crypto");
try {
Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true });
expect(() => randomBytes(32)).toThrow(/no cryptographically secure/i);
expect(() => randomEntropy128()).toThrow();
expect(() => generatePrivateKey()).toThrow();
} finally {
if (saved) Object.defineProperty(globalThis, "crypto", saved);
}
});
it("throws when crypto exists but getRandomValues does not", () => {
const saved = Object.getOwnPropertyDescriptor(globalThis, "crypto");
try {
Object.defineProperty(globalThis, "crypto", { value: {}, configurable: true });
expect(() => randomBytes(32)).toThrow(/no cryptographically secure/i);
} finally {
if (saved) Object.defineProperty(globalThis, "crypto", saved);
}
});
});
```
Add a companion uniformity test proving the rejection loop actually rejects —
without it, `randomUniformInt` could degrade to `value % maxExclusive` and stay
green:
```ts
it("randomUniformInt is unbiased across a modulus that does not divide 256", () => {
const M = 94, N = 200_000;
const counts = new Array(M).fill(0);
for (let i = 0; i < N; i++) counts[randomUniformInt(M)]++;
const expected = N / M;
const chi2 = counts.reduce((a, c) => a + (c - expected) ** 2 / expected, 0);
expect(chi2).toBeLessThan(160); // df=93, p<0.001 critical value ~147
});
```
**Acceptance.** Both tests pass on `main`. Deliberately breaking `random.ts`
(returning a zero-filled array, or swapping the rejection loop for a bare
modulo) must turn them red — verify this locally before committing, then revert.
---
### L1 — `Math.random` in the backup-verification quiz — **DONE 2026-07-31**
`pickRandomIndices` now uses `randomUniformInt`, and `relay.ts`'s subscription
id is `bytesToHex(randomBytes(8))` rather than a hash over `Math.random()`.
Neither was a secret; both were changed so the ban below has **no standing
exception**.
That ban is now machine-enforced. `.semgrep.yml` adds three rules over
`packages/*/src/**`, run in CI as a separate step after `--config auto`:
- `bitlogin-no-math-random` — no `Math.random` anywhere, secret or not
- `bitlogin-no-crypto-fallback` — no `?? fallback`, `|| fallback`, or
catch-and-substitute around `getRandomValues`
- `bitlogin-no-time-or-id-seeded-secrets` — no hashing `Date.now()` /
`performance.now()` into a key
Verified the rules actually fire rather than merely passing: reintroducing the
old `Math.floor(Math.random() * pool.length)` produces 1 blocking finding and
exit 1; the tree is clean at 0 findings with it restored.
The original note follows.
### L1 (original) — `Math.random` in the backup-verification quiz
`packages/widget/src/element.ts:1471`:
```ts
function pickRandomIndices(max: number, count: number): number[] {
...
const idx = Math.floor(Math.random() * pool.length);
```
Called once, at `element.ts:857`, to choose which three recovery-phrase words to
ask the user to re-type after registration. **This is not a secret and leaks no
entropy** — the phrase itself comes from `randomEntropy128`, and an attacker who
cannot see the phrase gains nothing from predicting which words get quizzed.
It is worth changing anyway, for a process reason. As long as one `Math.random`
call survives in security-adjacent source, a static-analysis rule banning
`Math.random` (checklist §13) needs a standing exception — and standing
exceptions are where the next real one hides.
**Fix.** Import `randomUniformInt` from `@bitlogin/core/crypto` and replace the
`Math.floor(Math.random() * pool.length)` expression. Then add the ban rule to
the semgrep config so the file cannot regress.
**Note.** `src/vendor/bitlogin/bitlogin.js:1696` in the **bitunlock** repo is
this same function, vendored. Fixing it here and re-running
`scripts/build-bitlogin-widget.mjs` there clears both.
---
### L2 — `Math.random` in the relay subscription id — **DONE 2026-07-31** (folded into L1)
`packages/core/src/nostr/relay.ts:223` mixes `Math.random()` into the material
hashed for a subscription id. A subscription id is a public wire identifier
scoped to one socket; it is not a secret and collision is the only failure mode.
No change needed. Recorded so the next audit does not re-flag it.
---
## Not findings, recorded so they are not re-litigated
- **`params.now` test hook** in `account/create.ts:62` injects a timestamp, not
entropy. Timestamps are already public in Nostr event envelopes.
- **`@noble` v2 upgrade.** Out of scope here, and blocked for an unrelated
reason already captured in `TODO.md` — noble v2 dropped the extensionless
subpath exports this code imports.
## Sequencing
M2 first — it is additive, cannot break the derivation chain, and it is what
protects M1's fix from silently regressing later. Then M1. Then L1, batched
with whatever else next touches `element.ts`.