mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 16:41:25 +00:00
dependabot/github_actions/actions/setup-node-7.1.0
28
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5aa7d9ca15 |
Relay lists: drop a relay that cannot accept writes, widen for margin
Measured every candidate on 2026-08-05 against its own NIP-11 document and over a real socket, three runs each. nostr.wine advertises payment_required AND restricted_writes, so it can never accept a free user's capsule -- yet it held one of five slots in BUILTIN_VAULT_RELAYS, the list capsules are PUBLISHED to. relay.damus.io and relay.nostr.band were unreachable 3/3 from the machine under test. That left two usable relays against publishAndVerify's floor of two acknowledgements and two readbacks: zero margin, so a single hiccup fails registration or a password rotation outright. Vault list is now eight relays, each verified to accept anonymous writes (no payment_required, no auth_required, no restricted_writes). damus stays for reach -- its failure here may be datacenter-IP filtering rather than downtime -- but quorum no longer depends on it. Discovery gains primal; relay.nostr.band stays there, where a search relay belongs, rather than in a write path. Pinned by hermetic tests: no WRITE_HOSTILE_RELAYS entry may appear in the vault list, the list must keep margin over the floor, and both lists must be duplicate-free wss URLs. Reachability is a property of the user's network and is deliberately NOT asserted; write policy is a property of the relay and is. Mutation-verified: putting nostr.wine back turns the pin red. Co-Authored-By: Claude Fable 5 <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
205b0678cc |
Pin that pre-removal passwords still sign in
Manual passwords were removed from the widget on 2026-07-31. That change
took away the CONTROLS -- you can no longer type your own password when
creating, recovering, or rotating -- but it did not change the derivation
chain and it did not add any check on the login path. Accounts created with
a hand-typed password therefore still sign in exactly as before.
That distinction is load-bearing and was previously unwritten. BitLogin
keeps no server-side record, so if sign-in ever stopped accepting a password
shape it had previously issued, those accounts would be reachable only
through their recovery phrase. Nothing in the suite said otherwise, and
securityPolicy.test.ts pins only the absence of the creation/recovery/
rotation controls.
Four tests: register-then-login with a hand-typed password, wrong password
still refused, the real migration path (sign in with the old typed password,
rotate onto a generated one, old one stops working), and derivation across
the password shapes the old UI accepted -- short, spaced, symbol-laden,
whitespace-padded, unicode, and 256 characters. No length floor, charset
rule, or entropy gate exists on the derivation path, which is exactly what
keeps those accounts reachable.
Separately confirmed by diff rather than by test: normalize.ts, argon2id.ts,
hkdf.ts and scalarExpand.ts are unchanged between
|
||
|
|
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 |
||
|
|
e131f0313b |
Take nonce and IV control off the public crypto surface
nip44Encrypt and nip04Encrypt took optional nonceOverride / ivOverride parameters. No production caller ever passed one -- but they were exported from @bitlogin/core/crypto, so "encrypt with a nonce I chose" was a discoverable feature of the package rather than an internal detail. That is worth closing because neither primitive degrades gracefully. NIP-44 is ChaCha20: a nonce reused under one conversation key recovers plaintext outright. NIP-04 is AES-CBC, where a repeated IV leaks block-equality between messages and a predictable one gives an attacker considerably more. A parameter that silently accepts a fixed nonce should not sit one autocomplete away from application code. The public functions now take no nonce or IV and always draw from the CSPRNG. The capability moved to crypto/testing.ts as __unsafeNip44EncryptWithNonce / __unsafeNip04EncryptWithIv, which is absent from both crypto/index.ts and the package.json exports map. crypto/index.ts also switches from `export *` to an explicit re-export list for these two modules, so the shared encryptWithNonce / encryptWithIv helpers stay off the surface even though their modules must export them for the test entry point to reach them. Closing this cost nothing: exactly one test used an override -- a nonce-length validation check -- and ivOverride had no callers at all. The official NIP-44 vectors do not need it. Verified from a throwaway consumer package linking the BUILT @bitlogin/core rather than from source. Deep imports of crypto/testing.js, dist/crypto/testing.js and crypto/nip44.js all fail with ERR_PACKAGE_PATH_NOT_EXPORTED; the crypto surface contains neither encryptWithNonce nor encryptWithIv; and nip44Encrypt/nip04Encrypt report arity 2/3, i.e. no nonce or IV parameter. Adds four regression tests pinning the closed surface, including that 64 calls with identical plaintext produce 64 distinct nonces and IVs. knownAnswer.test.ts stayed green throughout, which is the signal that matters here: no capsule bytes change and no existing account is affected. Typecheck, build and the full suite pass (185 tests). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u |
||
|
|
b85e1572ea |
Pin fail-closed RNG behaviour and randomUniformInt's uniformity
crypto/random.ts is the single chokepoint for every secret this codebase mints -- recovery entropy, everyday keys, vault roots, AEAD nonces, account ids, generated passphrases. It is correct today: it throws when no secure source exists rather than falling back, and rejection-samples to avoid modulo bias. Nothing in CI would have reported it if either stopped being true. That is the gap this closes. The existing suite is strong on known-answer vectors -- HKDF, ScalarExpand, Argon2id, BIP-39, Schnorr, GCM tamper detection, NIP-44 official vectors -- but every one of them is satisfied by a deterministic stream. Same input, same output. That is exactly how a predictable RNG survives a health check that only asks whether the output looks random. Adds two blocks, nine tests: - Fail-closed: no crypto object, crypto without getRandomValues, and getRandomValues present but not callable must each throw, and randomEntropy128 / generatePrivateKey / randomUniformInt must abort rather than substitute a value. Plus a check that normal operation resumes once a source is available again, so the guard cannot be satisfied by breaking everything permanently. - Uniformity: chi-square over 200k draws at modulus 94, full-range coverage at the EFF wordlist modulus of 7776, rejection of invalid bounds, and the degenerate modulus of 1. Both mutation-verified rather than assumed. Replacing the throw with a zero-filling stub turns five fail-closed tests red. Replacing rejection sampling with a bare `value % maxExclusive` gives chi-square 5614 against a threshold of 147 -- while leaving every other test in the suite green, which is the whole reason the distribution test has to exist. Tests only; no source or behaviour change. Suite 172 -> 181. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u |
||
|
|
6eb1d7d640 |
Pin the derivation chain, and stop Dependabot grouping crypto majors
A Dependabot PR failed the build by bundling @noble/hashes 1.8->2.2, @noble/curves 1.9->2.2, @scure/base 1.2->2.2, @scure/bip39 1.6->2.2, TypeScript 5->7, Vite 6->8 and Vitest 3->4 into a single change. Production was never affected -- only that preview -- and the cause is real rather than flaky: noble v2 exports only ".js"-suffixed subpaths, while this code imports the extensionless "@noble/hashes/sha2" that 1.8 provides. Failing to compile was the LUCKY outcome. The tests could not have caught the dangerous version of this. Account capsules live on public relays, and the login name plus password derive the locator, signing scalar and capsule key deterministically -- so those outputs are a compatibility contract with every account that already exists. The crypto suite only checked self-consistency (same input, same output within one run), which stays green even if HKDF, base64url framing, BIP-39 seeding or JCS ordering all change. A grouped major that compiled would have shipped and locked every existing user out, with no server-side record to migrate them from. So this adds knownAnswer.test.ts, pinning the actual outputs of HKDF-Extract, HKDF-Expand, ScalarExpand (scalar, counter and resulting public key), BIP-39 phrase and seed, base64url, and JCS ordering. The vectors are captured from the shipping implementation, and the header says plainly what a failure means: not "update the expected value", but "this upgrade is account-breaking". dependabot.yml now groups only minor and patch updates. Majors are still proposed, individually, so each can be judged on its own. Majors of the derivation chain itself (@noble/*, @scure/*, hash-wasm, canonicalize) are ignored entirely -- that is deliberate work with the vectors green, not an automated PR waiting to be merged on a quiet afternoon. 172/172 tests, typecheck clean. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
a465e1d8fa |
Implement the Connection Vault protocol layer (phases A-C)
connection-vault.md 5-13 as code, with the implementation-time decisions recorded in a new section 18 and vault-ux.md holding the agreed UX walkthroughs as the authorization layer's acceptance criteria. Capsules gain paired optional fields connection_vault_root and vault_sudo_key (both or neither, validated); registration mints them, login/change-password/recovery carry them forward, and enableConnectionVault is the phrase-gated migration for existing accounts -- recovery capsule written first, fail closed, idempotent by reusing roots found in either capsule so a partial failure can never mint twice and strand records. src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand constants pinned by test vectors that must only ever be reverted, never updated), per-connection encrypted records signed by a derived vault identity with the capsule JCS+padding+AES-GCM pipeline and shared buckets so record types are indistinguishable by size, the NWC profile with lossless URI parse/export, relay sync reusing the publish-and- verify quorum bar, and per-record high-water marks so a stale replica is withheld and reported rather than silently resurrecting a revoked credential. The two-tier model makes sudo mode cryptographically real: personal- tier record keys require vault_sudo_key as HKDF input, VaultSession holds it only inside an enableSudo/endSudo window (wiping on the way in and out), and outside a window personal records are invisible -- the same trial-decrypt returns null, not an error. The app-facing API is structurally blind to the tier by construction. 118 tests passing, including four end-to-end scenarios against mock relays: clean-device restore, password-change carry, legacy-account migration (wrong phrase refused), and rollback detection. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1 |
||
|
|
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 |
||
|
|
681f622ef8 |
Expand built-in vault/discovery relay lists for redundancy
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 |
||
|
|
01fc7c5ed3 |
Refuse to overwrite an existing account on register or password change
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). |
||
|
|
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.
|
||
|
|
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]> |
||
|
|
9408fef484 |
Add MIT license, CONTRIBUTING, and SECURITY docs for open-sourcing
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |