Commit Graph
83 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Fable 5 30b4f34300 Recovery phrase is mandatory for passkey accounts
Owner decision: keep the architecture (static, independently hostable, no
BitLogin-operated OAuth service, shared signer, or central login domain)
and strengthen recovery instead.

Passkey registration now runs the phrase ceremony BEFORE granting a
session -- phrase shown, three words verified, then dashboard -- exactly
as password signup does. The deferred 'Secure your account' card is gone,
along with the securePhrasePending state behind it: that design was
inherited from the OAuth on-ramps, where a service still held a copy of
the credential. Nothing holds a copy for a passkey account, so deferring
the only recovery path was strictly worse than the design it came from.

Note the recovery EXPORT is not an alternative here: by design it carries
no phrase-derived key and cannot recover an account alone (§19.5), so the
phrase ceremony is the only thing that closes the trap.

Also documents the loss mode that surprises people: a passkey is bound to
the site's WebAuthn RP id, so a deployment changing domain leaves its
users' passkeys unable to unlock anything there -- account untouched on
relays, phrase still works.

The e2e now drives the real ceremony (reads the 12 words from the DOM,
answers the three-word quiz) and asserts activeMethod() is null until it
completes, so 'no session before recovery' is enforced against a real
browser. New policy pin: runPasskeyRegistration must hand off to the
phrase screens and must not dispatch a session itself.

269 tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 15:41:24 -04:00
thePR0M3TH3ANandClaude Fable 5 0f066750b6 Correct two errors in the superseded on-ramps doc
The recorded rejection of a shared ecosystem client ID was written
against a credential-RELAY hub (one that hands the unlock secret back to
the calling site) and stated too broadly. A hub that stays the SIGNER --
exposing only NIP-07-shaped operations over an origin/nonce-bound
postMessage channel, never releasing the credential -- is not refuted by
that objection. Recorded, with the observation that such a hub is
architecturally a NIP-46 bunker, which BitLogin already supports: the
open question is whether anyone runs a Google-gated one, not whether to
add a protocol. Residual cost restated plainly: the hub's served
JavaScript becomes the security of every user of every integrating site.

Also corrects drive.appdata's classification -- non-sensitive per
Google's current Drive scope documentation, not sensitive as written.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 15:17:12 -04:00
thePR0M3TH3ANandClaude Fable 5 9534adf820 PK6: record concrete PRF provider support, including KeePassXC's absence
Replaces the vague 'modern authenticator stack' line with an observed
snapshot table: Google Password Manager, iCloud Keychain, Bitwarden, and
hmac-secret hardware keys work; KeePassXC 2.7.12 does not (it manages
passkeys and ships credProps, but no prf/hmac-secret -- verified by
binary inspection, and PRF must be evaluated inside the authenticator, so
its browser extension cannot supply it); de-Googled Android is unreliable
and better served by the NIP-46 rail. States plainly that uneven support
is the method's real limitation, that only the ceremony is authoritative,
and that a deployment must never make a passkey the only route to an
account.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 15:12:34 -04:00
thePR0M3TH3ANandClaude Fable 5 fa9e2d9665 Automated passkey e2e: real Chromium, real WebAuthn ceremonies, virtual PRF
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]>
2026-08-04 14:57:10 -04:00
thePR0M3TH3ANandClaude Fable 5 25df5140b3 Audit round 3: live-sub budgets, revocation-before-courtesy, pending-key catch, CI semgrep, input bounds
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]>
2026-08-04 14:31:25 -04:00
thePR0M3TH3ANandClaude Fable 5 bb9788588f Audit round 2: resource ceilings, key lifecycle, local gate, courtesy logout
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]>
2026-08-04 13:56:29 -04:00
thePR0M3TH3ANandClaude Fable 5 f0b5f40fd4 Audit response: fix BL-16 through BL-20 and the low findings, same day
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]>
2026-08-04 13:10:20 -04:00
thePR0M3TH3ANandClaude Fable 5 36cd3eb853 Sync dev docs with today's work: README, CHANGELOG, TODO, llms.txt cleanup
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]>
2026-08-04 12:58:32 -04:00
thePR0M3TH3ANandClaude Fable 5 8079d90cf8 Replace OAuth on-ramps with passkey sign-in: zero registration, zero servers
Owner decision: static only, and no per-site provider registration --
which every OAuth path requires (and GitHub/Facebook additionally require
a server). Both on-ramp implementations (service handshake, serverless
Google Drive) are removed the day they shipped; centralized-onramps.md
stays on the record as the analysis of why, superseded by
passkey-login.md.

The passkey rail: a WebAuthn PRF-capable passkey (synced for most users
by Google Password Manager or iCloud Keychain) deterministically derives
the login name and password of a standard capsule account -- passkey.ts
holds the derivation behind a FROZEN v1 known-answer contract with the
same standing rule as knownAnswer.test.ts. The ordinary password flows
do everything else; nothing is stored outside the user's authenticator,
and the rail needs no network of its own.

Flow is idempotent by construction: sign-in against a passkey with no
account routes into setup with the derived credential
(AccountNotFoundError); setup against an existing account signs in
(AccountAlreadyExistsError). Fresh identity or §SF10 nsec import at
setup; Tier B2 phrase card retained ('your account stays yours even if
you lose your passkey'); rotation is the graduation step and says so.

Pins updated: passkey row with honest custody label inside the menu,
OAuth actions provably absent, element still network-free. 11 passkey
tests incl. the frozen vectors; menu and screens verified in Chrome.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 12:20:26 -04:00
thePR0M3TH3ANandClaude Fable 5 be5dd529e9 Record the Google rail's scoping and blast radius in §CO3.3
Per-client-ID app-data silo makes the rail per-site; cross-site
portability is the capsule account's job. A host's dead Google project
locks out ungraduated users in practice while every account survives on
relays -- the phrase card is load-bearing. Shared ecosystem client ID
behind a bitlogin.network relay hub considered and rejected: a chokepoint
plus federated credential readability is worse than the silo. Note the
sensitive-scope verification review hosts should expect.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 11:59:13 -04:00
thePR0M3TH3ANandClaude Fable 5 37f5bbd243 Serverless 'Continue with Google': the credential lives in the user's own Drive
Implements centralized-onramps.md §CO3.3, per the owner call that static
only is the way. No server anywhere: the host registers a Google OAuth
client ID (onramp-google-client-id) and the widget does the rest -- a
popup runs the plain implicit grant (no external script, so
script-src 'self' survives), scoped to drive.appdata only, redirecting
back to the widget page itself, which relays the token fragment to its
opener under the same origin+nonce discipline as the service handshake.

Returning users: the credential file is read from the user's app-data
folder and fed into the ordinary password login. First-timers: fresh
identity or §SF10 import, client-side registration, credential uploaded
to their own Drive -- with the no-orphan rule forcing the phrase
ceremony if the upload fails. Rotating the password later IS the
graduation step; the rotate screen says so for managed sessions, and
the dashboard states the custody plainly: your sign-in key is stored
in your own Google Drive.

driveOnramp.ts is pure and unit-tested (13 tests); new pins keep all
network I/O out of the element and constrain Drive fetches to the two
Google API constants. Hosts add https://www.googleapis.com to
connect-src and register the widget page URL as the redirect URI --
documented in the guide, llms.txt, and the design doc (v0.3), including
the recorded dependency on the legacy implicit grant.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 11:48:51 -04:00
thePR0M3TH3ANandClaude Fable 5 ff1470a849 Centralized on-ramps, widget side: 'Use an account you already have'
Implements centralized-onramps.md §CO5. A host opts in via onramp-url /
onramp-name / onramp-providers attributes -- nothing centralized ships
by default. The options menu gains the owner-chosen group heading 'Use
an account you already have' covering extension, remote signer, key
import, and 'Continue with <Provider>' rows whose sub-label IS the
custody statement (§CO7): '<Service> manages your sign-in'.

The handshake (onramp.ts, pure and unit-tested): popup to the
configured URL with a CSPRNG state nonce; exactly one unlock or
register message accepted, only from the configured origin with the
exact nonce. Returning users flow into the ordinary password login.
New users choose a fresh identity or §SF10-import an existing nsec;
registration runs client-side and the password is POSTed to the
service -- if the store fails, the phrase ceremony is forced
immediately rather than deferred, so no account exists that nobody
can unlock. Tier B2 otherwise: the dashboard carries a 'Secure your
account' card into the existing phrase screens while the phrase is
still in session memory, plus a standing '<Service> manages your
sign-in' line.

Policy pins: on-ramp rows render only inside the menu with the custody
label; group heading wording pinned. 12 new tests; verified visually
with a configured element.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 11:34:52 -04:00
thePR0M3TH3ANandClaude Fable 5 dc6490e46a Design doc: centralized OAuth on-ramps as a journey toward self-custody
Proposes 'Sign in with Google/GitHub/…' as a clearly labeled fourth
method family behind the same login surface. Two architectures: an
OAuth-gated NIP-46 bunker (works today, full custody, the proof point)
and the recommended one -- OAuth as the password manager for a REAL
capsule account, where the service holds only the unlock secret, the
identity lives on relays, and service compromise maps to the password-
compromise analysis the spec already has (§14).

Phrase handling is a labeled custody dial (shown at signup / first-
session-only with a 'secure your account' card / escrowed). Graduation
keeps the same npub at every step: save the npub, claim the phrase,
hold your own password, full export -- with §SF10 import as the
already-implemented escape from any custodial tier. Honesty contract:
every centralized option answers who can access the account, what
happens if they vanish, and how to leave. Conforming services must
export and delete on demand and publish their tier machine-readably.

Owner framing on the record: meet users at whatever sign-in they trust,
nudge toward decentralization over time -- 'it's a journey for all of us.'

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 11:15:31 -04:00
thePR0M3TH3ANandClaude Fable 5 a7cf55ea84 window.bitlogin.activeMethod()/activeSession(): session state for every method
Hosts had no method-agnostic session check: isActiveSigner() answers who
backs window.nostr, which is deliberately false during a NIP-07 session
(the extension IS the signer and rightly owns the slot) even though the
user signed in through BitLogin. Sites using it as a login check
mis-read extension sessions.

A page-level registry (globalSession.ts, public data only -- method,
pubkey, npub) is written at the single dispatchLogin point and cleared
on logout and DOM removal, owner-scoped so one element's teardown can't
clear another's session. window.bitlogin gains activeMethod() and
activeSession(); the integration guide and llms.txt now spell out the
two-questions distinction and lead with the one-login-surface pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:58:14 -04:00
thePR0M3TH3ANandClaude Fable 5 14d993d11a Screen headers with icon back buttons; tidy the sign-in screen
Every pre-auth screen (sign in, create, import, recover, extension and
bunker flows) opens with a chevron back button beside its title instead
of ending with a dangling 'Back' link after the form. The sign-in
screen's 'Forgot password? Recover with phrase' shrinks to a
right-aligned 'Forgot password?' under the password field -- the
recovery screen itself explains the phrase. Back buttons are 36px icon
targets with aria-label; the rollback screen's 'Cancel' keeps its
explicit wording. Verified in the browser.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:50:58 -04:00
thePR0M3TH3ANandClaude Fable 5 2e7b1e19e3 Welcome-screen polish: alternative methods behind a collapsed options menu
Import, recovery, extension, and remote signer no longer stack as four
links under the primary buttons -- they live behind a 'More sign-in
options' disclosure that expands into an icon+description option menu.
Rows keep a 56px minimum touch target, the toggle carries aria-expanded,
chevron rotation respects prefers-reduced-motion, and buttons get a
focus-visible outline. The disclosure state survives re-renders so an
error doesn't snap the menu shut. Contextual renderings stay put: the
login screen keeps 'Forgot password?' and the bunker screen its retry.

Also cap the nostrconnect URI at two relays: five relay params pushed
the QR several versions denser, and tiny modules hurt both the dot
aesthetic and camera lock-on. Verified in the browser: collapsed and
expanded welcome, and the sparser QR.

Policy pins updated to the new structure: every alternative action must
render inside the option-menu template, with exact occurrence counts.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:45:17 -04:00
thePR0M3TH3ANandClaude Fable 5 49682dd75c NIP-46 sign-in: remote-signer sessions behind the same facade, memory-only
Implements login-methods.md §LM5 phase one. Core gains nostr/nip46.ts -- a
Nip46Client speaking kind-24133 RPC with NIP-44 transport encryption,
bunker:// parsing, nostrconnect listen (secret echo is proof-of-scan),
auth_url surfacing, and typed deadline errors -- plus subscribeLive on
RelayConnection with re-REQ on reconnect, and live post-EOSE delivery in
the MockRelay so tests exercise real round trips against a fake bunker.

The ephemeral client key and Nip46Client live in the crypto worker
(§LM5.4); nip46* actions bypass the serial queue so a signer waiting
minutes for human approval cannot block other worker calls, and the
signer's auth_url reaches the UI as the worker's first notification frame.

The widget's connect screen runs both legs at once: a nostrconnect QR --
rendered as dot-style inline SVG with rounded finder eyes, per owner
preference -- for phone signers, and a bunker:// paste box (password
input; the URI carries a token). window.nostr is now an element-routed
provider, so a NIP-46 session claims the slot and hosts reading it get
the remote signer transparently; NIP-07 still leaves the slot to the
extension. Sessions stay thin per §LM7: no vault, no persistence.
Persistence remains gated on the external vault review (§LM5.3).

14 new tests (core fake-bunker e2e, QR geometry, signer delegation) and
two policy pins: bunker option below the password path, element stays
web-storage-free.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:31:36 -04:00
thePR0M3TH3ANandClaude Fable 5 b616068689 Service worker: precache with cache:'reload' so 304s can't bake stale headers
Diagnosed live: after the CSP fix deployed, a client's SW precached the new
release but cryptoWorker.js revalidated to a 304 (bytes unchanged across
the deploy) and kept its OLD stored headers -- including the
pre-'wasm-unsafe-eval' Content-Security-Policy. A dedicated worker runs
under its own script response's CSP, so Argon2id still could not compile
even though every page and the origin server carried the fixed policy.

cache:'reload' forces full network fetches during install, so cached
responses always carry the headers current at install time. The sw.js byte
change also makes every installed client -- poisoned ones included --
reinstall into the same-named cache, overwriting the stale-header entries.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:05:14 -04:00
thePR0M3TH3ANandClaude Fable 5 6b10f0b902 Fix live login: allow WASM compilation under the deployment CSP
script-src 'self' alone forbids WebAssembly.compile, and Argon2id
(hash-wasm) compiles a WASM module in the crypto worker -- so the CSP
added in the second-audit hardening broke every password flow on the
deployed site. Add 'wasm-unsafe-eval', which permits WASM compilation
only; JS eval stays banned, and the policy test now pins both facts so
neither can regress silently.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 09:55:49 -04:00
thePR0M3TH3ANandClaude Fable 5 0fec9c4182 Demo guide and llms.txt: document the extension sign-in method
The integration guide's bitlogin-login example now shows detail.method and
detail.capabilities, a callout explains the one-surface model and what an
extension session does and doesn't get, and the window.nostr claiming
section states the exception up front: NIP-07 sessions leave the slot to
the extension. Same facts mirrored in llms.txt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 09:41:17 -04:00
thePR0M3TH3ANandClaude Fable 5 a7adef05f9 NIP-07 sign-in: delegate to an installed extension behind the same facade
Implements login-methods.md §LM4. New signers/ layer in the widget:
detectForeignNip07Provider never mistakes BitLogin's own provider for an
extension, and Nip07Signer wraps the snapshotted provider with capability
probing (typed SignerUnsupportedError instead of TypeErrors off missing
nip44), deadlines on every call (SignerTimeoutError -- no silent hangs),
pubkey validation, and unsigned-event normalization.

