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
This commit is contained in:
Claude committed 2026-07-21 16:41:35 +00:00
1 parent 0801371566
commit a24719184e
9 files changed
+394 -52

No files matched your search

@@ -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 () => {
+27 -2
View File
@@ -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<Candidate<RecoveryPayload>> = [];
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<string, Candidate<RecoveryPayload>>();
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");
+18
View File
@@ -102,6 +102,24 @@ if (window.bitlogin?.isActiveSigner()) {
shadowing the API object described above. Use something like <code>id="bitlogin-widget"</code> instead
(as this demo does).
</div>
<h4 style="margin-top:20px">Letting a user switch away from BitLogin without reloading</h4>
<p>By default, once <code>&lt;bitlogin-auth&gt;</code> mounts it claims <code>window.nostr</code> 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
<code>window.bitlogin.releaseSigner()</code> to hand the slot back (it's a no-op, safe to call speculatively,
if BitLogin doesn't currently hold it):</p>
<pre><code>// 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
}</code></pre>
<p>If they switch back to BitLogin later, call <code>claimSigner()</code> 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):</p>
<pre><code>document.querySelector('bitlogin-auth').claimSigner();</code></pre>
<p>Both <code>claimSigner()</code>/<code>releaseSigner()</code> on the element and
<code>window.bitlogin.releaseSigner()</code> also fire <code>bitlogin-signer-claimed</code> /
<code>bitlogin-signer-released</code> events (on the element and on <code>window</code>, respectively) if you'd
rather react to the change than check a return value.</p>
</li>
<li>
<h3>Choose your relays</h3>
+2 -2
View File
@@ -75,8 +75,8 @@
<p>Encrypted capsules are published to multiple Nostr relays with quorum reads and readback verification, so no single relay is a single point of failure.</p>
</div>
<div class="feature">
<h3>Generated credentials only</h3>
<p>A downloadable encrypted capsule can be attacked offline forever. So BitLogin generates a high-entropy passphrase for you — no "password123", ever.</p>
<h3>Generated by default, never weak</h3>
<p>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.</p>
</div>
<div class="feature">
<h3>Argon2id + AES-256-GCM</h3>
+17 -4
View File
@@ -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 `<bitlogin-auth>` 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.
+254 -43
View File
@@ -1,8 +1,16 @@
/** <bitlogin-auth> — 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<void> {
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 `
<label for="manualPassword">Password</label>
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" value="${escapeHtml(this.manualPasswordDraft)}" />
<div class="notice info" id="manual-password-feedback"></div>
<label for="manualPasswordConfirm">Confirm password</label>
<input type="password" name="manualPasswordConfirm" id="manualPasswordConfirm" autocomplete="new-password" required minlength="12" value="${escapeHtml(this.manualPasswordConfirmDraft)}" />
<div class="notice info" id="manual-password-match-feedback"></div>
`;
}
/**
* 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<HTMLElement>("#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<HTMLElement>("#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<void> {
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<void> {
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
@@ -542,9 +724,7 @@ export class BitLoginAuthElement extends HTMLElement {
<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>
${this.renderError()}
<form data-form="create-credential">
<label for="manualPassword">Password</label>
<input type="password" name="manualPassword" id="manualPassword" autocomplete="new-password" required minlength="12" />
<div class="notice info" id="manual-password-feedback"></div>
${this.renderManualPasswordFields()}
<label class="checkbox-row">
<input type="checkbox" id="saved-check" ${this.savedCheckbox ? "checked" : ""} />
I have saved this password somewhere safe.
@@ -586,8 +766,15 @@ export class BitLoginAuthElement extends HTMLElement {
.join("")}
</div>
${this.renderError()}
<form data-form="confirm-phrase">
<p class="sub" style="margin-top:14px">Confirm you saved it by typing the requested words:</p>
<button class="primary" type="button" data-action="goto-verify-phrase">I've saved my phrase — continue</button>
`;
case "verify-phrase":
return `
<h2>Verify your recovery phrase</h2>
<p class="sub">Your phrase is hidden now so you can confirm you actually saved it. Enter the requested words below.</p>
${this.renderError()}
<form data-form="verify-phrase">
${this.confirmSlots
.map(
(slot) => `
@@ -598,6 +785,7 @@ export class BitLoginAuthElement extends HTMLElement {
.join("")}
<button class="primary" type="submit">Confirm and continue</button>
</form>
<button class="link" data-action="goto-confirm-phrase">Back to phrase</button>
`;
case "login":
@@ -626,6 +814,14 @@ export class BitLoginAuthElement extends HTMLElement {
<form data-form="recover-phrase">
<label for="phrase">Recovery phrase</label>
<input type="text" name="phrase" id="phrase" autocomplete="off" required placeholder="12 words separated by spaces" />
<label for="offlineExportFile" style="margin-top:14px">Recovery export file (optional)</label>
<input type="file" name="offlineExportFile" id="offlineExportFile" accept=".json,application/json" />
<p class="small">Only needed if relays are unreachable -- the phrase above is still required either way. The file alone can never recover an account by itself.</p>
${
this.offlineExportFileNotice
? `<div class="notice ${this.offlineExportFile ? "info" : "warn"}">${escapeHtml(this.offlineExportFileNotice)}</div>`
: ""
}
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Recovering…' : "Continue"}
</button>
@@ -637,6 +833,7 @@ export class BitLoginAuthElement extends HTMLElement {
const chainWarning = this.recoveredPreview?.chainWarning
? `<div class="notice warn">${escapeHtml(this.recoveredPreview.chainWarning)}</div>`
: "";
const submitLabel = this.busy ? '<span class="spinner"></span>Finishing recovery…' : "Finish recovery";
return `
<h2>Identity recovered</h2>
<p class="sub">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.</p>
${chainWarning}
<p class="sub">Now set a new login name and password.</p>
<div class="credential-box">${escapeHtml(this.newCredentialAfterRecovery)}</div>
${
this.manualPasswordMode
? `<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>`
: `<div class="credential-box">${escapeHtml(this.newCredentialAfterRecovery)}</div>`
}
${this.renderError()}
<form data-form="recover-new-credentials">
<label for="newLoginName">New login name</label>
<input type="text" name="newLoginName" id="newLoginName" autocomplete="off" required />
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Finishing recovery…' : "Finish recovery"}
</button>
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${submitLabel}</button>
</form>
<button class="link" data-action="toggle-manual-password">
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
</button>
`;
}
@@ -678,15 +881,23 @@ export class BitLoginAuthElement extends HTMLElement {
return `
<h2>Rotate password</h2>
<p class="sub">Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded.</p>
<div class="credential-box">${escapeHtml(this.changePasswordNewCredential)}</div>
${
this.manualPasswordMode
? `<div class="notice warn">Offline guessing against this password can never be fully prevented. The generated passphrase remains the safer default.</div>`
: `<div class="credential-box">${escapeHtml(this.changePasswordNewCredential)}</div>`
}
${this.renderError()}
<form data-form="change-password">
<label for="oldPassword">Current password</label>
<input type="password" name="oldPassword" id="oldPassword" autocomplete="current-password" required />
${this.manualPasswordMode ? this.renderManualPasswordFields() : ""}
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Rotating…' : "Confirm rotation"}
</button>
</form>
<button class="link" data-action="toggle-manual-password">
${this.manualPasswordMode ? "Use a generated password instead (recommended)" : "Use my own password instead (not recommended)"}
</button>
<button class="link" data-action="goto-dashboard">Cancel</button>
`;
+19
View File
@@ -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
* <bitlogin-auth> 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;
}
};
+6 -1
View File
@@ -151,7 +151,12 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
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);
+8
View File
@@ -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<TAction extends string = string, TPayload = unknown> {
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;