Add local session persistence so a page reload doesn't require re-login

Every reload previously destroyed the crypto worker, forcing the full
login-name + password flow again -- re-running Argon2id and a relay
quorum read, both deliberately expensive (the whole point of Argon2id
is to resist offline brute force), on every single page navigation.
No real site can expect users to accept that.

After a successful register/login/completeRecovery/changePassword,
cache the everyday private key plus the active credential/recovery
capsule events in IndexedDB (persistSession() in cryptoWorker.ts).
connectedCallback() now calls a new "restoreSession" worker action
right after "configure", before rendering the welcome screen; if a
valid cache exists, the widget lands straight on the dashboard and
fires "bitlogin-login" exactly as a fresh login would, with no
password prompt. Logout clears the cache.

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, and scoped by IndexedDB's own per-origin isolation
(one site's cached session is never visible to another, same boundary
window.nostr already relies on). The recovery phrase and login
name/password are never cached.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
This commit is contained in:
thePR0M3TH3ANandClaude Sonnet 5 committed 2026-07-22 07:47:07 -04:00
1 parent 92a87ff13e
commit 9e3787d301
5 files changed
+183 -1

No files matched your search

+31 -1
View File
@@ -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<void> {
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
@@ -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<void> {
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<unknown> {
switch (action) {
case "configure": {
@@ -108,6 +133,7 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
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<unknown> {
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<unknown> {
// 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<unknown> {
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<unknown> {
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 {};
}
+7
View File
@@ -157,6 +157,12 @@ export interface SessionStatusResult {
everydayPublicKey?: string;
}
export interface RestoreSessionResult {
restored: boolean;
everydayPublicKey?: string;
accountId?: string;
}
export type WorkerActionMap = {
configure: [ConfigurePayload, Record<string, never>];
register: [RegisterPayload, RegisterResult];
@@ -176,6 +182,7 @@ export type WorkerActionMap = {
buildRecoveryExport: [Record<string, never>, unknown];
repairReplicas: [Record<string, never>, RepairReplicasResult];
getSessionStatus: [Record<string, never>, SessionStatusResult];
restoreSession: [Record<string, never>, RestoreSessionResult];
logout: [Record<string, never>, Record<string, never>];
};
@@ -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<void> {
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<CachedSessionData | null> {
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<StoredSession>;
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<void> {
try {
await store.delete(SESSION_KEY);
} catch {
// ignore
}
}
@@ -112,6 +112,9 @@ export class WorkerClient {
getSessionStatus() {
return this.call("getSessionStatus", {});
}
restoreSession() {
return this.call("restoreSession", {});
}
logout() {
return this.call("logout", {});
}