diff --git a/packages/core/src/account/account.e2e.test.ts b/packages/core/src/account/account.e2e.test.ts index 5af4203..7618d56 100644 --- a/packages/core/src/account/account.e2e.test.ts +++ b/packages/core/src/account/account.e2e.test.ts @@ -17,6 +17,7 @@ import { derivePasswordKeys, normalizeLoginName } from "./normalize.js"; import { buildCredentialCapsuleEvent, decryptCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; import { RelayConnection } from "../nostr/relay.js"; import { readCredentialCapsule } from "./capsuleReader.js"; +import { buildRecoveryExport, parseRecoveryExport } from "./exportImport.js"; const ARGON2_TIMEOUT = 20000; @@ -186,6 +187,48 @@ describe("BitLogin Phase 0 end-to-end scenarios (§32)", () => { ARGON2_TIMEOUT * 2 ); + it( + "recovery export file lets recovery succeed even when every configured relay is unreachable (§19.5)", + async () => { + const loginName = "offlinerecover"; + const password = generatePassphrase().secret; + const registration = await registerAccount({ loginName, password, vaultRelayUrls }); + + // Build (and round-trip through JSON, as the downloaded file would be) the recovery + // export exactly as the widget does right after registration. + const exportFile = buildRecoveryExport({ + recoveryPublicKeyHex: registration.recoveryPublicKey, + vaultRelayUrls, + recoveryCapsuleEvents: [registration.recoveryEvent], + relayListEvents: [] + }); + const parsedExportFile = parseRecoveryExport(JSON.parse(JSON.stringify(exportFile))); + + // Simulate every configured relay being unreachable (not just empty -- an actual closed + // port, so the quorum read genuinely fails rather than vacuously succeeding on zero relays). + const unreachableRelayUrls = ["ws://127.0.0.1:1"]; + + const recovered = await recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls: unreachableRelayUrls, + discoveryRelayUrls: unreachableRelayUrls, + offlineRecoveryCapsuleEvents: parsedExportFile.recovery_capsule_events + }); + expect(recovered.everydayPublicKey).toBe(registration.everydayPublicKey); + expect(recovered.accountId).toBe(registration.accountId); + + // Without the offline file, the same unreachable relays must fail outright. + await expect( + recoverWithPhrase({ + phrase: registration.recoveryPhrase, + vaultRelayUrls: unreachableRelayUrls, + discoveryRelayUrls: unreachableRelayUrls + }) + ).rejects.toThrow(AccountNotFoundError); + }, + ARGON2_TIMEOUT + ); + it( "known-password change tombstones the old locator and a stale replay of the old capsule does not win", async () => { diff --git a/packages/core/src/account/recover.ts b/packages/core/src/account/recover.ts index fee70b0..f47bc6e 100644 --- a/packages/core/src/account/recover.ts +++ b/packages/core/src/account/recover.ts @@ -14,8 +14,8 @@ import { SCHEMA_CREDENTIAL_V1, SCHEMA_RECOVERY_V1 } from "../nostr/kinds.js"; -import { readRecoveryCapsule, checkRecoveryChainAcrossCandidates } from "./capsuleReader.js"; -import { buildRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js"; +import { readRecoveryCapsule, checkRecoveryChainAcrossCandidates, type Candidate } from "./capsuleReader.js"; +import { buildRecoveryCapsuleEvent, decryptRecoveryCapsuleEvent } from "../capsules/recoveryCapsule.js"; import { buildCredentialCapsuleEvent } from "../capsules/credentialCapsule.js"; import { PROTOCOL_CAPSULE_ENCRYPTION, PROTOCOL_PASSWORD_KDF, PROTOCOL_RECOVERY_DERIVATION } from "../capsules/types.js"; import type { CredentialPayload, RecoveryPayload } from "../capsules/types.js"; @@ -28,6 +28,14 @@ export interface RecoverWithPhraseParams { vaultRelayUrls: string[]; discoveryRelayUrls: string[]; timeoutMs?: number; + /** + * Recovery-capsule events from a previously downloaded recovery export file (§19.5), used + * as a fallback alongside the live relay read. The file alone can never recover an account + * on its own — it holds only the encrypted capsule, never the phrase or a phrase-derived + * key — so this only ever supplements the phrase-derived read, e.g. when every configured + * relay is unreachable or has lost the capsule. + */ + offlineRecoveryCapsuleEvents?: NostrEvent[]; } export interface RecoveredIdentity { @@ -68,6 +76,23 @@ export async function recoverWithPhrase(params: RecoverWithPhraseParams): Promis vaultPool.closeAll(); } + if (params.offlineRecoveryCapsuleEvents?.length) { + const offlineCandidates: Array> = []; + for (const event of params.offlineRecoveryCapsuleEvents) { + if (!verifyNostrEvent(event)) continue; + try { + offlineCandidates.push({ event, payload: await decryptRecoveryCapsuleEvent(event, capsuleKey) }); + } catch (err) { + offlineCandidates.push({ event, payload: null, error: (err as Error).message }); + } + } + const merged = new Map>(); + for (const candidate of [...result.candidates, ...offlineCandidates]) merged.set(candidate.event.id, candidate); + const candidates = [...merged.values()].sort((a, b) => b.event.created_at - a.event.created_at); + const best = candidates.find((c) => c.payload !== null) ?? null; + result = { ...result, candidates, best, quorumMet: result.quorumMet || best !== null }; + } + if (!result.quorumMet) throw new AccountNotFoundError("quorum-not-met"); if (!result.best) { throw new AccountNotFoundError(result.candidates.length > 0 ? "no-valid-candidate" : "no-matching-event"); diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 6afd2e9..c941a93 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -102,6 +102,24 @@ if (window.bitlogin?.isActiveSigner()) { shadowing the API object described above. Use something like id="bitlogin-widget" instead (as this demo does). +

Letting a user switch away from BitLogin without reloading

+

By default, once <bitlogin-auth> mounts it claims window.nostr and keeps it — + even after the user logs out of BitLogin, since they might just be about to log back in with the same widget. + If your site offers a signer picker and the user switches to a different method, call + window.bitlogin.releaseSigner() to hand the slot back (it's a no-op, safe to call speculatively, + if BitLogin doesn't currently hold it):

+
// User picked a different signing method in your own UI
+if (window.bitlogin?.releaseSigner()) {
+  // window.nostr is now undefined; install the other method's provider
+}
+

If they switch back to BitLogin later, call claimSigner() on the element instance to reclaim + the slot (this also happens automatically every time a user completes sign-in, sign-up, or recovery through + the widget itself, so you only need this for a picker UI that lives outside the widget):

+
document.querySelector('bitlogin-auth').claimSigner();
+

Both claimSigner()/releaseSigner() on the element and + window.bitlogin.releaseSigner() also fire bitlogin-signer-claimed / + bitlogin-signer-released events (on the element and on window, respectively) if you'd + rather react to the change than check a return value.

  • Choose your relays

    diff --git a/packages/demo/public/index.html b/packages/demo/public/index.html index 553ce05..c615203 100644 --- a/packages/demo/public/index.html +++ b/packages/demo/public/index.html @@ -75,8 +75,8 @@

    Encrypted capsules are published to multiple Nostr relays with quorum reads and readback verification, so no single relay is a single point of failure.

    -

    Generated credentials only

    -

    A downloadable encrypted capsule can be attacked offline forever. So BitLogin generates a high-entropy passphrase for you — no "password123", ever.

    +

    Generated by default, never weak

    +

    A downloadable encrypted capsule can be attacked offline forever, so BitLogin generates a high-entropy passphrase for you by default. Prefer your own? It's allowed, but it has to clear a real entropy floor and a common-password check — no "password123", ever.

    Argon2id + AES-256-GCM

    diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index 0bcfea5..f9b9762 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -33,11 +33,24 @@ deployment above. existing Nostr app code that already calls `window.nostr` works unmodified, same as it would against a browser extension (Alby, nos2x, …). - `window.bitlogin` — installed as soon as the script loads, independent of - login state: `{ version, isActiveSigner() }`. Use + login state: `{ version, isActiveSigner(), releaseSigner() }`. Use `window.bitlogin.isActiveSigner()` to tell whether BitLogin specifically - (not another extension) currently backs `window.nostr`. -- Events on the element: `bitlogin-login` (`detail.publicKey`) and - `bitlogin-logout`. + (not another extension) currently backs `window.nostr`, and + `releaseSigner()` to hand `window.nostr` back (a safe no-op if BitLogin + doesn't hold it) when a user switches to a different signing method in + your own UI. +- The `` element also exposes instance-scoped + `claimSigner()` / `releaseSigner()` methods for the same purpose; + `claimSigner()` is called automatically on every successful sign-in, + sign-up, or recovery, and `releaseSigner()` on logout and on removal + from the DOM — so by default a page with a single widget never needs to + call either manually. Without this, `window.nostr` would stay pointed at + a signed-out BitLogin provider forever, blocking a switch to another + method without a page reload. +- Events on the element: `bitlogin-login` (`detail.publicKey`), + `bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released` + (the last also fires on `window` when triggered via + `window.bitlogin.releaseSigner()`). - Private keys never leave a dedicated Web Worker; the main thread only ever sees public keys, signed events, and ciphertext. diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 527fa3e..e77f156 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -1,8 +1,16 @@ /** — the embeddable BitLogin login/create/recover widget (§3, §27). */ -import { generatePassphrase, isValidLoginName, checkManualPassword, type ManualPasswordCheck } from "@bitlogin/core/account"; +import { + generatePassphrase, + isValidLoginName, + checkManualPassword, + parseRecoveryExport, + RecoveryExportParseError, + type ManualPasswordCheck, + type RecoveryExportFile +} from "@bitlogin/core/account"; import { encodeNpub } from "@bitlogin/core/nostr"; import { WorkerClient } from "./worker/workerClient.js"; -import { createNip07Provider } from "./provider.js"; +import { createNip07Provider, type Nip07Provider } from "./provider.js"; import { readConfigFromElement } from "./config.js"; import { WIDGET_STYLES } from "./styles.js"; @@ -12,6 +20,7 @@ type Screen = | "create-name" | "create-credential" | "confirm-phrase" + | "verify-phrase" | "login" | "recover-phrase" | "recover-new-credentials" @@ -29,6 +38,7 @@ export class BitLoginAuthElement extends HTMLElement { private worker: WorkerClient; private vaultRelayUrls: string[] = []; private discoveryRelayUrls: string[] = []; + private installedProvider: Nip07Provider | null = null; private screen: Screen = "welcome"; private busy = false; @@ -43,8 +53,13 @@ export class BitLoginAuthElement extends HTMLElement { // Manual password opt-in (§9.3). Off by default; the generated passphrase remains the // recommendation. manualPasswordFeedback is updated on every keystroke WITHOUT a full // re-render (see onInput) so the field never loses focus while the user is typing. + // manualPasswordDraft/manualPasswordConfirmDraft re-populate the fields' value attribute + // across a validation-failure re-render, so a rejected password isn't silently wiped, + // forcing the user to retype it (and re-check "I saved it") from scratch. private manualPasswordMode = false; private manualPasswordFeedback: ManualPasswordCheck | null = null; + private manualPasswordDraft = ""; + private manualPasswordConfirmDraft = ""; // Import flow (§SF10). importKey holds the pasted nsec/hex only until registration completes. private importKey = ""; @@ -54,6 +69,11 @@ export class BitLoginAuthElement extends HTMLElement { private recoveredPreview: { generalRelaysCount: number; dmRelaysCount: number; chainWarning?: string } | null = null; private newCredentialAfterRecovery = ""; + // Optional recovery-export-file fallback (§19.5) for when live relays can't be reached. + // Never a substitute for the phrase -- the file never contains it or any phrase-derived key. + private offlineExportFile: RecoveryExportFile | null = null; + private offlineExportFileNotice: string | undefined; + private session: { publicKey: string; npub: string; accountId?: string } | null = null; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; @@ -75,14 +95,17 @@ export class BitLoginAuthElement extends HTMLElement { this.root.addEventListener("click", (e) => this.onClick(e)); this.root.addEventListener("submit", (e) => this.onSubmit(e)); this.root.addEventListener("input", (e) => this.onInput(e)); + this.root.addEventListener("change", (e) => void this.onFileChange(e)); this.render(); + this.installedProvider = createNip07Provider(this.worker, () => this.vaultRelayUrls); if (!(window as unknown as { nostr?: unknown }).nostr) { - (window as unknown as { nostr: unknown }).nostr = createNip07Provider(this.worker, () => this.vaultRelayUrls); + (window as unknown as { nostr: unknown }).nostr = this.installedProvider; } } disconnectedCallback(): void { + this.releaseSigner(); this.worker.terminate(); } @@ -96,10 +119,43 @@ export class BitLoginAuthElement extends HTMLElement { async logout(): Promise { await this.worker.logout(); this.session = null; + this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); this.goto("welcome"); } + /** + * (Re)installs this element's own provider as window.nostr, taking over from whatever is + * currently there (an extension, a different BitLogin instance, or nothing). Called + * automatically whenever a user completes sign-in through this widget — that's an + * explicit signal they want BitLogin active. Also public: a host page offering several + * signing methods can call it directly (e.g. `document.querySelector('bitlogin-auth').claimSigner()`) + * when the user re-selects BitLogin from its own method picker, without a page reload. + */ + claimSigner(): void { + (window as unknown as { nostr: unknown }).nostr = this.installedProvider; + this.dispatchEvent(new CustomEvent("bitlogin-signer-claimed")); + } + + /** + * Releases window.nostr back to undefined, but only if it's still this element's own + * provider — never clobbers a different signing method a host page (or another widget + * instance) may have since taken over. Called automatically on logout and on removal from + * the DOM, so a page offering multiple signing methods (an extension, a NIP-46 bunker, + * BitLogin) can let a user switch away from BitLogin without a full page reload — before + * this existed, window.nostr stayed pointed at a signed-out BitLogin provider forever. + * Returns whether it actually released anything. + */ + releaseSigner(): boolean { + const w = window as unknown as { nostr?: unknown }; + if (this.installedProvider && w.nostr === this.installedProvider) { + delete w.nostr; + this.dispatchEvent(new CustomEvent("bitlogin-signer-released")); + return true; + } + return false; + } + private goto(screen: Screen): void { this.screen = screen; this.errorMessage = undefined; @@ -121,6 +177,38 @@ export class BitLoginAuthElement extends HTMLElement { return (this.root.querySelector(`[name="${name}"]`) as HTMLInputElement | null)?.value ?? ""; } + /** + * Shared manual-password field markup (§9.3), reused on every screen that lets a user set + * their own password (initial creation, phrase recovery, and password rotation) so the + * option isn't only available at signup. + */ + private renderManualPasswordFields(): string { + return ` + + +
    + + +
    + `; + } + + /** + * Reads and validates the manual-password fields against a caller-supplied login name. + * Always stashes what was typed into manualPasswordDraft/manualPasswordConfirmDraft so a + * validation-failure re-render can restore it (see renderManualPasswordFields). + */ + private readManualPassword(loginNameForValidation: string): { password: string; error?: string } { + const typed = this.field("manualPassword"); + const confirmTyped = this.field("manualPasswordConfirm"); + this.manualPasswordDraft = typed; + this.manualPasswordConfirmDraft = confirmTyped; + const check = checkManualPassword(typed, loginNameForValidation); + if (!check.ok) return { password: "", error: `Password not accepted: ${check.reason}` }; + if (typed !== confirmTyped) return { password: "", error: "Passwords do not match. Please re-enter both." }; + return { password: typed }; + } + /** * Explicitly asks the browser to offer saving this credential via the Credential * Management API (Chromium-based browsers only — Firefox and Safari never implemented @@ -170,6 +258,8 @@ export class BitLoginAuthElement extends HTMLElement { this.goto("login"); return; case "goto-recover": + this.offlineExportFile = null; + this.offlineExportFileNotice = undefined; this.goto("recover-phrase"); return; case "goto-welcome": @@ -178,8 +268,18 @@ export class BitLoginAuthElement extends HTMLElement { case "goto-dashboard": this.goto("dashboard"); return; + case "goto-verify-phrase": + this.goto("verify-phrase"); + return; + case "goto-confirm-phrase": + this.goto("confirm-phrase"); + return; case "goto-change-password": this.changePasswordNewCredential = generatePassphrase().secret; + this.manualPasswordMode = false; + this.manualPasswordFeedback = null; + this.manualPasswordDraft = ""; + this.manualPasswordConfirmDraft = ""; this.goto("change-password"); return; case "goto-export": @@ -193,6 +293,8 @@ export class BitLoginAuthElement extends HTMLElement { case "toggle-manual-password": this.manualPasswordMode = !this.manualPasswordMode; this.manualPasswordFeedback = null; + this.manualPasswordDraft = ""; + this.manualPasswordConfirmDraft = ""; this.savedCheckbox = false; this.render(); return; @@ -226,8 +328,8 @@ export class BitLoginAuthElement extends HTMLElement { return this.handleCreateNameSubmit(); case "create-credential": return await this.handleCreateCredentialSubmit(); - case "confirm-phrase": - return this.handleConfirmPhraseSubmit(); + case "verify-phrase": + return this.handleVerifyPhraseSubmit(); case "login": return await this.handleLoginSubmit(); case "recover-phrase": @@ -245,29 +347,78 @@ export class BitLoginAuthElement extends HTMLElement { } /** - * Live entropy/strength feedback for the manual-password field, updated by DIRECTLY - * patching a feedback element's text — never calling this.render() — so the input never - * loses focus or cursor position while the user is typing (§9.3). + * Live entropy/strength and match feedback for the manual-password fields, updated by + * DIRECTLY patching feedback elements' text — never calling this.render() — so neither + * input loses focus or cursor position while the user is typing (§9.3). */ private onInput(e: Event): void { const target = e.target as HTMLElement; - if (!(target instanceof HTMLInputElement) || target.name !== "manualPassword") return; - this.manualPasswordFeedback = target.value ? checkManualPassword(target.value, this.loginName) : null; + if (!(target instanceof HTMLInputElement)) return; + if (target.name !== "manualPassword" && target.name !== "manualPasswordConfirm") return; + + const pw = (this.root.querySelector('input[name="manualPassword"]') as HTMLInputElement | null)?.value ?? ""; + const confirm = (this.root.querySelector('input[name="manualPasswordConfirm"]') as HTMLInputElement | null)?.value ?? ""; + + this.manualPasswordFeedback = pw ? checkManualPassword(pw, this.loginName) : null; const feedback = this.root.querySelector("#manual-password-feedback"); - if (!feedback) return; - if (!this.manualPasswordFeedback) { - feedback.textContent = ""; - feedback.className = "notice info"; + if (feedback) { + if (!this.manualPasswordFeedback) { + feedback.textContent = ""; + feedback.className = "notice info"; + } else { + const bits = this.manualPasswordFeedback.entropyBits.toFixed(0); + if (this.manualPasswordFeedback.ok) { + feedback.textContent = `Looks good (~${bits} bits estimated).`; + feedback.className = "notice info"; + } else { + feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`; + feedback.className = "notice warn"; + } + } + } + + const matchFeedback = this.root.querySelector("#manual-password-match-feedback"); + if (matchFeedback) { + if (!confirm) { + matchFeedback.textContent = ""; + matchFeedback.className = "notice info"; + } else if (confirm === pw) { + matchFeedback.textContent = "Passwords match."; + matchFeedback.className = "notice info"; + } else { + matchFeedback.textContent = "Passwords do not match."; + matchFeedback.className = "notice warn"; + } + } + } + + /** + * Reads and validates an optional recovery-export file (§19.5) for the recover-phrase + * screen. Purely a relay-outage fallback -- the file never contains the phrase or any + * phrase-derived key, so it's ignored entirely unless the user also enters their phrase. + */ + private async onFileChange(e: Event): Promise { + const target = e.target as HTMLElement; + if (!(target instanceof HTMLInputElement) || target.name !== "offlineExportFile" || target.type !== "file") return; + const file = target.files?.[0]; + if (!file) { + this.offlineExportFile = null; + this.offlineExportFileNotice = undefined; + this.render(); return; } - const bits = this.manualPasswordFeedback.entropyBits.toFixed(0); - if (this.manualPasswordFeedback.ok) { - feedback.textContent = `Looks good (~${bits} bits estimated).`; - feedback.className = "notice info"; - } else { - feedback.textContent = `${this.manualPasswordFeedback.reason} (~${bits} bits estimated)`; - feedback.className = "notice warn"; + try { + const text = await file.text(); + this.offlineExportFile = parseRecoveryExport(JSON.parse(text)); + this.offlineExportFileNotice = `Loaded recovery export from ${new Date(this.offlineExportFile.created_at * 1000).toLocaleString()}.`; + } catch (err) { + this.offlineExportFile = null; + this.offlineExportFileNotice = + err instanceof RecoveryExportParseError || err instanceof SyntaxError + ? `Couldn't read that file: ${err.message}` + : `Couldn't read that file: ${String(err)}`; } + this.render(); } private async handlePreviewImport(): Promise { @@ -315,22 +466,25 @@ export class BitLoginAuthElement extends HTMLElement { this.savedCheckbox = false; this.manualPasswordMode = false; this.manualPasswordFeedback = null; + this.manualPasswordDraft = ""; + this.manualPasswordConfirmDraft = ""; this.goto("create-credential"); } private async handleCreateCredentialSubmit(): Promise { + // Read the checkbox before password validation so a rejected password doesn't also + // silently un-check it on the retry render. + this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false; let password = this.generatedCredential; if (this.manualPasswordMode) { - const typed = this.field("manualPassword"); - const check = checkManualPassword(typed, this.loginName); - if (!check.ok) { - this.errorMessage = `Password not accepted: ${check.reason}`; + const { password: manualPassword, error } = this.readManualPassword(this.loginName); + if (error) { + this.errorMessage = error; this.render(); return; } - password = typed; + password = manualPassword; } - this.savedCheckbox = (this.root.querySelector("#saved-check") as HTMLInputElement | null)?.checked ?? false; if (!this.savedCheckbox) { this.errorMessage = this.manualPasswordMode ? "Please confirm you've saved your password (or written it down) before continuing." @@ -365,7 +519,7 @@ export class BitLoginAuthElement extends HTMLElement { void this.offerToSaveCredential(this.loginName, password); } - private handleConfirmPhraseSubmit(): void { + private handleVerifyPhraseSubmit(): void { const words = this.recoveryPhrase.split(" "); for (const slot of this.confirmSlots) { const typed = this.field(`confirm-${slot.index}`).trim().toLowerCase(); @@ -375,6 +529,7 @@ export class BitLoginAuthElement extends HTMLElement { return; } } + this.claimSigner(); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); this.goto("dashboard"); } @@ -388,6 +543,7 @@ export class BitLoginAuthElement extends HTMLElement { this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId }; this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w); this.busy = false; + this.claimSigner(); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } })); this.goto("dashboard"); void this.offerToSaveCredential(loginName, password); @@ -396,7 +552,7 @@ export class BitLoginAuthElement extends HTMLElement { private async handleRecoverPhraseSubmit(): Promise { const phrase = this.field("phrase").trim(); this.setBusy(true); - const result = await this.worker.recover({ phrase }); + const result = await this.worker.recover({ phrase, offlineExportFile: this.offlineExportFile ?? undefined }); this.recoverPhraseInput = phrase; this.recoveredPreview = { generalRelaysCount: result.generalRelays.length, @@ -405,6 +561,10 @@ export class BitLoginAuthElement extends HTMLElement { }; this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId }; this.newCredentialAfterRecovery = generatePassphrase().secret; + this.manualPasswordMode = false; + this.manualPasswordFeedback = null; + this.manualPasswordDraft = ""; + this.manualPasswordConfirmDraft = ""; this.busy = false; this.goto("recover-new-credentials"); } @@ -416,26 +576,48 @@ export class BitLoginAuthElement extends HTMLElement { this.render(); return; } + let newPassword = this.newCredentialAfterRecovery; + if (this.manualPasswordMode) { + const { password, error } = this.readManualPassword(newLoginName); + if (error) { + this.errorMessage = error; + this.render(); + return; + } + newPassword = password; + } this.setBusy(true); - await this.worker.completeRecovery({ newLoginName, newPassword: this.newCredentialAfterRecovery }); + await this.worker.completeRecovery({ newLoginName, newPassword }); this.loginName = newLoginName; this.busy = false; + this.claimSigner(); this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); this.goto("dashboard"); - void this.offerToSaveCredential(newLoginName, this.newCredentialAfterRecovery); + void this.offerToSaveCredential(newLoginName, newPassword); } private async handleChangePasswordSubmit(): Promise { const oldPassword = this.field("oldPassword"); + let newPassword = this.changePasswordNewCredential; + if (this.manualPasswordMode) { + const { password, error } = this.readManualPassword(this.loginName); + if (error) { + this.errorMessage = error; + this.render(); + return; + } + newPassword = password; + } this.setBusy(true); await this.worker.changePassword({ loginName: this.loginName, oldPassword, - newPassword: this.changePasswordNewCredential + newPassword }); this.busy = false; + this.claimSigner(); this.goto("dashboard"); - void this.offerToSaveCredential(this.loginName, this.changePasswordNewCredential); + void this.offerToSaveCredential(this.loginName, newPassword); } private async handleDownloadRecoveryExport(): Promise { @@ -542,9 +724,7 @@ export class BitLoginAuthElement extends HTMLElement {
    Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.
    ${this.renderError()}
    - - -
    + ${this.renderManualPasswordFields()}
    ${this.renderError()} - -

    Confirm you saved it by typing the requested words:

    + + `; + + case "verify-phrase": + return ` +

    Verify your recovery phrase

    +

    Your phrase is hidden now so you can confirm you actually saved it. Enter the requested words below.

    + ${this.renderError()} + ${this.confirmSlots .map( (slot) => ` @@ -598,6 +785,7 @@ export class BitLoginAuthElement extends HTMLElement { .join("")} + `; case "login": @@ -626,6 +814,14 @@ export class BitLoginAuthElement extends HTMLElement {
    + + +

    Only needed if relays are unreachable -- the phrase above is still required either way. The file alone can never recover an account by itself.

    + ${ + this.offlineExportFileNotice + ? `
    ${escapeHtml(this.offlineExportFileNotice)}
    ` + : "" + } @@ -637,6 +833,7 @@ export class BitLoginAuthElement extends HTMLElement { const chainWarning = this.recoveredPreview?.chainWarning ? `
    ${escapeHtml(this.recoveredPreview.chainWarning)}
    ` : ""; + const submitLabel = this.busy ? 'Finishing recovery…' : "Finish recovery"; return `

    Identity recovered

    Found your account. Restored ${this.recoveredPreview?.generalRelaysCount ?? 0} general relay(s) and ${ @@ -644,15 +841,21 @@ export class BitLoginAuthElement extends HTMLElement { } DM relay(s) from your public events.

    ${chainWarning}

    Now set a new login name and password.

    -
    ${escapeHtml(this.newCredentialAfterRecovery)}
    + ${ + this.manualPasswordMode + ? `
    Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.
    ` + : `
    ${escapeHtml(this.newCredentialAfterRecovery)}
    ` + } ${this.renderError()} - + ${this.manualPasswordMode ? this.renderManualPasswordFields() : ""} +
    + `; } @@ -678,15 +881,23 @@ export class BitLoginAuthElement extends HTMLElement { return `

    Rotate password

    Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded.

    -
    ${escapeHtml(this.changePasswordNewCredential)}
    + ${ + this.manualPasswordMode + ? `
    Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.
    ` + : `
    ${escapeHtml(this.changePasswordNewCredential)}
    ` + } ${this.renderError()}
    + ${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
    + `; diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 063b5bc..1f03014 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -14,6 +14,16 @@ export interface BitLoginGlobal { * unlike checking `window.nostr._bitlogin` directly — since it's read fresh each call. */ isActiveSigner(): boolean; + /** + * Releases window.nostr back to undefined, but only if BitLogin currently occupies it — + * a no-op otherwise, so it's always safe to call speculatively. Lets a host page that + * offers several signing methods (an extension, a NIP-46 bunker, BitLogin) hand the slot + * back when a user switches away from BitLogin, without needing a reference to a specific + * element or a page reload. The element itself also exposes an + * instance-scoped `releaseSigner()`/`claimSigner()` pair for finer control when a page + * holds several widget instances. Returns whether it actually released anything. + */ + releaseSigner(): boolean; } declare global { @@ -37,6 +47,15 @@ window.bitlogin = { version: "0.1.0", isActiveSigner(): boolean { return (window as unknown as { nostr?: { _bitlogin?: boolean } }).nostr?._bitlogin === true; + }, + releaseSigner(): boolean { + const w = window as unknown as { nostr?: { _bitlogin?: boolean } }; + if (w.nostr?._bitlogin === true) { + delete (w as { nostr?: unknown }).nostr; + window.dispatchEvent(new CustomEvent("bitlogin-signer-released")); + return true; + } + return false; } }; diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index 0c0687c..e134a45 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -151,7 +151,12 @@ async function handle(action: string, payload: unknown): Promise { case "recover": { const p = payload as RecoverPayload; - const recovered = await recoverWithPhrase({ phrase: p.phrase, vaultRelayUrls, discoveryRelayUrls }); + const recovered = await recoverWithPhrase({ + phrase: p.phrase, + vaultRelayUrls, + discoveryRelayUrls, + offlineRecoveryCapsuleEvents: p.offlineExportFile?.recovery_capsule_events + }); clearSession(); session.pendingRecovery = recovered; session.signer = new NostrSigner(recovered.everydayPrivateKey); diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 87d048e..a1b2839 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -1,5 +1,6 @@ /** Request/response envelope for the main-thread <-> crypto-worker RPC channel. */ import type { NostrEvent, NostrTag } from "@bitlogin/core/nostr"; +import type { RecoveryExportFile } from "@bitlogin/core/account"; export interface WorkerRequest { id: string; @@ -54,6 +55,13 @@ export interface LoginResult { export interface RecoverPayload { phrase: string; + /** + * A previously downloaded recovery export file (§19.5), parsed on the main thread and + * passed through as-is. Used as a fallback alongside the live relay read when every + * configured relay is unreachable or has lost the capsule -- never a replacement for the + * phrase, which the file never contains. + */ + offlineExportFile?: RecoveryExportFile; } export interface RecoverResult { everydayPublicKey: string;