The element routes its public API through the active signer, so hosts get
the extension backend transparently. Extension sessions are thin (§LM7):
no capsule, no vault (requestNwcConnection resolves null), no persistence,
and window.nostr is left to the extension -- claimSigner stays reserved
for BitLogin-account sessions. bitlogin-login now carries method and
capabilities; publicKey is unchanged for existing hosts.

Password-first pinned (owner decision, §LM9.1): the extension option
renders as a secondary link under the primary sign-in, enforced in
securityPolicy.test.ts alongside a pin that the extension confirm path
never claims window.nostr or writes storage. 14 new signer tests.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 09:28:24 -04:00
thePR0M3TH3ANandClaude Fable 5 20a3c50dc2 Design doc: the widget as the single login surface (NIP-07, NIP-46)
Proposes aggregating sign-in methods behind the existing provider facade
instead of being one option in a host's login menu. NIP-07 delegates to an
installed extension; NIP-46 connects to a remote signer, memory-only until
the vault review gate clears. Rejects nsec paste as a login method on the
record — §SF10 import is the sanctioned path for raw keys. Non-BitLogin
sessions are signer-only: no capsule, no vault, no portable state.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 09:20:49 -04:00
thePR0M3TH3ANandClaude Opus 5 205b0678cc Pin that pre-removal passwords still sign in
Manual passwords were removed from the widget on 2026-07-31. That change
took away the CONTROLS -- you can no longer type your own password when
creating, recovering, or rotating -- but it did not change the derivation
chain and it did not add any check on the login path. Accounts created with
a hand-typed password therefore still sign in exactly as before.

That distinction is load-bearing and was previously unwritten. BitLogin
keeps no server-side record, so if sign-in ever stopped accepting a password
shape it had previously issued, those accounts would be reachable only
through their recovery phrase. Nothing in the suite said otherwise, and
securityPolicy.test.ts pins only the absence of the creation/recovery/
rotation controls.

Four tests: register-then-login with a hand-typed password, wrong password
still refused, the real migration path (sign in with the old typed password,
rotate onto a generated one, old one stops working), and derivation across
the password shapes the old UI accepted -- short, spaced, symbol-laden,
whitespace-padded, unicode, and 256 characters. No length floor, charset
rule, or entropy gate exists on the derivation path, which is exactly what
keeps those accounts reachable.

Separately confirmed by diff rather than by test: normalize.ts, argon2id.ts,
hkdf.ts and scalarExpand.ts are unchanged between efd994c -- the build
currently vendored by bitroad, bitunlock, bitvid and satisfied -- and HEAD.
Had any of them moved, every password would break, generated ones included.

