Commit Graph
43 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 b8b19599e0 widget: headless signer — restore + sign without the custom element
The element was the only way to sign, so a host that renders its own UI, a
background job, or a headless test couldn't sign without mounting
<bitlogin-auth>. Add @bitlogin/widget/headless: createHeadlessSigner() → a
DOM-free wrapper over the crypto worker exposing restore, getPublicKey,
signEvent, nip44Encrypt/Decrypt, isUnlocked, logout, destroy.

It restores the cached session and signs with no element and no window.nostr.
It deliberately cannot interactively log in (that needs the password UI):
restore() returns null when nothing is cached, at which point the host mounts
<bitlogin-auth> once; after that the headless signer works across reloads.

New bitlogin-headless.js build entry + @bitlogin/widget/headless export
(also re-exported from @bitlogin/widget). Covered by headless.test.ts with a
scripted worker; README gains "Signing without the widget (headless)".

Addresses the "headless session-restore API" 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
2026-08-11 22:54:14 -04:00
thePR0M3TH3ANandClaude Opus 4.8 5ae41ed9db widget: ship generated type declarations + typed element/events
Integrators had to read the demo to learn the API: the package built via
vite with no .d.ts and no types field, so @bitlogin/widget and
@bitlogin/widget/session resolved to `any`.

Emit declarations from source (tsconfig.types.json -> dist-types,
build:types wired into build) and point the package types/exports
conditions at them, for both the root and the /session entry. Generated,
so they can't drift from the implementation.

Add the type surface integrators actually want:
- HTMLElementTagNameMap["bitlogin-auth"], so querySelector/createElement
  return the typed element.
- Typed addEventListener/removeEventListener overloads (declaration-merged
  interface) for the bitlogin-* events.
- Exported event-detail types and BitLoginEventMap (src/events.ts), matching
  element.ts's dispatchEvent shapes.

Verified with a consumer conformance typecheck (typed detail with no cast,
typed element methods, readCachedSession return type). README gains a
"TypeScript" section.

Addresses the ".d.ts / documented API surface" 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
2026-08-11 19:37:32 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1b39e0f60c widget: expose ::part() hooks for structural theming
The widget renders in a shadow root, so hosts could recolor it via the
:host custom properties but couldn't restyle structure (padding, borders,
box-shadow, button shape) -- which is why an embed can look pasted in.

Stamp a stable set of shadow parts once per render in exposeParts():
card; button (+button-primary/button-secondary); link; input; checkbox;
heading; text; notice (+notice-warn/-info/-error). Mapped by existing
class in one place rather than inline on ~60 rendered elements, so new
buttons/notices stay exposed automatically instead of drifting.

Documented as a contract in the README "Theming" section, alongside the
existing custom-property theming (still the first tool for color/radius/
font; parts are the escape hatch for structure). Verified in-browser that
bitlogin-auth::part(...) matches from light-DOM CSS through the shadow
boundary.

Addresses the "CSS parts" 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
2026-08-11 19:31:40 -04:00
thePR0M3TH3ANandClaude Opus 4.8 86d7ee0114 widget: make an unmounted-widget teardown loud, not a silent hang
Removing <bitlogin-auth> from the DOM terminates its crypto worker. A host
that re-renders surrounding markup (framework diff, htmx swap, innerHTML
reset) can do this incidentally, after which signing calls fail -- and the
failure was hard to diagnose.

Two guardrails:

- disconnectedCallback logs a console.error when the element is removed
  while a session (BitLogin account or NIP-07/46) is active, naming the
  unmount as the cause and pointing at the README "Integration gotcha".
- WorkerClient.terminate(reason) records the reason; both in-flight calls
  (via failAll) and any later call (via the dead-flag path) reject with it,
  so an unmounted widget produces one legible error message instead of a
  bare or mismatched one. The dead flag already prevented the infinite
  hang; this makes the cause visible and unifies the wording.

Adds worker/workerClient.test.ts (fake Worker) covering both rejection
paths. Updates the README gotcha paragraphs from "hangs forever with no
error" to the new loud-rejection behavior.

