README now presents the widget as the complete login surface (four
methods, method-aware events, window.bitlogin session API) and retires
the stale 'NIP-46/NIP-07 interop deferred' line. CHANGELOG gains a
2026-08-04 entry -- leading with the 'wasm-unsafe-eval' CSP requirement
and the service-worker cache:'reload' pattern, since a vendored site
without them has broken password flows. TODO records the shipped
expansion and the owner follow-ups: live-fire Amber QR and real-device
passkey tests, the new passkey derivation compatibility contract, and
the unchanged NIP-46 persistence gate. Removes a dangling on-ramp
fragment my earlier llms.txt edit left fused to the passkey bullet.
Co-Authored-By: Claude Fable 5 <[email protected]>
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
nip44Encrypt and nip04Encrypt took optional nonceOverride / ivOverride
parameters. No production caller ever passed one -- but they were exported
from @bitlogin/core/crypto, so "encrypt with a nonce I chose" was a
discoverable feature of the package rather than an internal detail.
That is worth closing because neither primitive degrades gracefully. NIP-44
is ChaCha20: a nonce reused under one conversation key recovers plaintext
outright. NIP-04 is AES-CBC, where a repeated IV leaks block-equality
between messages and a predictable one gives an attacker considerably more.
A parameter that silently accepts a fixed nonce should not sit one
autocomplete away from application code.
The public functions now take no nonce or IV and always draw from the
CSPRNG. The capability moved to crypto/testing.ts as
__unsafeNip44EncryptWithNonce / __unsafeNip04EncryptWithIv, which is absent
from both crypto/index.ts and the package.json exports map. crypto/index.ts
also switches from `export *` to an explicit re-export list for these two
modules, so the shared encryptWithNonce / encryptWithIv helpers stay off the
surface even though their modules must export them for the test entry point
to reach them.
Closing this cost nothing: exactly one test used an override -- a
nonce-length validation check -- and ivOverride had no callers at all. The
official NIP-44 vectors do not need it.
Verified from a throwaway consumer package linking the BUILT @bitlogin/core
rather than from source. Deep imports of crypto/testing.js,
dist/crypto/testing.js and crypto/nip44.js all fail with
ERR_PACKAGE_PATH_NOT_EXPORTED; the crypto surface contains neither
encryptWithNonce nor encryptWithIv; and nip44Encrypt/nip04Encrypt report
arity 2/3, i.e. no nonce or IV parameter.
Adds four regression tests pinning the closed surface, including that 64
calls with identical plaintext produce 64 distinct nonces and IVs.
knownAnswer.test.ts stayed green throughout, which is the signal that
matters here: no capsule bytes change and no existing account is affected.
Typecheck, build and the full suite pass (185 tests).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
crypto/random.ts is the single chokepoint for every secret this codebase
mints -- recovery entropy, everyday keys, vault roots, AEAD nonces, account
ids, generated passphrases. It is correct today: it throws when no secure
source exists rather than falling back, and rejection-samples to avoid modulo
bias. Nothing in CI would have reported it if either stopped being true.
That is the gap this closes. The existing suite is strong on known-answer
vectors -- HKDF, ScalarExpand, Argon2id, BIP-39, Schnorr, GCM tamper
detection, NIP-44 official vectors -- but every one of them is satisfied by a
deterministic stream. Same input, same output. That is exactly how a
predictable RNG survives a health check that only asks whether the output
looks random.
Adds two blocks, nine tests:
- Fail-closed: no crypto object, crypto without getRandomValues, and
getRandomValues present but not callable must each throw, and
randomEntropy128 / generatePrivateKey / randomUniformInt must abort rather
than substitute a value. Plus a check that normal operation resumes once a
source is available again, so the guard cannot be satisfied by breaking
everything permanently.
- Uniformity: chi-square over 200k draws at modulus 94, full-range coverage
at the EFF wordlist modulus of 7776, rejection of invalid bounds, and the
degenerate modulus of 1.
Both mutation-verified rather than assumed. Replacing the throw with a
zero-filling stub turns five fail-closed tests red. Replacing rejection
sampling with a bare `value % maxExclusive` gives chi-square 5614 against a
threshold of 147 -- while leaving every other test in the suite green, which
is the whole reason the distribution test has to exist.
Tests only; no source or behaviour change. Suite 172 -> 181.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
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
Records the Dependabot PR to close and the manual-password decision to
confirm, plus the deferred hardening tiers and the gates still in force.
The part worth keeping visible: knownAnswer.test.ts failing is never "update
the expected value" -- it means the change is account-breaking. The rest of
the crypto suite only checks self-consistency, which stays green through
exactly the upgrade that would lock every user out.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1