Suite 185 -> 189.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 22:04:01 -04:00
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
thePR0M3TH3ANandClaude Opus 5 e131f0313b Take nonce and IV control off the public crypto surface
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
2026-07-31 17:20:05 -04:00
thePR0M3TH3ANandClaude Opus 5 b85e1572ea Pin fail-closed RNG behaviour and randomUniformInt's uniformity
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
2026-07-31 16:29:21 -04:00
thePR0M3TH3ANandClaude Opus 5 ba49673ef9 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
2026-07-31 16:26:35 -04:00
thePR0M3TH3ANandClaude Fable 5 30d4ac4b83 Open items, and the standing rule about the derivation chain
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
2026-07-31 14:52:30 -04:00
thePR0M3TH3ANandClaude Fable 5 6eb1d7d640 Pin the derivation chain, and stop Dependabot grouping crypto majors
A Dependabot PR failed the build by bundling @noble/hashes 1.8->2.2,
@noble/curves 1.9->2.2, @scure/base 1.2->2.2, @scure/bip39 1.6->2.2,
TypeScript 5->7, Vite 6->8 and Vitest 3->4 into a single change. Production
was never affected -- only that preview -- and the cause is real rather than
flaky: noble v2 exports only ".js"-suffixed subpaths, while this code imports
the extensionless "@noble/hashes/sha2" that 1.8 provides.

Failing to compile was the LUCKY outcome. The tests could not have caught the
dangerous version of this. Account capsules live on public relays, and the
login name plus password derive the locator, signing scalar and capsule key
deterministically -- so those outputs are a compatibility contract with every
account that already exists. The crypto suite only checked self-consistency
(same input, same output within one run), which stays green even if HKDF,
base64url framing, BIP-39 seeding or JCS ordering all change. A grouped major
that compiled would have shipped and locked every existing user out, with no
server-side record to migrate them from.

So this adds knownAnswer.test.ts, pinning the actual outputs of HKDF-Extract,
HKDF-Expand, ScalarExpand (scalar, counter and resulting public key), BIP-39
phrase and seed, base64url, and JCS ordering. The vectors are captured from
the shipping implementation, and the header says plainly what a failure means:
not "update the expected value", but "this upgrade is account-breaking".