Addresses the "unmount guardrail" 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
2026-08-11 19:25:59 -04:00
thePR0M3TH3ANandClaude Opus 4.8 c9791b9840 widget: lightweight, widget-free session reader
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
2026-08-11 19:22:06 -04:00
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 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 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 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 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 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 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 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 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 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
thePR0M3TH3AN e9a5a41ff7 fix(widget): encrypt persistent session cache 2026-07-25 23:28:41 -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 283840261f Add NIP-04 support for drop-in NIP-07 parity
signEvent() already handles any event kind and NIP-44 was already
implemented, but window.nostr.nip04 (and the equivalent element-scoped
nip04Encrypt/nip04Decrypt) didn't exist anywhere -- every real NIP-07
extension exposes it, and sites with legacy DM code paths still expect
it. A site whose own code called window.nostr.nip04.encrypt(...) while
BitLogin was the active signer got a bare "Cannot read properties of
undefined" instead of a working call.

- packages/core/src/crypto/nip04.ts: AES-256-CBC + secp256k1 ECDH
  shared secret (raw X coordinate, no HKDF -- this is legacy NIP-04,
  not NIP-44), "<ciphertext>?iv=<iv>" payload shape, cross-checked
  against the NIP-04 spec's algorithm description
- NostrSigner.nip04Encrypt/nip04Decrypt, mirroring the existing
  nip44Encrypt/nip44Decrypt methods
- crypto worker: new nip04Encrypt/nip04Decrypt actions and protocol
  payload types
- <bitlogin-auth>.nip04Encrypt/nip04Decrypt (element-scoped, like the
  existing nip44 methods) and window.nostr.nip04.encrypt/decrypt

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 23:28:51 -04:00
thePR0M3TH3ANandClaude Sonnet 5 e8712f9aa9 Fix widget styles being silently dropped under strict style-src CSP
element.ts re-injected the widget's CSS as a fresh inline <style> tag
on every render() call. A host with a strict style-src (e.g.
`style-src 'self'`, no 'unsafe-inline') silently drops an inline
<style>'s rules entirely -- every element inside the shadow root fell
back to unstyled browser defaults. The visible symptom: the brand SVG
rendered at its raw intrinsic ~590x119px instead of the intended
20px-tall lockup, since its `display: block; height: 20px` rule never
took effect.

Build the stylesheet once as a CSSStyleSheet and assign it via
adoptedStyleSheets instead -- not subject to style-src the way a
parsed inline <style> element is, since it's never parsed as untrusted
markup by the HTML parser. render() no longer touches styling at all.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
2026-07-21 22:09:30 -04:00
thePR0M3TH3AN a4e9b1d2ae Fix .link buttons collapsing onto one line at wide --bl-max-width
button.link overrode width back to auto (from the general button
rule's width: 100%) but never set display: block, so it stayed at the
<button> default of inline-block. At narrow widths (the default
380px card) two adjacent .link buttons happened to wrap onto separate
lines simply because they didn't fit side by side, masking the bug.
At --bl-max-width: none in a wide host container (found integrating
BitLogin into BitUnlock's /connect page), "Import an existing Nostr
key" and "Forgot password? Recover with phrase" rendered running into
each other on the same line.
2026-07-21 19:17:32 -04:00
thePR0M3TH3AN ed8d8eb1be Stamp genuine successes with the brand's brass ring + checkmark
Signing in, creating an account, recovering one, and rotating a
password all used to just silently swap to the dashboard screen --
nothing marked the moment as a success versus any other navigation.
These are exactly the security-relevant "you're safely in/done"
moments, so mark them with the same brass ring + checkmark mark used
in the logo: a brief overlay stamps the destination screen with the
mark and a short label ("Signed in", "Account created", "Account
recovered", "Password updated"), then fades on its own.

Deliberately narrow: this only fires on those four real completions,
not on every validation step or plain navigation (e.g. the "Back to
dashboard" button), so it stays a rare, meaningful beat instead of
becoming visual noise. It's also inert by design -- pointer-events:
none means it can never block interacting with the screen underneath,
a JS timer (not just the CSS animation) guarantees it's removed even
if the animation can't run, and prefers-reduced-motion drops the
motion but keeps the same brief hold-then-clear timing.
2026-07-21 18:44:58 -04:00
thePR0M3TH3AN f725e2cd83 Drop the "B" monogram, use one true logo lockup everywhere
The monogram read as a Bitcoin symbol, which BitLogin has nothing to
do with -- revert the icon/mark back to the brass ring + checkmark
(now brass/gold to match the ring, not white) with no letterform.

Also stop composing the icon and wordmark as two independently-sized
elements in each place they appear (a --mark height here, a
--wordmark height there) -- that let their relative size and spacing
drift apart between the site nav and the widget's header. Bake both
into a single SVG (assets/lockup.svg) with the layout fixed once, and
use that same file's markup everywhere a caller only ever sets one
overall height. Site nav and the widget's welcome screen now render
from the exact same source.

Also fills in the hero credential card's placeholder passphrase
("a few good words") with a realistic 6-word example matching what
the generator actually produces, and centers the last two cards of
the 6-card "Why this is different" grid under the middle two columns
instead of left-aligning them under columns 1-2.
2026-07-21 18:35:38 -04:00
thePR0M3TH3AN 2b272eb093 Give BitLogin a real vector logo, consistent across the site and widget
Replace the placeholder gradient-circle favicon and the widget's plain
"BitLogin" text heading with an actual brand: a monogram seal (a "B"
drawn from Fraunces, the same serif already used for headings, set in
a violet seal with a brass ring) plus a vector wordmark traced from
the live Fraunces glyphs.

Both are baked as static SVG path data (via fontkit) rather than
depending on the webfont at render time, so the widget can show the
exact same wordmark the site uses without loading any font of its
own -- solving the "vector logo instead of a typeface" problem for a
component meant to drop into arbitrary host pages.

- assets/icon.svg: updated favicon/app-icon with the new monogram
- assets/mark.svg (new): transparent-background version for inline
  use in a host site's own "Sign in with BitLogin" button
- assets/wordmark.svg (new): text-only logotype, fill=currentColor
- assets/lockup.svg (new): mark + wordmark combined, for anyone
  wanting a single-file logo
- All three site pages' nav now use the same inlined mark + wordmark
  instead of the ad-hoc checkmark-circle SVG that was duplicated
  across them
- <bitlogin-auth>'s welcome screen shows the same lockup instead of
  plain text, themed via --bl-fg like everything else in the widget
- docs.html gains a short "showing it as one option among several"
  step pointing integrators at mark.svg for a compact button icon
2026-07-21 17:54:21 -04:00
thePR0M3TH3AN 8c01a121df Never overwrite an existing profile or relay list on import
After every successful registration -- including nsec imports -- the
widget unconditionally published a default kind 0 profile ({name:
loginName}) plus kind 10002/10050 relay lists. All three are
NIP-01/NIP-65/NIP-17 replaceable events, so importing an nsec that
already had a real profile (display name, bio, avatar, NIP-05) and/or
relay preferences published elsewhere silently erased all of it,
replacing it with just the chosen BitLogin login name and the vault
relays.

publishInitialProfile() (moved into @bitlogin/core so it can be
covered by the existing MockRelay-based test harness) now queries for
an existing event of each kind before publishing, and only fills in
whichever one is genuinely missing. A fresh registration has nothing
to find, so behavior there is unchanged; an import now leaves
whatever the identity already had untouched.

Bumps docs/spec.md to v0.5 (§15.8, §28.1) and adds two new core tests
covering the fresh-identity and existing-profile-preserved cases.
2026-07-21 16:39:00 -04:00
thePR0M3TH3ANandClaude Sonnet 5 1dbb4fca51 Fail closed (not open-and-crash) when window.nostr can't be claimed
Some NIP-07 extensions install window.nostr as a non-configurable,
non-writable property to stop another script from overwriting it.
claimSigner() did a plain, unguarded `window.nostr = ...` assignment, so on
a page where such an extension is active, this threw
"Cannot assign to read only property 'nostr' of object '#<Window>'" --
and because it ran synchronously before dispatching bitlogin-login or
moving to the dashboard screen, it froze account creation/import at the
final "verify your recovery phrase" step, with that raw TypeError as the
only feedback shown. Reported from a real BitRoad integration session.

claimSigner() and releaseSigner() now catch this and return false instead
of throwing; connectedCallback's initial claim attempt is guarded the same
way. Every caller (registration, login, recovery, password change) now
completes sign-in regardless -- a host page using this element's own
methods directly (getPublicKey/signEvent/nip44Encrypt/nip44Decrypt) was
never actually affected by a failed window.nostr takeover, only a page that
reads window.nostr itself. The dashboard now shows a plain-language warning
when this happens, reusing the existing rollback/relay-disagreement warning
banner mechanism.

Also fixed while auditing this area: the "Copy" button on the generated-
password screen silently did nothing if the clipboard write failed (e.g.
Firefox's stricter permissions, an unfocused document) -- it now flashes
"Copied" or "Copy failed -- select the text manually" either way.

Verified live in Chrome: reproduced the exact crash by locking down
window.nostr the way a real extension does, confirmed account
creation/import now completes end-to-end (in both the standalone demo and
embedded in BitRoad) with the new warning shown instead of a stuck flow.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-21 15:44:52 -04:00
thePR0M3TH3ANandClaude Sonnet 5 1eff23aca8 Expose widget theming as CSS custom properties, and a max-width override
<bitlogin-auth> renders in a shadow root, but every color/radius/font value
already flowed through a CSS custom property on :host -- these inherit
through the shadow boundary, so a host page can already override the
widget's look from ordinary light-DOM CSS. This adds the two properties
that weren't yet exposed:

- --bl-accent-fg (default: white): text color on top of --bl-accent. Was
  hardcoded on button.primary, so a host with a light accent color and its
  own dark-on-accent convention had no way to match it.
- --bl-max-width (default: 380px): the widget stays a fixed-width card by
  default (unchanged for existing embedders), but a host page that wants it
  to fill a wider container can now override this instead of being stuck
  with the hardcoded cap.

Also routes the hardcoded font-family through --bl-font-family so that's
themeable too. Documented in the README with a worked example (BitRoad,
which maps every one of these onto its own design tokens to render the
widget in its own colors at full width).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-21 15:05:55 -04:00
thePR0M3TH3ANandClaude Sonnet 5 1dc5e6802c Fail closed on credential-generation rollback; fix NIP-44 extended-length payloads
Found during a real integration pass (embedding this widget in BitRoad, a
separate Nostr commerce app):

- Password rotation's old-locator tombstone (§18.1) is only advisory -- a
  relay that never processes it can keep serving the pre-rotation capsule
  indefinitely. loginWithPassword/changePassword previously accepted that
  stale capsule with only a non-blocking warning. A device that has already
  seen the newer generation now refuses it (RollbackDetectedError) unless the
  caller explicitly passes acknowledgeRollback: true for the narrower
  relay-lag case. The widget surfaces this as a blocking confirmation screen
  before claiming the signer, not a banner shown after the fact.
- NIP-44 capped plaintext at 65535 bytes; added the extended-length prefix
  for payloads up to 2^32-1 bytes, verified byte-for-byte against the
  nostr-tools reference implementation (conversation keys, padding at every
  bucket boundary, round trips in both directions).
- <bitlogin-auth> now exposes nip44Encrypt/nip44Decrypt directly on the
  element instance, alongside the existing getPublicKey/signEvent, so a host
  page can reach every capability without racing other providers for the
  single window.nostr slot.

docs/spec.md bumped to v0.4 with the full writeup (§16.2, §29.7). 75/75
tests passing (14 new NIP-44 tests, 2 new rollback e2e tests).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-21 14:36:35 -04:00
Claude a24719184e Add signer switching, manual-password parity, phrase-quiz split, and offline recovery import
Signer interop:
- <bitlogin-auth> gains claimSigner()/releaseSigner() instance methods, and
  window.bitlogin gains a matching releaseSigner(). Before this, once the
  widget claimed window.nostr it held it forever (even after logout), so a
  site offering multiple signing methods (an extension, a NIP-46 bunker,
  BitLogin) had no way to let a user switch away from BitLogin without a
  page reload. claimSigner() is now also called automatically on every
  successful sign-in/sign-up/recovery, and releaseSigner() on logout and
  DOM removal. Both fire bitlogin-signer-claimed/-released events.
  docs.html and llms.txt document the new API and a switch-signer example.

Manual password parity + fixes:
- The opt-in custom-password path (added earlier per spec §9.3) was only
  available at initial account creation; it's now offered on password
  rotation and phrase recovery too, via a shared renderManualPasswordFields()/
  readManualPassword() pair.
- Added a required confirm-password field with live match feedback,
  wherever a manual password can be set.
- Found and fixed a real bug while testing this: a validation failure
  (mismatched confirm, weak password) on any manual-password screen fully
  re-rendered the form, silently wiping the typed password and
  un-checking "I saved it" -- forcing a full retype. Fixed via draft
  fields that repopulate the inputs' value attribute across the
  failure re-render, and by reading the checkbox before validation
  instead of after.

Recovery-phrase display split (§anti-cheat):
- The 12-word phrase and its 3-word verification quiz were shown on the
  same screen, defeating the point of the quiz. Split into a
  display-only "confirm-phrase" screen and a separate "verify-phrase"
  screen where the phrase is hidden while the quiz is answered.

Offline recovery-export import (§19.5):
- recoverWithPhrase() now accepts optional offlineRecoveryCapsuleEvents,
  merged with whatever the live relay read returns; recovery still
  requires the phrase either way, since the export file never contains
  it or a phrase-derived key. Covered by a new core e2e test that
  recovers successfully against genuinely unreachable relay URLs using
  only the offline candidate.
- The recover-phrase screen gets an optional file input for a previously
  downloaded recovery export, parsed and validated client-side before
  being passed through to the worker.

Landing page copy updated to reflect that manual passwords are allowed
(with a real entropy floor and common-password check), not disallowed.

60/60 tests pass; core and widget typecheck clean. Verified live in
Chromium via Playwright against a 3-relay in-memory mock quorum: account
creation (generated and manual, including the mismatch/retry-preserves-
draft path), phrase display/quiz split, signer claim/release/reclaim
across logout and re-login, password rotation and phrase recovery with
manual passwords (including logging back in with the result), and the
offline recovery-export file path. Zero page or console errors across
every run.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
2026-07-21 16:41:35 +00:00
Claude 0801371566 Fix live-deployment bugs, add manual passwords, and improve integration support
Fixes reported from the live Vercel deployment:
- vercel.json: remove cleanUrls, which was breaking every internal .html
  link (ERR_FAILED) since links already use explicit extensions
- Recovery export was silently broken after login or password rotation:
  the crypto worker's session state only picked up the recovery capsule
  event on register/recover, never on login/changePassword, even though
  the decrypted payload always carries it. Surfaced recoveryCapsuleEvent
  (and recoveryPublicKey for changePassword) through core's LoginResult /
  ChangePasswordResult and wired them into the worker session; covered by
  a new account.e2e.test.ts case
- New accounts never got a Nostr profile: publishProfileAndRelayLists is
  now called with name: loginName after registration, so the npub's
  profile defaults to the chosen login name
- Password manager never recognized the fields (shadow DOM + JS-driven
  submission defeat the passive save-prompt heuristics, and the
  generated-password screen never put the password in a real input at
  all). Fixed with an explicit navigator.credentials.store() call after
  every register/login/recover/changePassword; documented as
  Chromium-only in docs.html

New: optional manual/custom password (packages/core/src/account/manualPassword.ts),
opt-in per spec §9.3's "future release may permit manual passwords behind
an advanced setting" allowance. Enforces a length floor, a common-password
blocklist, a login-name-substring check, a low-variety-pattern check, and
an entropy floor; the widget shows live non-blocking feedback as the user
types. The generated high-entropy passphrase remains the default,
recommended option.

Integration/interop support:
- window.nostr provider now carries a readonly _bitlogin marker
- window.bitlogin ({ version, isActiveSigner() }) is installed
  unconditionally as soon as the script loads, independent of login
  state or window.nostr's current occupant. Found and fixed a real bug
  along the way: an unconditional assignment is required because browsers
  auto-expose any element with a matching id/name as a same-named global,
  so <bitlogin-auth id="bitlogin"> silently shadowed window.bitlogin
  under a naive `if (!window.bitlogin)` guard. Renamed the demo's own
  element id to avoid the same trap and documented the gotcha in docs.html
- docs.html: new section on supporting multiple signing methods
  (extensions, NIP-46, BitLogin) alongside each other
- Added packages/demo/public/llms.txt for AI-assisted integration

Deployment: GitHub Pages requires a paid plan for private repos, so its
workflow and the .github directory are removed; README documents
Vercel-only deployment and the direct-from-CDN integration path.

Docs: added docs/future-recovery-options.md, laying out passkey-based
2FA/recovery options (pure 2FA, full independent recovery, and a
constrained middle ground) with pros/cons for future consideration —
not a development plan, nothing here is decided.

59/59 tests pass; core and widget typecheck clean; all fixes verified
live in Chromium via Playwright against an in-memory mock relay.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
2026-07-21 15:35:10 +00:00
Claude a76c8484f7 Add nsec import: use an existing Nostr identity as the everyday key (§SF10)
core:
- registerAccount gains an optional everydayPrivateKey (validated scalar,
  used instead of generating); result carries an `imported` flag
- importAccount() + decodeEverydayPrivateKey() convenience helpers accept
  nsec (bech32) or 64-char hex, wiring in the existing decodeNsec primitive
- e2e tests: imported nsec round-trips to the same npub through both
  clean-device login and phrase recovery; malformed keys rejected

widget:
- worker gains a `previewImportKey` action (validate + preview npub without
  publishing) and passes importKey through `register`
- <bitlogin-auth> gets an "Import an existing Nostr key" screen: paste
  nsec/hex, check-key preview shows the resulting npub for confirmation,
  then the normal name -> password -> phrase flow, with the pasted key
  dropped from the main thread after registration (§11.10) and a
  risky-paste warning shown up front

docs: mark §SF10 implemented; note backup codes are redundant with the
phrase and unlikely to ship; README implemented-list updated.

Verified end-to-end in real Chromium (preview npub, import, clean-device
login all reproduce the imported identity). 51/51 tests pass.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
2026-07-21 10:59:01 +00:00
Claude 5ee63ae6b0 Implement BitLogin as a modular protocol library, embeddable widget, and demo site
Builds out the v0.3 spec as an npm workspace monorepo any static site can
consume for login and Nostr signing:

- packages/core: framework-free protocol implementation — Argon2id/HKDF/
  ScalarExpand/AES-256-GCM/JCS crypto primitives, NIP-01 event handling and a
  quorum-aware relay pool, credential/recovery capsule construction and
  validation, and the full account lifecycle (registration, any-device
  login with rollback/relay-disagreement detection, phrase recovery with
  recovery-capsule refresh, password rotation with mandatory tombstone +
  NIP-09 deletion, replica repair, recovery export, NIP-19 encoding, a
  NIP-44 v2 implementation, and a NIP-07-shaped signer). 49 tests cover the
  spec's Phase 0 byte-level vectors plus full create/login/recover/rotate
  end-to-end scenarios against an in-memory mock relay.

- packages/widget: a <bitlogin-auth> custom element with a dedicated crypto
  Web Worker (private keys never touch the main thread) and a window.nostr
  NIP-07-compatible provider, so existing Nostr web apps work against it
  with zero code changes. Built with Vite into two relocatable static files.

- packages/demo: a plain static site (no build step of its own) consuming
  the widget — a branding/landing page, an integration guide, and an
  account-manager page covering create/login/recover, password rotation,
  and identity export.

All flows were verified twice: protocol-level tests against a mock relay,
and the actual built widget driven end-to-end in real Chromium (registration,
phrase confirmation, event signing, clean-device login, window.nostr, and
phrase recovery all reproduce the identical everyday pubkey).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
2026-07-21 03:44:10 +00:00