diff --git a/README.md b/README.md index 12dfe11..2eb4a34 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ password that unlocks a portable Nostr identity, plus an independent 12-word recovery phrase. Encrypted account material lives on public Nostr relays — no account server, no password-reset email, no database. -This repo implements the protocol from `docs/spec.md` (v0.4) as a modular +This repo implements the protocol from `docs/spec.md` (v0.6) as a modular system any static site can embed for login and Nostr signing. ## Layout @@ -312,7 +312,7 @@ point of vendoring is that this is a reviewed step, not an automatic one. This build covers the protocol's Phase 0 (cryptographic core + test vectors) and the load-bearing parts of Phase 1 (account MVP), verified two ways: -- **Protocol-level tests** (`npm test`, 79 tests): Argon2id/ScalarExpand/JCS/ +- **Protocol-level tests** (`npm test`, 120 tests): Argon2id/ScalarExpand/JCS/ padding/timestamp/NIP-44 unit tests with the spec's exact byte layouts (§11), and full end-to-end scenarios — create → clean-device login, phrase recovery → new credentials → clean-device login, password change with diff --git a/docs/README.md b/docs/README.md index 141bb31..4d0e21b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,8 +36,10 @@ identity and recovery model. The Connection Vault stores portable application credentials outside the core identity capsules as independently encrypted records. Its protocol layer (records, key hierarchy, relay sync, migration, tiers) is **implemented in -`@bitlogin/core`'s `src/vault/`**; the widget authorization layer (consent -UI, grants, Connected apps) is still to come. +`@bitlogin/core`'s `src/vault/`**; the widget authorization layer shipped 2026-07-30 in REVEAL mode +(`requestNwcConnection`, `offerNwcConnection`, and a Wallet-connections +screen). Durable per-origin grants and a Connected-apps page are still to +come; see `connection-vault.md` §19 for the open limitations. - [`connection-vault.md`](./connection-vault.md) — overall vault architecture, key hierarchy, relay storage, authorization, recovery, and deletion; §18 diff --git a/docs/nwc-connections.md b/docs/nwc-connections.md index 3cd55b1..a8dae19 100644 --- a/docs/nwc-connections.md +++ b/docs/nwc-connections.md @@ -3,7 +3,11 @@ **Status:** Experimental design **Document version:** 0.1 **Date:** July 22, 2026 -**Implementation status:** Not implemented +**Implementation status:** PARTIALLY implemented. The parser/exporter and the +canonical credential shipped in `packages/core/src/vault/nwc.ts`, and the +request/offer flows shipped in the widget. NOT implemented: declared +capabilities, budget and expiry fields, `get_info` refresh, the suspend state, +broker mode, and the `wallet.*` API in §12 — those sections remain design. ## 1. Summary diff --git a/packages/core/src/account/capsuleReader.ts b/packages/core/src/account/capsuleReader.ts index 4f674b7..ff619dc 100644 --- a/packages/core/src/account/capsuleReader.ts +++ b/packages/core/src/account/capsuleReader.ts @@ -4,7 +4,7 @@ * recovery, and password-change (which must read the capsule it replaces). */ import { RelayPool } from "../nostr/pool.js"; -import { verifyNostrEvent, type NostrEvent } from "../nostr/event.js"; +import { verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js"; import { KIND_APP_DATA, D_TAG_PASSWORD_CAPSULE, D_TAG_RECOVERY_CAPSULE } from "../nostr/kinds.js"; import { decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; import { decryptRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js"; @@ -28,9 +28,28 @@ export interface CapsuleReadResult { relayDisagreement: boolean; } -function dedupeAndSort(events: NostrEvent[]): NostrEvent[] { +/** + * Keeps only events that are actually AT the address we asked for. + * + * A relay's response is a claim, not an answer: nothing stops it returning a + * validly-signed event by some other author, of some other kind, or under a + * different `d` tag. Signature verification alone accepted all of those. + * `best` was never at risk (the AEAD binds the ciphertext to the event's own + * pubkey, so a foreign event cannot decrypt) -- but `candidates.length` is + * load-bearing in the registration, rotation, and recovery collision guards, + * so ONE junk event from ONE relay was enough to block all three, for every + * login name and password the user tried, at zero cost. + */ +function dedupeAndSort( + events: NostrEvent[], + authorPubkeyHex: string, + dTag: string +): NostrEvent[] { const byId = new Map(); for (const event of events) { + if (event.pubkey !== authorPubkeyHex) continue; + if (event.kind !== KIND_APP_DATA) continue; + if (findTagValue(event, "d") !== dTag) continue; if (verifyNostrEvent(event)) byId.set(event.id, event); } return [...byId.values()].sort((a, b) => b.created_at - a.created_at); @@ -49,7 +68,7 @@ async function readAddressableCapsule( ); const allEvents = quorum.outcomes.flatMap((o) => o.events); - const sortedEvents = dedupeAndSort(allEvents); + const sortedEvents = dedupeAndSort(allEvents, authorPubkeyHex, dTag); const candidates: Array> = []; for (const event of sortedEvents) { @@ -63,8 +82,12 @@ async function readAddressableCapsule( const best = candidates.find((c) => c.payload !== null) ?? null; const latestIdPerRelay = quorum.outcomes - .filter((o) => o.responded && o.events.length > 0) - .map((o) => o.events.slice().sort((a, b) => b.created_at - a.created_at)[0]!.id); + .map((outcome) => ({ + responded: outcome.responded, + events: dedupeAndSort(outcome.events, authorPubkeyHex, dTag) + })) + .filter((outcome) => outcome.responded && outcome.events.length > 0) + .map((outcome) => outcome.events[0]!.id); const relayDisagreement = new Set(latestIdPerRelay).size > 1; return { diff --git a/packages/core/src/account/highWaterMark.ts b/packages/core/src/account/highWaterMark.ts index 1d296db..910a004 100644 --- a/packages/core/src/account/highWaterMark.ts +++ b/packages/core/src/account/highWaterMark.ts @@ -35,3 +35,25 @@ export async function raiseHighWaterMark( await store.set(key(everydayPublicKeyHex), JSON.stringify(merged)); return merged; } + +/** + * Resets the mark after a phrase recovery. + * + * The recovered account restarts at generation 0 at a BRAND NEW locator + * address, but the mark is keyed by the everyday public key -- which recovery + * deliberately preserves -- so a user who had ever rotated a password was met + * with "Refusing to log in with older, possibly-revoked credentials" on every + * subsequent login, on every device that had seen the higher generation, with + * no way to clear it (raiseHighWaterMark keeps the maximum). The recovery + * phrase is the master authority in this protocol (§SF9); a phrase-driven + * reset is exactly the authority it is supposed to carry, and leaving the + * alarm permanently lit trains users to click through the one control that + * detects a real replay. + */ +export async function resetHighWaterMark( + store: KeyValueStore, + everydayPublicKeyHex: string, + generations: HighWaterMark = { generation: 0, recoveryGeneration: -1 } +): Promise { + await store.set(key(everydayPublicKeyHex), JSON.stringify(generations)); +} diff --git a/packages/core/src/account/recover.ts b/packages/core/src/account/recover.ts index d218a35..309d723 100644 --- a/packages/core/src/account/recover.ts +++ b/packages/core/src/account/recover.ts @@ -28,6 +28,8 @@ import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js"; import { parseDmRelayListEvent, parseRelayListEvent } from "./profileEvents.js"; import { publishAndVerify, type PublishVerificationResult } from "./publish.js"; import { AccountNotFoundError, RecoveryFailedError } from "./errors.js"; +import { resetHighWaterMark } from "./highWaterMark.js"; +import type { KeyValueStore } from "../storage/interface.js"; export interface RecoverWithPhraseParams { phrase: string; @@ -160,6 +162,10 @@ export interface CompleteRecoveryParams { minAcknowledgements?: number; timeoutMs?: number; now?: number; + /** Where this device's rollback high-water mark lives. Passing it is what + * stops the recovered account from tripping the rollback alarm on every + * later login (the new capsule legitimately restarts at generation 0). */ + store?: KeyValueStore; } export interface CompleteRecoveryResult { @@ -285,5 +291,16 @@ export async function completeRecoveryWithNewCredentials(params: CompleteRecover throw new RecoveryFailedError("Could not publish the refreshed recovery and credential capsules to enough relays. Please retry."); } + // Only after both capsules are durable: the recovered account is now + // authoritatively at generation 0, so the mark must follow it down rather + // than keep flagging every future login as a rollback (see + // resetHighWaterMark for why the phrase is entitled to do this). + if (params.store) { + await resetHighWaterMark(params.store, recovered.everydayPublicKey, { + generation: 0, + recoveryGeneration: recovered.currentRecoveryPayload.recovery_generation + 1 + }); + } + return { normalizedLoginName, locatorPublicKey, credentialEvent, refreshedRecoveryEvent, credentialPublish, recoveryPublish }; } diff --git a/packages/core/src/nostr/relay.ts b/packages/core/src/nostr/relay.ts index 543881f..ceafa26 100644 --- a/packages/core/src/nostr/relay.ts +++ b/packages/core/src/nostr/relay.ts @@ -184,16 +184,27 @@ export class RelayConnection { content: JSON.stringify(filter) + Math.random() }).slice(0, 16); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const events: NostrEvent[] = []; - const finish = () => { + const settle = (fail?: Error) => { clearTimeout(timer); this.subs.delete(subId); this.send(["CLOSE", subId]); - resolve(events); + if (fail) reject(fail); + else resolve(events); }; - const timer = setTimeout(finish, timeoutMs); - this.subs.set(subId, { events, onEose: finish }); + // A relay that opened a socket and then said NOTHING for the whole + // window has not answered, and must not be counted toward quorum: + // quorumMet was measuring reachability, not participation, which made + // every "not enough relays answered" guard -- login, recovery, + // rotation, and the registration collision check -- near-vacuous + // against a set of connectable-but-silent relays. Only EOSE is an + // answer; the timeout is a failure. + const timer = setTimeout( + () => settle(new Error("relay did not answer the subscription before the timeout")), + timeoutMs + ); + this.subs.set(subId, { events, onEose: () => settle() }); this.send(["REQ", subId, filter]); }); } diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 6e94037..66c23f6 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -88,9 +88,7 @@ const signed = await window.nostr.signEvent({ const cipher = await window.nostr.nip44.encrypt(peerPubkeyHex, 'hi there'); const plain = await window.nostr.nip44.decrypt(peerPubkeyHex, cipher);
- Private keys never leave a dedicated Web Worker. The main thread — your page's JS — only ever - sees public keys, signed events, and ciphertext. window.nostr calls are proxied into the worker - and back. + Private keys live in a dedicated Web Worker. The signing APIs return only public keys, signed events, and ciphertext — the main thread never receives a private key through them. Be precise about the limit, though: an embedded widget runs same-origin with the page hosting it, so a malicious or XSS-compromised host can reach the worker directly (including exportIdentity, which backs the widget's own "reveal private key" button) and can visually overlay the consent UI. Treat the host page as trusted and embed BitLogin only in sites you control.
  • diff --git a/packages/demo/public/index.html b/packages/demo/public/index.html index 8e7c4f2..7f55b2e 100644 --- a/packages/demo/public/index.html +++ b/packages/demo/public/index.html @@ -104,7 +104,7 @@

    Recovery phrase

    -

    A 12-word phrase derives a completely independent recovery identity. A cracked password can never sign, replace, or delete the recovery capsule — it has no authority over that path at all.

    +

    A 12-word phrase derives a completely independent recovery identity. A cracked password cannot sign, replace, or delete the recovery capsule. Recovery availability still depends on relays retaining your capsule and on your own recovery export — quorum and retention are best-effort, not guarantees.

    One everyday identity

    @@ -145,7 +145,7 @@

    No single point of failure

    -

    Encrypted capsules are published to multiple independent relays with quorum reads and readback verification, so losing any one of them doesn't lock anyone out.

    +

    Encrypted capsules are published to multiple independent relays with quorum reads and readback verification, so losing any one of them doesn't lock anyone out. An account still lives only as long as some relay retains its capsule — keep the recovery export.

    Generated by default, never weak

    @@ -165,7 +165,7 @@

    Wallets that follow you

    -

    The Connection Vault stores wallet connections (NWC) as independently encrypted records on your account. Connect a wallet to an app once; on a new device it's one tap, not another paste. Apps ask with requestNwcConnection(), you approve, you revoke — and the budget your wallet enforces stays the real limit.

    +

    The Connection Vault stores wallet connections (NWC) as independently encrypted records on your account. Connect a wallet to an app once; on a new device it's one tap, not another paste. Apps ask with requestNwcConnection(), you approve, and you remove the stored copy any time (revoking spending authority happens at your wallet) — and the budget your wallet enforces stays the real limit.

    @@ -179,7 +179,7 @@
    - BitLogin is an experimental alpha protocol, built on the open Nostr network. This demo is a reference implementation, not a production account service. + BitLogin is an experimental, independently unaudited alpha protocol, built on the open Nostr network. This demo is a reference implementation, not a production account service.
    diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index 56665f2..a8dbae7 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -10,7 +10,7 @@ ## Integrate in one snippet ```html - + @@ -67,8 +67,16 @@ deployment above. (the last also fires on `window` when triggered via `window.bitlogin.releaseSigner()`), `bitlogin-connection-granted` (`detail.origin`). -- Private keys never leave a dedicated Web Worker; the main thread only ever - sees public keys, signed events, and ciphertext. +- Private keys live in a dedicated Web Worker and the signing APIs return only + public keys, signed events, and ciphertext. LIMIT: an embedded widget is + same-origin with its host page, so a malicious or XSS-compromised host can + reach the worker directly (including exportIdentity) and can overlay the + consent UI. Treat the host page as trusted; embed only in sites you control. +- Removing a wallet connection from BitLogin deletes the stored copy only. It + is NOT revocation -- spending authority is revoked at the wallet. +- Losing BOTH the password and the recovery phrase is permanent: there is no + custodial recovery, by design. An account also depends on relays retaining + its encrypted capsule; download the recovery export as an offline fallback. ## Key facts for integration @@ -92,7 +100,7 @@ deployment above. - [Live account manager demo](/account.html) — create/login/recover, sign a test event, rotate password, export identity. - [Protocol specification](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/spec.md) — - the full BitLogin v0.3 protocol (crypto construction, capsule formats, + the full BitLogin protocol (crypto construction, capsule formats, relay architecture, threat model). - [Second-factor / recovery options design notes](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/second-factor.md) and [future recovery-path options](https://github.com/PR0M3TH3AN/bitlogin/blob/main/docs/future-recovery-options.md) — @@ -103,6 +111,8 @@ deployment above. ## Status -Experimental alpha. Verified with an in-memory mock-relay test suite and -real-browser end-to-end runs, but not independently audited. See the README +Experimental alpha, NOT independently audited. Verified with an in-memory +mock-relay test suite, real-browser end-to-end runs, and an internal +adversarial audit (2026-07-31) whose findings are recorded in +docs/connection-vault.md §19 and the CHANGELOG. See the README in the source repository for the full implemented/deferred feature list. diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index 5e819c5..8e73f6c 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -333,7 +333,8 @@ async function handle(action: string, payload: unknown): Promise { recovered: session.pendingRecovery, newLoginName: p.newLoginName, newPassword: p.newPassword, - vaultRelayUrls + vaultRelayUrls, + store }); session.activeCredentialEvent = result.credentialEvent; session.activeRecoveryEvent = result.refreshedRecoveryEvent; @@ -561,10 +562,21 @@ async function handle(action: string, payload: unknown): Promise { } case "vaultSetBinding": { + // Two transitions only, and the difference is the whole feature: + // origin === null is UNBIND (the dashboard's "Revoke app access"), and + // anything else binds to THIS origin -- never a caller-named one, or a + // host could adopt a wallet the user bound to another site. + // + // The bare identifier `origin` sat here and silently resolved to the + // WORKER GLOBAL self.origin, so revoke rebound the record to the + // calling page instead of clearing it -- the exact adoption this guard + // exists to prevent, wearing the label of the button that prevents it. + // It typechecked because the DOM lib declares that global. const p = payload as { connectionId: string; origin: string | null }; const { vault, connection } = await findConnection(p.connectionId); + const nextOrigin = p.origin === null ? null : selfOrigin(); const { record, event } = await vault.updateConnection(connection, { - application_binding: { origin, app_pubkey: null } + application_binding: { origin: nextOrigin, app_pubkey: null } }); const publish = await vault.publish({ event,