mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Entropy audit: RNG integration review and open hardening items
Audits 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. No predictable-fallback defect. crypto/random.ts is the single chokepoint for every secret this codebase mints and it throws rather than degrading; randomUniformInt rejection-samples, so wordlist and charset selection carry no modulo bias; generatePrivateKey rejection-samples valid scalars. Crypto deps are exact-pinned and CI already emits an SBOM. Three hardening items recorded, in the order they should be done: - M2: nothing tests that the RNG fails closed. Additive, cannot touch the derivation chain, and it is what protects M1's fix from regressing later. - M1: nonceOverride/ivOverride are reachable from the public @bitlogin/core/crypto surface. No production caller passes them, but nonce reuse in NIP-44/NIP-04 is immediate plaintext recovery, so the parameter should not be one autocomplete away from application code. - L1: Math.random in the backup-quiz word picker. Not a secret, but it forces a standing exception in any Math.random ban rule. Docs only; no source or behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
This commit is contained in:
1 parent
30d4ac4b83
commit
ba49673ef9
2 files changed
+211
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
Updated: 2026-07-31
|
||||
|
||||
## Entropy audit (2026-07-31)
|
||||
|
||||
[`entropy-audit-2026-07-31.md`](./entropy-audit-2026-07-31.md) — RNG and
|
||||
randomness-integration audit. **No predictable-fallback defect:** `random.ts`
|
||||
is the single chokepoint, throws rather than degrading, and rejection-samples to
|
||||
avoid modulo bias. Three hardening items, in order:
|
||||
|
||||
- **M2** — nothing tests that the RNG fails closed. Do this first; it is
|
||||
additive, cannot touch the derivation chain, and it is what stops M1's fix
|
||||
from regressing later.
|
||||
- **M1** — `nonceOverride` / `ivOverride` are reachable from the public
|
||||
`@bitlogin/core/crypto` surface. Move behind a non-exported test module.
|
||||
- **L1** — `Math.random` in the backup-quiz word picker (`element.ts:1471`).
|
||||
Not a secret, but it forces a standing exception in any `Math.random` ban rule.
|
||||
|
||||
## Owner action items
|
||||
|
||||
1. **Close the Dependabot PR on
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# 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
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
|
||||
`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`.
|
||||
Reference in new issue
Block a user