mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Merge pull request #3 from PR0M3TH3AN/claude/bitlogin-protocol-spec-q18ust
Add signer switching, manual-password parity, phrase-quiz split, and offline recovery import
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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><bitlogin-auth></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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user