dependabot.yml now groups only minor and patch updates. Majors are still
proposed, individually, so each can be judged on its own. Majors of the
derivation chain itself (@noble/*, @scure/*, hash-wasm, canonicalize) are
ignored entirely -- that is deliberate work with the vectors green, not an
automated PR waiting to be merged on a quiet afternoon.

172/172 tests, typecheck clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 14:46:46 -04:00
thePR0M3TH3ANandClaude Fable 5 7d2209d706 Second-audit hardening: phrase-gated sudo key, deterministic migration, policy tests
Authored by ChatGPT/Codex in a separate review pass. Reviewed and verified
here before committing rather than taken on trust; findings below.

The substantive change is that the vault sudo key now lives ONLY in the
phrase-gated recovery capsule. A newly written password credential capsule
carries just connection_vault_root, so an ordinary login restores
connectable-tier records without putting personal-tier material behind a
password capsule that is downloadable and therefore offline-guessable forever.
Pre-hardening capsules stay readable, password login does not return their
sudo key, and the next rotation scrubs it. That closes the gate this project
had explicitly set on personal-tier UI.

Legacy vault-enable migration now derives roots deterministically from
phrase-protected material and account_id, so two concurrent attempts cannot
mint divergent roots -- the failure mode the docs previously mitigated with
"enable the vault once, from one device".

Manual (user-chosen) passwords are REMOVED, not merely discouraged, and a new
securityPolicy.test.ts pins that along with several things tests do not
usually reach: the deployment CSP (default-src 'none', frame-ancestors
'none') plus six baseline headers, demo pages free of inline scripts and
remote fonts, and README documenting same-origin self-hosting only rather
than a remote demo embed. llms.txt was updated to match, which I checked
because that test only reads README.

What I verified, since a passing suite proves less than it appears to:

- All 12 fixes from the 2026-07-30 audit survived the rewrite of recover.ts,
  changePassword.ts, validation.ts, relay.ts, session.ts and element.ts.
  Checked semantically where grep cannot help: changePassword still checks
  publish success BEFORE building the tombstone, and vaultSetBinding still
  unbinds on origin === null rather than silently rebinding to the worker
  global.
- No dependency changes; the package.json diff is scripts only.
- Argon2id and KDF parameters unchanged. The NIP-44 diff ADDS a base64 length
  cap before decoding, a memory-DoS guard.
- Validation increased rather than decreased: 44 throws added, 33 removed.
- No disabled or skipped tests, no secrets in the diff.

typecheck clean, 165/165 tests, build green, and the account manager exercised
in a browser: creation correctly refused when relay quorum could not be met,
and on retry failed at the recovery capsule with "No login credential was
published" -- the §18.1 fail-closed ordering doing its job against a flaky
public relay set.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 13:54:36 -04:00
thePR0M3TH3ANandClaude Fable 5 efd994c4de Fix revoke-rebinds bug; correct the website's custody and revoke claims
The audit's most important finding was not a doc issue at all. In
vaultSetBinding the identifier `origin` resolved to the WORKER GLOBAL
self.origin rather than the intended value, so the dashboard's "Revoke
app access" REBOUND the record to the calling page instead of clearing
it -- the precise cross-origin adoption the binding guard exists to
prevent, wearing the label of the button meant to prevent it. It
typechecked because the DOM lib declares that global. I introduced this
hours ago: a string replace intended for one call site silently matched
two. Fixed, with the reasoning written where the next reader will hit it.

Relay/account audit fixes:
- Phrase recovery permanently tripped the rollback alarm. The mark is
  keyed by the everyday pubkey (unchanged by recovery) while recovery
  legitimately restarts at generation 0, and raiseHighWaterMark keeps
  the maximum -- so anyone who had ever rotated a password met "possibly
  revoked credentials" on EVERY later login, forever, with no way to
  clear it. The phrase is the master authority; it now resets the mark.
- quorumMet counted TCP connections, not answers: a query timeout
  RESOLVED, so a connectable-but-silent relay counted as responding and
  every "not enough relays answered" guard was near-vacuous. Only EOSE
  is an answer now.
- Relay responses were never checked against the address requested, so
  one junk event from one relay blocked registration, rotation, and
  recovery for every login name and password a user tried. Events must
  now match author, kind, and d-tag.

Website honesty (the surface a stranger reads first, and the one that
had drifted furthest from the spec):
- docs.html and llms.txt still carried "private keys never leave the
  worker" -- false on its face, since the widget ships a reveal-nsec
  button. Both now state the same limit the README does.
- "you revoke" became honest everywhere: removing a connection deletes
  the stored copy; spending authority is revoked at the wallet.
- index.html dropped a pre-softening recovery claim, now says an account
  lives only while relays retain its capsule, and says "independently
  unaudited" out loud.
- llms.txt gains the two warnings that were missing entirely: losing
  BOTH password and phrase is permanent, and removal is not revocation.
- Stale headers: nwc-connections.md no longer claims "Not implemented",
  docs/README no longer says the authorization layer is unbuilt, the
  test count and spec version are current, and the demo domain points
  at bitlogin.network.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 09:47:01 -04:00
thePR0M3TH3ANandClaude Fable 5 d040c2f5af Tier 1 hardening: no more silent hangs, no writes before consent
Reliability, which is where most of these reach users:
- The worker RPC had no message validation, no error/messageerror
  listener, no timeout, and terminate() left every pending promise
  unsettled. A malformed frame threw inside the listener (posting no
  response at all) and a dead worker stranded its callers -- the
  documented innerHTML-remount hazard manifested as a permanent freeze
  rather than an error. Both sides now validate the envelope, calls
  time out at 60s, and worker death rejects everything outstanding.
- requestNwcConnection fired no pending event, so a host using the
  RECOMMENDED hidden-mount pattern navigated a hidden element to a
  consent screen nobody could see and waited forever. Now symmetric
  with bitlogin-offer-pending.

Consent integrity:
- vaultOfferCheck published an origin rebinding to relays -- a durable
  mutation of the user's account -- from a plain host API call before
  any consent screen existed. It is read-only now, and a wallet stored
  under a DIFFERENT origin no longer reports as already-saved, so the
  binding can only change through the consented path.
- offerNwcConnection's guard sat above three awaits; two concurrent
  calls both passed it and the second dropped the first's promise. The
  slot is claimed synchronously.

Vault reads fail closed:
- listConnections threw rather than returning a relay-shaped subset
  when quorum is not met -- a short listing is indistinguishable from
  "you have no connections", and one relay should not get to say that.
- The 500-record page limit is reported (truncated) instead of silently
  deciding which connections exist.

Supply chain:
- All eight crypto dependencies pinned to exact versions (they held
  users' keys behind caret ranges), and Bitcoin Connect with them.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 09:40:49 -04:00
thePR0M3TH3ANandClaude Fable 5 b414cc53d2 Security audit: fix 8 findings across crypto, vault, and widget
An adversarial audit (five independent reviewers over crypto/protocol,
the vault, the widget, relay flows, and doc honesty) found real bugs in
code this repo shipped. Fixed here, worst first:

CRITICAL - the widget worker ignored application_binding.origin on
vaultRevealNwc, so any caller naming a connection id could pull a
wallet credential the user granted to a DIFFERENT site. The worker now
derives its own origin (self.location) instead of trusting a payload
field -- which also closes binding forgery via vaultSaveNwc /
vaultSetBinding / vaultOfferCheck, where a host could bind another
site's wallet to itself and inherit its one-tap approval.

CRITICAL - VaultSession.destroy() left the vault root live (the root
regenerates every derived key) and, worse, left the session USABLE with
a zeroed prk: deriveRecordKey/deriveVaultSigningKey over 32 zero bytes
are deterministic and world-computable, so a stale reference could seal
a wallet secret under a key any observer can derive and report success.
Now wipes the root and fails loudly.

CRITICAL - changePassword published the tombstone that DESTROYS the old
capsule before checking that the new one reached quorum: a failed
rotation could leave neither password working, phrase-only recovery.
Reordered to fail closed with the old password intact, plus a
regression test that drives it with a partially-hostile relay set.

HIGH - enableConnectionVault never checked quorum on the recovery read,
so one stale relay could make it mint a SECOND vault root and orphan
every stored credential -- the outcome its own header calls
unforgivable.

HIGH - the rollback high-water mark used strict `<` and never compared
the event id it stored, so a same-second sibling of a revocation could
resurrect a revoked credential forever. Ties now break on event id.

HIGH - §11.10 claimed derived secrets are wiped; wipe() had two callers
and neither was on the password or recovery path. The Argon2id output,
both PRKs, and the 64-byte BIP-39 seed now die at their derivation
site.

MEDIUM - one malformed record threw out of the listing loop and made
every OTHER connection unlistable; recovery lacked the
already-registered-at-this-locator guard that registration and rotation
both carry; the recovery chain check silently passed on gaps and forks.
Synthetic (untrusted) clicks and submits no longer drive consent UI.

Docs corrected where they overstated: the README's "private keys never
leave the worker" now says plainly that a same-origin host can reach
exportIdentity, and connection-vault.md gains §19 recording six OPEN
limitations -- no CAS on capsule writes, per-device-only rollback
state, tombstones not destroying prior ciphertext, the sudo key
shipping with the root, fetches not failing closed, and a plaintext
mark store.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 09:00:24 -04:00
thePR0M3TH3ANandClaude Fable 5 2cf2ee9351 Widget: dispatch bitlogin-offer-pending when the offer screen shows
Hosts that keep the element hidden until needed (the recommended
permanent-mount pattern) listen for this to reveal it; the silent
outcomes (duplicate, unavailable) never fire it, so nothing flashes
for a no-op. Already shipped in the vendored builds consuming this --
the source line lagged the artifact, which this commit corrects.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-30 23:43:55 -04:00
thePR0M3TH3ANandClaude Fable 5 6b48de6168 Widget: offerNwcConnection() -- consent-gated offer-to-save
The inverse of requestNwcConnection: an app that obtained an NWC URI by
its own means (its own wallet chooser, its own paste box) offers the
user a portable copy. The credential is already in the app's hands, so
the only question on screen is whether a copy enters the user's vault
-- and that write happens in BitLogin's own chrome or not at all, never
silently, because a page that can pump records into someone's account
without a tap is a spam vector wearing a feature's clothes.

Dedupe by connection identity (wallet pubkey + secret; relays and
labels may drift without making a new connection -- sameNwcCredential
in core): a duplicate resolves 'already-saved' with a silent
origin-binding refresh and no UI, which is what makes the call safe to
fire after every successful connect. 'unavailable' (no session, no
vault root) is a quiet no-op by contract.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-30 21:17:06 -04:00
thePR0M3TH3ANandClaude Fable 5 6841d647c2 Site: document requestNwcConnection and the Connection Vault
Integration guide gains the wallet-connection step (reveal-mode honesty,
CSP requirement, legacy-account caveat), llms.txt gains the API entry
and event, the landing page gains a "Wallets that follow you" feature,
and the file-copy step now says five files.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-30 14:14:00 -04:00
thePR0M3TH3ANandClaude Fable 5 c3e55add16 Widget: requestNwcConnection() -- vault authorization layer, reveal mode
The Phase D surface from vault-ux.md, scoped to NWC and honest about its
boundary: an embedded same-origin widget cannot broker (§CV12.3), so the
API reveals, says so in its consent copy, and leans on the wallet-side
budget as the real security boundary.

One public call owns the whole ceremony. If nobody is signed in, the
sign-in flow runs first and the request resumes via the goto("dashboard")
hook exactly once; if the account has a connection bound to the
requesting origin, the user gets the one-tap approval screen the vault
exists for; otherwise a guided import -- Bitcoin Connect's wallet
chooser or a pasted URI. The BC integration lifts Satisfied's
production discipline verbatim: chooser only, lazy chunks (never
downloaded unless the flow opens), no numeric budget in
authorizationUrlOptions (the unit is not interoperable), disconnect and
clear bc:config so no plaintext copy outlives the ceremony.

The worker holds the vault root (never the sudo key -- wiped on
arrival) and gains vaultStatus/List/SaveNwc/FindForOrigin/RevealNwc/
SetBinding/Delete actions; the root rides the encrypted session cache
so restored sessions keep working, with a vaultEnabled marker so a
stale cache says "sign in again" instead of falsely claiming the
account has no vault. Publishing failures are loud -- a connection that
reached no relay is not saved anywhere and says so. The dashboard gains
Wallet connections management with "revoke app access" and "remove
from BitLogin" honestly separated from wallet-side revocation.
VaultSession gains buildDeletionRequest so the NIP-09 courtesy ask is
signed without exposing the signing key.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-30 13:10:39 -04:00
thePR0M3TH3ANandClaude Fable 5 a465e1d8fa Implement the Connection Vault protocol layer (phases A-C)
connection-vault.md 5-13 as code, with the implementation-time decisions
recorded in a new section 18 and vault-ux.md holding the agreed UX
walkthroughs as the authorization layer's acceptance criteria.

Capsules gain paired optional fields connection_vault_root and
vault_sudo_key (both or neither, validated); registration mints them,
login/change-password/recovery carry them forward, and
enableConnectionVault is the phrase-gated migration for existing
accounts -- recovery capsule written first, fail closed, idempotent by
reusing roots found in either capsule so a partial failure can never
mint twice and strand records.

src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand
constants pinned by test vectors that must only ever be reverted, never
updated), per-connection encrypted records signed by a derived vault
identity with the capsule JCS+padding+AES-GCM pipeline and shared
buckets so record types are indistinguishable by size, the NWC profile
with lossless URI parse/export, relay sync reusing the publish-and-
verify quorum bar, and per-record high-water marks so a stale replica
is withheld and reported rather than silently resurrecting a revoked
credential.

The two-tier model makes sudo mode cryptographically real: personal-
tier record keys require vault_sudo_key as HKDF input, VaultSession
holds it only inside an enableSudo/endSudo window (wiping on the way in
and out), and outside a window personal records are invisible -- the
same trial-decrypt returns null, not an error. The app-facing API is
structurally blind to the tier by construction.

118 tests passing, including four end-to-end scenarios against mock
relays: clean-device restore, password-change carry, legacy-account
migration (wrong phrase refused), and rollback detection.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-30 12:29:46 -04:00
thePR0M3TH3AN e9a5a41ff7 fix(widget): encrypt persistent session cache 2026-07-25 23:28:41 -04:00
thePR0M3TH3AN fe9a7afde2 Add BitLogin documentation index 2026-07-22 20:56:59 -04:00
thePR0M3TH3AN 82539c2057 Document relay and messaging portability 2026-07-22 20:56:45 -04:00
thePR0M3TH3AN f656f014bb Document experimental portable entitlements 2026-07-22 20:56:07 -04:00
thePR0M3TH3AN 66c97820db Document experimental portable application state 2026-07-22 20:55:24 -04:00
thePR0M3TH3AN 6f0bc2a056 Document experimental multi-account switching 2026-07-22 20:54:46 -04:00
thePR0M3TH3AN 0c9011e064 Document experimental S3C connection string format 2026-07-22 17:41:33 -04:00
thePR0M3TH3AN 6000cfd06f Document experimental NWC Connection Vault profile 2026-07-22 17:40:33 -04:00
thePR0M3TH3AN 98a8c60821 Document experimental BitLogin Connection Vault 2026-07-22 17:39:45 -04:00
thePR0M3TH3ANandClaude Sonnet 5 681cec7734 Document session persistence in README and CHANGELOG
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-22 07:47:49 -04:00
thePR0M3TH3ANandClaude Sonnet 5 9e3787d301 Add local session persistence so a page reload doesn't require re-login
Every reload previously destroyed the crypto worker, forcing the full
login-name + password flow again -- re-running Argon2id and a relay
quorum read, both deliberately expensive (the whole point of Argon2id
is to resist offline brute force), on every single page navigation.
No real site can expect users to accept that.

After a successful register/login/completeRecovery/changePassword,
cache the everyday private key plus the active credential/recovery
capsule events in IndexedDB (persistSession() in cryptoWorker.ts).
connectedCallback() now calls a new "restoreSession" worker action
right after "configure", before rendering the welcome screen; if a
valid cache exists, the widget lands straight on the dashboard and
fires "bitlogin-login" exactly as a fresh login would, with no
password prompt. Logout clears the cache.

This trades "nothing persists locally" for "the everyday private key
sits decrypted in this browser's IndexedDB until logout" -- the same
tradeoff every NIP-07 extension and crypto wallet already makes for
the same reason, and scoped by IndexedDB's own per-origin isolation
(one site's cached session is never visible to another, same boundary
window.nostr already relies on). The recovery phrase and login
name/password are never cached.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-22 07:47:07 -04:00
thePR0M3TH3ANandClaude Sonnet 5 92a87ff13e CHANGELOG: document the NIP-04 addition
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 23:29:10 -04:00