diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 60b5a39..2a8e73d 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -118,7 +118,9 @@ export class BitLoginAuthElement extends HTMLElement { const config = readConfigFromElement(this); this.vaultRelayUrls = config.vaultRelayUrls ?? []; this.discoveryRelayUrls = config.discoveryRelayUrls ?? []; - void this.worker.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }); + void this.worker + .configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }) + .then(() => this.tryRestoreSession()); this.root.addEventListener("click", (e) => this.onClick(e)); this.root.addEventListener("submit", (e) => this.onSubmit(e)); @@ -665,6 +667,34 @@ export class BitLoginAuthElement extends HTMLElement { await this.attemptLogin(loginName, password); } + /** + * Called once per connectedCallback, right after "configure" -- restores whatever + * a prior login/register/rotate cached locally (§21), so a page reload lands + * straight on the dashboard instead of asking for the login name + password + * again. Silent by design: no flashSuccess() stamp (that's reserved for a + * deliberate action the user just took) and no offerToSaveCredential (there's + * no password in hand to save). If the welcome screen already rendered by the + * time this resolves, goto() just re-renders over it -- a brief flash, not a + * correctness issue. + */ + private async tryRestoreSession(): Promise { + try { + const result = await this.worker.restoreSession(); + if (!result.restored || !result.everydayPublicKey) return; + this.session = { + publicKey: result.everydayPublicKey, + npub: encodeNpub(result.everydayPublicKey), + accountId: result.accountId + }; + this.noteSignerClaim(this.claimSigner()); + this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } })); + this.goto("dashboard"); + } catch { + // No cached session, or the worker/IndexedDB isn't available -- fall + // through to the normal welcome screen already rendered. + } + } + /** * Shared by the login form and the "continue anyway" rollback-confirmation step so both * paths grant a session identically -- claimSigner() and the bitlogin-login event only ever diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index f79f8d8..b1911cf 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -21,6 +21,7 @@ import { import { RelayPool, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr"; import { getPublicKeyHex } from "@bitlogin/core/crypto"; import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js"; +import { saveCachedSession, loadCachedSession, clearCachedSession } from "./sessionCache.js"; import type { WorkerRequest, WorkerResponse, @@ -87,6 +88,30 @@ function clearSession(): void { } } +// Caches the current session (§21) so a later page load can restore it via +// "restoreSession" instead of asking for the login name + password again. +// Best-effort and silent on any missing field -- every call site here runs +// right after setting all five, so this only ever no-ops if IndexedDB itself +// is unavailable (saveCachedSession's own concern, not this one's). +async function persistSession(): Promise { + if ( + !session.everydayPrivateKey || + !session.accountId || + !session.recoveryPublicKey || + !session.activeCredentialEvent || + !session.activeRecoveryEvent + ) { + return; + } + await saveCachedSession(store, { + everydayPrivateKey: session.everydayPrivateKey, + accountId: session.accountId, + recoveryPublicKey: session.recoveryPublicKey, + activeCredentialEvent: session.activeCredentialEvent, + activeRecoveryEvent: session.activeRecoveryEvent + }); +} + async function handle(action: string, payload: unknown): Promise { switch (action) { case "configure": { @@ -108,6 +133,7 @@ async function handle(action: string, payload: unknown): Promise { session.recoveryPublicKey = result.recoveryPublicKey; session.activeCredentialEvent = result.credentialEvent; session.activeRecoveryEvent = result.recoveryEvent; + await persistSession(); return { recoveryPhrase: result.recoveryPhrase, everydayPublicKey: result.everydayPublicKey, @@ -146,6 +172,7 @@ async function handle(action: string, payload: unknown): Promise { session.recoveryPublicKey = result.recoveryPublicKey; session.activeCredentialEvent = result.credentialEvent; session.activeRecoveryEvent = result.recoveryCapsuleEvent; + await persistSession(); return { everydayPublicKey: result.everydayPublicKey, accountId: result.accountId, @@ -193,6 +220,7 @@ async function handle(action: string, payload: unknown): Promise { // The recovery phrase's signing key must not linger beyond the operations that need it (§7.1, §11.10). session.pendingRecovery.recoveryPrivateKey.fill(0); session.pendingRecovery = null; + await persistSession(); return { locatorPublicKey: result.locatorPublicKey, credentialEventId: result.credentialEvent.id, @@ -216,6 +244,7 @@ async function handle(action: string, payload: unknown): Promise { session.activeCredentialEvent = result.newCredentialEvent; session.recoveryPublicKey = result.recoveryPublicKey; session.activeRecoveryEvent = result.recoveryCapsuleEvent; + await persistSession(); return { newLocatorPublicKey: result.newLocatorPublicKey, newGeneration: result.newGeneration, @@ -306,8 +335,27 @@ async function handle(action: string, payload: unknown): Promise { return { unlocked: !!session.signer, everydayPublicKey: session.signer?.getPublicKey() }; } + // Called once, right after "configure", before the widget renders its welcome + // screen -- restores whatever persistSession() last cached for this origin, so + // a page reload doesn't ask for the login name + password again. A missing or + // corrupt cache is not an error: it just means the widget falls through to its + // normal welcome screen, exactly like it always has. + case "restoreSession": { + const cached = await loadCachedSession(store); + if (!cached) return { restored: false }; + clearSession(); + session.signer = new NostrSigner(cached.everydayPrivateKey); + session.everydayPrivateKey = cached.everydayPrivateKey; + session.accountId = cached.accountId; + session.recoveryPublicKey = cached.recoveryPublicKey; + session.activeCredentialEvent = cached.activeCredentialEvent; + session.activeRecoveryEvent = cached.activeRecoveryEvent; + return { restored: true, everydayPublicKey: session.signer.getPublicKey(), accountId: cached.accountId }; + } + case "logout": { clearSession(); + await clearCachedSession(store); return {}; } diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 5ce26e4..05ef02c 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -157,6 +157,12 @@ export interface SessionStatusResult { everydayPublicKey?: string; } +export interface RestoreSessionResult { + restored: boolean; + everydayPublicKey?: string; + accountId?: string; +} + export type WorkerActionMap = { configure: [ConfigurePayload, Record]; register: [RegisterPayload, RegisterResult]; @@ -176,6 +182,7 @@ export type WorkerActionMap = { buildRecoveryExport: [Record, unknown]; repairReplicas: [Record, RepairReplicasResult]; getSessionStatus: [Record, SessionStatusResult]; + restoreSession: [Record, RestoreSessionResult]; logout: [Record, Record]; }; diff --git a/packages/widget/src/worker/sessionCache.ts b/packages/widget/src/worker/sessionCache.ts new file mode 100644 index 0000000..6ba40e0 --- /dev/null +++ b/packages/widget/src/worker/sessionCache.ts @@ -0,0 +1,94 @@ +/** + * Local "stay signed in" cache (§21) -- lets a page reload restore the active + * identity without re-running Argon2id + a relay quorum read (both + * deliberately expensive, exactly the wrong tradeoff for something that + * should happen on every navigation). Scoped by IndexedDB's own per-origin + * isolation, same boundary a NIP-07 extension's own storage already relies + * on -- one site's cached session is never visible to another. + * + * This trades "nothing persists locally" for "the everyday private key sits + * decrypted in this browser's IndexedDB until logout" -- the same tradeoff + * every NIP-07 extension and crypto wallet already makes for the same + * reason. Only the everyday key is cached; the recovery phrase and the + * login name/password are never retained anywhere. + */ +import type { KeyValueStore } from "@bitlogin/core/storage"; +import type { NostrEvent } from "@bitlogin/core/nostr"; +import { bytesToHex, hexToBytes } from "@bitlogin/core/crypto"; + +const SESSION_KEY = "bitlogin:session:v1"; + +export interface CachedSessionData { + everydayPrivateKey: Uint8Array; + accountId: string; + recoveryPublicKey: string; + activeCredentialEvent: NostrEvent; + activeRecoveryEvent: NostrEvent; +} + +interface StoredSession { + everydayPrivateKeyHex: string; + accountId: string; + recoveryPublicKey: string; + activeCredentialEvent: NostrEvent; + activeRecoveryEvent: NostrEvent; +} + +export async function saveCachedSession(store: KeyValueStore, data: CachedSessionData): Promise { + const stored: StoredSession = { + everydayPrivateKeyHex: bytesToHex(data.everydayPrivateKey), + accountId: data.accountId, + recoveryPublicKey: data.recoveryPublicKey, + activeCredentialEvent: data.activeCredentialEvent, + activeRecoveryEvent: data.activeRecoveryEvent + }; + // Best-effort: a full disk or a browser blocking IndexedDB (some private- + // browsing modes) shouldn't break the login/register/rotate call that + // triggered this -- it just means the next page load asks for credentials + // again, exactly like today. + try { + await store.set(SESSION_KEY, JSON.stringify(stored)); + } catch { + // ignore + } +} + +export async function loadCachedSession(store: KeyValueStore): Promise { + let raw: string | undefined; + try { + raw = await store.get(SESSION_KEY); + } catch { + return null; + } + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.everydayPrivateKeyHex !== "string" || + typeof parsed.accountId !== "string" || + typeof parsed.recoveryPublicKey !== "string" || + !parsed.activeCredentialEvent || + !parsed.activeRecoveryEvent + ) { + return null; + } + return { + everydayPrivateKey: hexToBytes(parsed.everydayPrivateKeyHex), + accountId: parsed.accountId, + recoveryPublicKey: parsed.recoveryPublicKey, + activeCredentialEvent: parsed.activeCredentialEvent, + activeRecoveryEvent: parsed.activeRecoveryEvent + }; + } catch { + // Corrupt or unrecognized cache entry -- treat as "no session," not an error. + return null; + } +} + +export async function clearCachedSession(store: KeyValueStore): Promise { + try { + await store.delete(SESSION_KEY); + } catch { + // ignore + } +} diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 68224fd..7752433 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -112,6 +112,9 @@ export class WorkerClient { getSessionStatus() { return this.call("getSessionStatus", {}); } + restoreSession() { + return this.call("restoreSession", {}); + } logout() { return this.call("logout", {}); }