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]>
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]>
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]>
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]>
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]>
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]>
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]>
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
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
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
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
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.
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
The old landing page led with generic Nostr-signer framing and a
templated violet/pink gradient look. Reframe the pitch around the
thing that's actually differentiated: one login name and password
that works on every BitLogin site, explicitly positioned against
NIP-07 extensions and NIP-46 bunkers, with Nostr terminology kept
light for the non-technical end users who'll actually be typing a
password rather than integrating a widget.
Visual redesign: warm parchment/brass accent alongside the existing
violet, Fraunces display serif paired with the existing system-ui
body font, and a new "one credential, many doors" hero illustration
with a staggered stamp-in animation (prefers-reduced-motion aware)
replacing the generic feature-grid-only layout. Applied consistently
across index.html, docs.html, and account.html.
docs.html was missing everything from this session's work:
- The disconnectedCallback/crypto-worker-teardown gotcha (found via the
BitRoad integration) -- now a callout right where the element is first
mounted, since that's the step where it actually bites.
- Talking to the element directly (getPublicKey/signEvent/nip44Encrypt/
nip44Decrypt as instance methods) as an alternative to window.nostr,
with BitRoad's adapter as the worked example.
- claimSigner() failing gracefully (returns false, doesn't throw) when a
NIP-07 extension has locked window.nostr down -- was previously
undocumented entirely; this is the exact bug just fixed.
- Theming via CSS custom properties, with the full property list and
BitRoad's mapping as a real example.
Also fixed two stale "(v0.3)" references to docs/spec.md in README.md and
CONTRIBUTING.md -- the spec was bumped to v0.4 earlier this session for the
rollback-detection changes but these two mentions were missed.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Every page's footer now links to the repo (github.com/PR0M3TH3AN/bitlogin)
and its LICENSE file, so visitors can see this is open source without
digging through docs.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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
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
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