mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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
This commit is contained in:
1 parent
e131f0313b
commit
98b2967116
6 files changed
+112
-20
No files matched your search
@@ -48,6 +48,8 @@ jobs:
|
||||
run: pipx install semgrep==1.171.0
|
||||
- name: Scan repository rules without per-rule timeouts
|
||||
run: semgrep scan --config auto --error --timeout 0 packages
|
||||
- name: Enforce local randomness rules (entropy audit §13)
|
||||
run: semgrep scan --config .semgrep.yml --error --timeout 0 packages
|
||||
- name: Scan repository history for secrets
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Local semgrep rules, run alongside `--config auto` in
|
||||
# .github/workflows/security.yml.
|
||||
#
|
||||
# Checklist §13 of docs/entropy-audit-2026-07-31.md: make the randomness rules
|
||||
# machine-enforced rather than review-enforced. Every rule here is currently
|
||||
# clean across packages/ — they exist to keep it that way, not to flag a
|
||||
# backlog.
|
||||
rules:
|
||||
- id: bitlogin-no-math-random
|
||||
languages: [typescript, javascript]
|
||||
severity: ERROR
|
||||
message: >-
|
||||
Math.random() is not a CSPRNG and must not be used anywhere in this
|
||||
package, including for values that are not secrets. Use randomBytes or
|
||||
randomUniformInt from @bitlogin/core/crypto. A ban with a standing
|
||||
exception is where the next real misuse hides -- if you believe this
|
||||
specific call is safe, the correct move is still to use the CSPRNG, which
|
||||
costs nothing here.
|
||||
paths:
|
||||
include:
|
||||
- /packages/*/src/**
|
||||
pattern: Math.random(...)
|
||||
|
||||
- id: bitlogin-no-crypto-fallback
|
||||
languages: [typescript, javascript]
|
||||
severity: ERROR
|
||||
message: >-
|
||||
Do not fall back to another source when getRandomValues is unavailable.
|
||||
Key generation must fail closed -- "continue with reduced security" is the
|
||||
COLDCARD defect class. crypto/random.ts throws by design; keep it that way.
|
||||
paths:
|
||||
include:
|
||||
- /packages/*/src/**
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: $C.getRandomValues(...) ?? $FALLBACK
|
||||
- pattern: $C.getRandomValues(...) || $FALLBACK
|
||||
- pattern: |
|
||||
try { ... $C.getRandomValues(...) ... } catch(...) { ... return $FALLBACK; ... }
|
||||
|
||||
- id: bitlogin-no-time-or-id-seeded-secrets
|
||||
languages: [typescript, javascript]
|
||||
severity: ERROR
|
||||
message: >-
|
||||
Timestamps, counters and identifiers are not entropy. A secret derived
|
||||
from Date.now() or performance.now() is predictable to anyone who knows
|
||||
roughly when it was created.
|
||||
paths:
|
||||
include:
|
||||
- /packages/*/src/**
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: |
|
||||
$KEY = $HASH(Date.now(), ...)
|
||||
- pattern: |
|
||||
$KEY = $HASH(performance.now(), ...)
|
||||
+9
-3
@@ -21,9 +21,15 @@ avoid modulo bias. Three hardening items, in order:
|
||||
Verified from a real consumer against the built package: all three deep-import
|
||||
routes fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. `knownAnswer.test.ts` green
|
||||
throughout — the derivation chain is untouched.
|
||||
- **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.
|
||||
Last remaining item from the entropy audit in this repo.
|
||||
- **L1 — done.** Both `Math.random` calls (backup-quiz picker, relay subscription
|
||||
id) now use the CSPRNG, so the ban has no standing exception. `.semgrep.yml`
|
||||
enforces it in CI alongside two more rules: no fallback around
|
||||
`getRandomValues`, and no timestamp-seeded secrets. Verified the rules fire —
|
||||
reintroducing the old call gives 1 blocking finding and exit 1.
|
||||
|
||||
**The entropy audit is closed for this repo.** The only gate left is an
|
||||
independent review of the RNG path by someone other than the author, which is
|
||||
not something a coding agent can sign off.
|
||||
|
||||
## Owner action items
|
||||
|
||||
|
||||
@@ -188,7 +188,29 @@ modulo) must turn them red — verify this locally before committing, then rever
|
||||
|
||||
---
|
||||
|
||||
### L1 — `Math.random` in the backup-verification quiz
|
||||
### 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`:
|
||||
|
||||
@@ -218,7 +240,7 @@ this same function, vendored. Fixing it here and re-running
|
||||
|
||||
---
|
||||
|
||||
### L2 — `Math.random` in the relay subscription id
|
||||
### 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
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
* A single relay WebSocket connection: NIP-01 EVENT/REQ/CLOSE, NIP-42 AUTH,
|
||||
* and NIP-11 relay information document fetches (§11.8, §16.1, §17.3, §19.2).
|
||||
*/
|
||||
import {
|
||||
computeEventId,
|
||||
signNostrEvent,
|
||||
verifyNostrEvent,
|
||||
type NostrEvent,
|
||||
} from "./event.js";
|
||||
import { signNostrEvent, verifyNostrEvent, type NostrEvent } from "./event.js";
|
||||
import { KIND_AUTH } from "./kinds.js";
|
||||
import { getPublicKeyHex } from "../crypto/secp256k1.js";
|
||||
import { randomBytes } from "../crypto/random.js";
|
||||
import { bytesToHex } from "../crypto/encoding.js";
|
||||
import { isAllowedRelayUrl } from "./relayUrl.js";
|
||||
|
||||
export interface NostrFilter {
|
||||
@@ -215,13 +212,11 @@ export class RelayConnection {
|
||||
timeoutMs = 8000,
|
||||
): Promise<NostrEvent[]> {
|
||||
await this.connect();
|
||||
const subId = computeEventId({
|
||||
pubkey: "0".repeat(64),
|
||||
created_at: Date.now(),
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: JSON.stringify(filter) + Math.random(),
|
||||
}).slice(0, 16);
|
||||
// Subscription ids are public wire identifiers scoped to one socket, not
|
||||
// secrets — collision is the only failure mode. Drawn from the CSPRNG
|
||||
// regardless, so `Math.random` stays banned package-wide (.semgrep.yml)
|
||||
// with no standing exception to reason about at review time.
|
||||
const subId = bytesToHex(randomBytes(8));
|
||||
|
||||
return new Promise<NostrEvent[]>((resolve, reject) => {
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RecoveryExportFile
|
||||
} from "@bitlogin/core/account";
|
||||
import { encodeNpub } from "@bitlogin/core/nostr";
|
||||
import { randomUniformInt } from "@bitlogin/core/crypto";
|
||||
import { WorkerClient } from "./worker/workerClient.js";
|
||||
import { createNip07Provider, type Nip07Provider } from "./provider.js";
|
||||
import { readConfigFromElement } from "./config.js";
|
||||
@@ -1468,12 +1469,22 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks `count` distinct indices in [0, max) for the write-it-down confirmation
|
||||
* quiz.
|
||||
*
|
||||
* This is not a secret and leaks no entropy — the recovery phrase itself comes
|
||||
* from randomEntropy128, and an attacker who cannot see the phrase gains
|
||||
* nothing from predicting which words get quizzed. It uses the CSPRNG anyway so
|
||||
* that `Math.random` can be banned outright in this package (see
|
||||
* .semgrep.yml). A ban with a standing exception is where the next real misuse
|
||||
* hides.
|
||||
*/
|
||||
function pickRandomIndices(max: number, count: number): number[] {
|
||||
const pool = Array.from({ length: max }, (_, i) => i);
|
||||
const chosen: number[] = [];
|
||||
for (let i = 0; i < count && pool.length > 0; i++) {
|
||||
const idx = Math.floor(Math.random() * pool.length);
|
||||
chosen.push(pool.splice(idx, 1)[0]!);
|
||||
chosen.push(pool.splice(randomUniformInt(pool.length), 1)[0]!);
|
||||
}
|
||||
return chosen.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user