connection-vault.md 5-13 as code, with the implementation-time decisions
recorded in a new section 18 and vault-ux.md holding the agreed UX
walkthroughs as the authorization layer's acceptance criteria.
Capsules gain paired optional fields connection_vault_root and
vault_sudo_key (both or neither, validated); registration mints them,
login/change-password/recovery carry them forward, and
enableConnectionVault is the phrase-gated migration for existing
accounts -- recovery capsule written first, fail closed, idempotent by
reusing roots found in either capsule so a partial failure can never
mint twice and strand records.
src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand
constants pinned by test vectors that must only ever be reverted, never
updated), per-connection encrypted records signed by a derived vault
identity with the capsule JCS+padding+AES-GCM pipeline and shared
buckets so record types are indistinguishable by size, the NWC profile
with lossless URI parse/export, relay sync reusing the publish-and-
verify quorum bar, and per-record high-water marks so a stale replica
is withheld and reported rather than silently resurrecting a revoked
credential.
The two-tier model makes sudo mode cryptographically real: personal-
tier record keys require vault_sudo_key as HKDF input, VaultSession
holds it only inside an enableSudo/endSudo window (wiping on the way in
and out), and outside a window personal records are invisible -- the
same trial-decrypt returns null, not an error. The app-facing API is
structurally blind to the tier by construction.
118 tests passing, including four end-to-end scenarios against mock
relays: clean-device restore, password-change carry, legacy-account
migration (wrong phrase refused), and rollback detection.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
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
relay.nostr.band (shared by both lists) is currently unreachable, and
publishAndVerify() enforces a fixed minAcks/minReadbacks=2 floor
regardless of relay count -- so a 3-relay vault list had zero margin
the moment one relay went down, surfacing as "Registration did not
reach the required relay acknowledgement and readback quorum."
Add nostr.wine and relay.snort.social to the vault list, and
nostr-pub.wellorder.net to discovery, so losing any single relay no
longer risks failing registration.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
registerAccount() never checked whether a credential capsule already
existed at the locator address it was about to publish to. That
address is a NIP-33 replaceable event fully determined by login name +
password, so registering with a login name + password combination that
was already registered -- by mistake (double-clicking "create account",
confusing it with "sign in"), or by coincidence with someone else's
account under a shared login name -- silently replaced the existing
account's identity binding. The old everyday identity became
unreachable through that login unless the original recovery phrase was
still on hand, with nothing warning the user beforehand.
changePassword() had the identical gap on the new locator: rotating to
a password that happened to already be registered under the same login
name would silently destroy that other account.
Both flows now query for an existing capsule at the target locator
before publishing, and refuse with AccountAlreadyExistsError if one is
found (or a retry-oriented error if no relay could be reached to check).
Two new end-to-end tests reproduce both scenarios and confirm the
pre-existing account survives untouched.
Bumps docs/spec.md to v0.6 (§15.6, §18.1).
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.
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]>
Preparing to open the project up beyond this repo's own use:
- LICENSE: MIT, and a "license": "MIT" field on the root and all three
workspace package.json files.
- CONTRIBUTING.md: dev workflow, the mock-relay testing setup (most
sandboxes/CI have no route to public Nostr relays, and registration
needs a 2-of-N ack quorum so a single mock relay always fails), the
spec-first process for anything touching wire format or crypto
(docs/spec.md is canonical; speculative ideas go in their own doc, not
the spec), and stricter expectations for crypto/key-handling changes.
- SECURITY.md: private vulnerability disclosure via GitHub's advisory
flow rather than public issues, given this handles private keys and
password-derived secrets client-side; states current audit status
plainly (none yet).
- README: short "Contributing" section pointing at all three.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
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
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
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