Reading "who is signed in" on a page required mounting the whole widget
(bitlogin.js + crypto worker + wasm, ~1.6MB) because that was the only
thing that restored the per-origin session. Multi-page sites paid the
full load on every page just to render a "Signed in as …" header.
Write a cleartext, public-only record ({ pubkey, npub, name? } — never
key material) beside the encrypted session in IndexedDB, in lockstep with
it: the worker writes it at the single persistSession() choke point,
self-heals it on restoreSession (so sessions cached before this feature
upgrade with no re-login), and clears it on logout, so a reported session
can never outlive the real one.
Add @bitlogin/widget/session -> bitlogin-session.js: a self-contained
~1KB gzip reader (zero imports, no worker/wasm) exposing
readCachedSession(): Promise<{ pubkey, npub, name? } | null>. Mirror the
same call on window.bitlogin.readCachedSession() for pages that already
load the full widget.
The private key stays in the AES-GCM record in sessionCache.ts, exactly
as protected as before. Reader falls through to null for every failure
mode (DB/store absent, IndexedDB blocked), which reads as "not signed in".
Addresses the top item of the Tessera integration feedback (docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
Discharges most of the 'live-fire passkey on a real device' TODO without
hardware. Playwright drives headless Chromium with a CDP virtual
authenticator (hasPrf: true) against the built demo served locally and
pointed at three in-process mock relays, so the test is hermetic -- no
public relays, no platform passkey provider, no network.
Covered end to end: navigator.credentials.create, PRF extension results
crossing the browser boundary, worker-side derivation, account
registration (asserted at the PROTOCOL layer -- kind-30078 capsules
really land on the relays, not just a UI state), the Tier B2 phrase card
appearing for a first session and not a returning one,
window.bitlogin.activeSession() reporting, logout, then
navigator.credentials.get signing back in to the SAME npub. A second
test proves PRF is per-credential: a different passkey derives a
different account.
Two things the harness taught us, both now encoded: WebAuthn rejects a
bare 127.0.0.1 as an RP id (localhost only), and the options menu's
disclosure state survives re-renders by design, so the driver opens it
conditionally.
Runs inside npm test and skips itself when the Playwright browser isn't
installed, so CI and other machines stay green. MockRelay gains
storedEventKinds() for protocol-level assertions. Remaining real-device
gap is recorded in TODO: a genuine platform authenticator and the
create-time-PRF-absent fallback, which the virtual authenticator does
not exercise.
268 tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
BL-25 (medium): live subscriptions get the same adversarial-relay
posture buffered queries got in round 2 -- a bounded already-seen id
cache (2048 ids, FIFO eviction) so replaying one retained valid event
costs one verification and ONE delivery ever, plus a fixed-window
verification budget (100 per 10s) that quarantines distinct-event spray
for the rest of the window. The audit's 2,000-replay probe now produces
one callback, bounded by test.
BL-26 (medium): teardown order inverted. retireNip46Client pre-signs
the courtesy logout event while the key exists, then close() runs
IMMEDIATELY -- rejecting every in-flight request and wiping keys -- and
only then is the pre-signed event delivered, asynchronously and bounded
(publishCourtesy, safe after close). The audit's probe -- a pending
get_public_key resolving after logout began -- is now impossible and
regression-tested: in-flight requests reject within the same tick of
close().
BL-27 (low): a nostrconnect listen that fails naturally (timeout,
unreachable relays) discards the pending ephemeral key in a guarded
catch -- ownership-transfer on success unchanged.
BL-28 (low): the hosted verify job installs the pinned Semgrep 1.171.0
before security:ci, which round 2 taught to require it; the job was red
at the previous HEAD and skipping its SBOM/checksum artifacts.
Informational: bunker URI input bounds -- 4096 chars total, 256-char
secret (rejected beyond), 512-char relay URLs (filtered beyond).
8 new/updated adversarial tests; 266 total, all green.
Co-Authored-By: Claude Fable 5 <[email protected]>
BL-21 (medium): untrusted relays and crafted bunker URIs can no longer
buy CPU, memory, or sockets. Frames over 256K chars are dropped before
JSON.parse; buffered queries run every cheap check (id shape, replay
dedup, capacity) BEFORE Schnorr verification and carry a per-query
verification budget (3x limit, floor 50) so distinct forged events
exhaust the budget rather than the CPU; bunker URIs are deduplicated
and capped at 8 relays. The audit's exact probe -- thousands of replayed
copies against limit 1 -- now costs one verification, bounded by test.
Documented trade: a relay that sprays forgeries past the budget forfeits
the query.
BL-22 (low): pending nostrconnect attempts now have a real lifecycle.
Superseding or abandoning an attempt aborts its 180s listener (new
AbortSignal support) and wipes its ephemeral key; on success the key's
ownership transfers into the adopted client, which wipes on close.
Per-candidate conversation keys in the listen loop are wiped in a
finally instead of left to GC.
BL-23 (low): the named local gate no longer skips static analysis --
security:ci runs the custom Semgrep rules via security:semgrep (same
config and pin as the hosted workflow).
BL-24 (informational): the courtesy protocol logout defined by current
NIP-46 is sent on every teardown path (disconnect, supersession),
bounded at 2s and never allowed to block closing; TODO's outdated 'no
stable method name' note is corrected. switch_relays remains the one
deferred interop item.
6 new adversarial tests; 262 total, all green; semgrep gate exit 0.
Co-Authored-By: Claude Fable 5 <[email protected]>
BL-16 (high): a remote signer's auth_url is attacker-controlled input that
became a clickable href. sanitizeAuthUrl in core now admits only https
(or loopback http) -- javascript:/data:/blob:/custom schemes are dropped
without consuming the surfacing slot -- and the widget link shows the
hostname. Hostile-scheme tests at both layers.
BL-17: the post-creation PRF fallback assertion is pinned via
allowCredentials to the credential that was just created, so a multi-
passkey account sheet can no longer silently bind an older passkey's
account.
BL-18: queryOnce's timeout path sent CLOSE before settling, unguarded --
a relay dying mid-query stranded the promise and everything queued behind
the worker's serial queue. The send is guarded; settling is unconditional.
MockRelay.close() now terminates live sockets (it previously waited on
them forever), and a suppressEose switch models the silent relay.
BL-19: relays are no longer trusted to honor limits. Buffered queries
enforce filter.limit locally, drop replayed duplicate ids, and cap at
1000 events; a 20-copy replay now yields one event.
BL-20: the .semgrep.yml try/catch fallback pattern never parsed, so the
entropy rules silently did not run. Rewritten in parseable form and
verified under the pinned Semgrep 1.171.0: fires on hostile code, exits
0 on the clean tree.
Lows: NIP-07 and NIP-46 verify returned signed events (signature, pinned
session identity, field equality with the request); Nip46Client.close()
wipes the client and conversation keys (§11.10); passkey labels carry a
short suffix so multiple accounts are distinguishable; postcss bumped to
8.5.25 (GHSA-fxqj-rqcc-2cmp); GitHub Actions pinned to commit SHAs.
Deferred as interop (recorded in TODO): NIP-46 protocol logout and
switch_relays. 12 new adversarial tests; 256 total.
Co-Authored-By: Claude Fable 5 <[email protected]>
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