diff --git a/packages/widget/src/storage/indexedDbStore.ts b/packages/widget/src/storage/indexedDbStore.ts index 86c773c..a50f2a9 100644 --- a/packages/widget/src/storage/indexedDbStore.ts +++ b/packages/widget/src/storage/indexedDbStore.ts @@ -3,7 +3,8 @@ import type { KeyValueStore } from "@bitlogin/core/storage"; const DB_NAME = "bitlogin"; const STORE_NAME = "kv"; -const DB_VERSION = 1; +const KEY_STORE_NAME = "device-keys"; +const DB_VERSION = 2; function openDb(): Promise { return new Promise((resolve, reject) => { @@ -13,6 +14,9 @@ function openDb(): Promise { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } + if (!db.objectStoreNames.contains(KEY_STORE_NAME)) { + db.createObjectStore(KEY_STORE_NAME); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); @@ -56,4 +60,55 @@ export class IndexedDbKeyValueStore implements KeyValueStore { tx.onerror = () => reject(tx.error); }); } + + /** + * Returns a non-extractable AES-GCM key kept by the browser's IndexedDB + * implementation. The key is deliberately separate from the string-only KV + * API so callers cannot accidentally serialize it as application data. + */ + async getOrCreateDeviceKey(key: string): Promise { + const db = await this.db(); + const existing = await new Promise((resolve, reject) => { + const request = db.transaction(KEY_STORE_NAME, "readonly").objectStore(KEY_STORE_NAME).get(key); + request.onsuccess = () => resolve(request.result as CryptoKey | undefined); + request.onerror = () => reject(request.error); + }); + if (existing) return existing; + + const created = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(KEY_STORE_NAME, "readwrite"); + // add(), rather than put(), means a concurrent page keeps the key it + // won the race to create instead of orphaning its encrypted session. + tx.objectStore(KEY_STORE_NAME).add(created, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + return created; + } catch { + const raced = await new Promise((resolve, reject) => { + const request = db.transaction(KEY_STORE_NAME, "readonly").objectStore(KEY_STORE_NAME).get(key); + request.onsuccess = () => resolve(request.result as CryptoKey | undefined); + request.onerror = () => reject(request.error); + }); + if (raced) return raced; + throw new Error("Unable to create the browser-bound session key."); + } + } + + async deleteDeviceKey(key: string): Promise { + const db = await this.db(); + return new Promise((resolve, reject) => { + const tx = db.transaction(KEY_STORE_NAME, "readwrite"); + tx.objectStore(KEY_STORE_NAME).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } } diff --git a/packages/widget/src/worker/sessionCache.test.ts b/packages/widget/src/worker/sessionCache.test.ts new file mode 100644 index 0000000..f941919 --- /dev/null +++ b/packages/widget/src/worker/sessionCache.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { KeyValueStore } from "@bitlogin/core/storage"; +import { clearCachedSession, loadCachedSession, saveCachedSession } from "./sessionCache.js"; + +class TestSessionStore implements KeyValueStore { + values = new Map(); + keys = new Map(); + + async get(key: string): Promise { return this.values.get(key); } + async set(key: string, value: string): Promise { this.values.set(key, value); } + async delete(key: string): Promise { this.values.delete(key); } + async getOrCreateDeviceKey(key: string): Promise { + let value = this.keys.get(key); + if (!value) { + value = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + this.keys.set(key, value); + } + return value; + } + async deleteDeviceKey(key: string): Promise { this.keys.delete(key); } +} + +const session = { + everydayPrivateKey: new Uint8Array(32).fill(7), + accountId: "account", + recoveryPublicKey: "a".repeat(64), + activeCredentialEvent: { id: "credential" } as never, + activeRecoveryEvent: { id: "recovery" } as never +}; + +describe("encrypted session cache", () => { + it("restores seamlessly without storing the everyday private key in plaintext", async () => { + const store = new TestSessionStore(); + await saveCachedSession(store, session); + + const raw = store.values.get("bitlogin:session:v1") || ""; + expect(raw).toContain('"v":2'); + expect(raw).not.toContain("07070707"); + + const restored = await loadCachedSession(store); + expect(restored?.everydayPrivateKey).toEqual(session.everydayPrivateKey); + expect(restored?.accountId).toBe(session.accountId); + }); + + it("deletes a legacy plaintext session instead of restoring it", async () => { + const store = new TestSessionStore(); + store.values.set("bitlogin:session:v1", JSON.stringify({ + everydayPrivateKeyHex: "07".repeat(32), accountId: "account", recoveryPublicKey: "a".repeat(64), + activeCredentialEvent: { id: "credential" }, activeRecoveryEvent: { id: "recovery" } + })); + + await expect(loadCachedSession(store)).resolves.toBeNull(); + expect(store.values.has("bitlogin:session:v1")).toBe(false); + }); + + it("clears both encrypted data and its browser-bound key on logout", async () => { + const store = new TestSessionStore(); + await saveCachedSession(store, session); + await clearCachedSession(store); + expect(store.values.has("bitlogin:session:v1")).toBe(false); + expect(store.keys.has("bitlogin:session-device-key:v1")).toBe(false); + }); +}); diff --git a/packages/widget/src/worker/sessionCache.ts b/packages/widget/src/worker/sessionCache.ts index 6ba40e0..fed3cd6 100644 --- a/packages/widget/src/worker/sessionCache.ts +++ b/packages/widget/src/worker/sessionCache.ts @@ -6,17 +6,17 @@ * 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. + * The session record is AES-GCM encrypted with a non-extractable CryptoKey + * retained by IndexedDB. That keeps page-to-page restore seamless while + * avoiding a raw everyday private key in application-readable storage. This + * is protection at rest, not a substitute for CSP: active same-origin script + * can still ask the browser to use the key and must never be untrusted. */ 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"; +const SESSION_DEVICE_KEY = "bitlogin:session-device-key:v1"; export interface CachedSessionData { everydayPrivateKey: Uint8Array; @@ -26,7 +26,7 @@ export interface CachedSessionData { activeRecoveryEvent: NostrEvent; } -interface StoredSession { +interface PlainSession { everydayPrivateKeyHex: string; accountId: string; recoveryPublicKey: string; @@ -34,9 +34,49 @@ interface StoredSession { activeRecoveryEvent: NostrEvent; } -export async function saveCachedSession(store: KeyValueStore, data: CachedSessionData): Promise { - const stored: StoredSession = { - everydayPrivateKeyHex: bytesToHex(data.everydayPrivateKey), +interface EncryptedSession { + v: 2; + iv: string; + ciphertext: string; +} + +interface DeviceKeyStore { + getOrCreateDeviceKey(key: string): Promise; + deleteDeviceKey(key: string): Promise; +} + +type SessionStore = KeyValueStore & Partial; + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function fromBase64(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function isEncryptedSession(value: unknown): value is EncryptedSession { + return Boolean( + value && + typeof value === "object" && + (value as EncryptedSession).v === 2 && + typeof (value as EncryptedSession).iv === "string" && + typeof (value as EncryptedSession).ciphertext === "string" + ); +} + +export async function saveCachedSession(store: SessionStore, data: CachedSessionData): Promise { + const stored: PlainSession = { + everydayPrivateKeyHex: Array.from(data.everydayPrivateKey, (byte) => byte.toString(16).padStart(2, "0")).join(""), accountId: data.accountId, recoveryPublicKey: data.recoveryPublicKey, activeCredentialEvent: data.activeCredentialEvent, @@ -47,13 +87,28 @@ export async function saveCachedSession(store: KeyValueStore, data: CachedSessio // 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)); + if (typeof store.getOrCreateDeviceKey !== "function") { + // Never retain the legacy plaintext format when secure key storage is + // unavailable (for example, an old/private browser implementation). + await store.delete(SESSION_KEY); + return; + } + const key = await store.getOrCreateDeviceKey(SESSION_DEVICE_KEY); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(stored)); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); + const encrypted: EncryptedSession = { + v: 2, + iv: toBase64(iv), + ciphertext: toBase64(new Uint8Array(ciphertext)) + }; + await store.set(SESSION_KEY, JSON.stringify(encrypted)); } catch { // ignore } } -export async function loadCachedSession(store: KeyValueStore): Promise { +export async function loadCachedSession(store: SessionStore): Promise { let raw: string | undefined; try { raw = await store.get(SESSION_KEY); @@ -62,7 +117,20 @@ export async function loadCachedSession(store: KeyValueStore): Promise; + const encrypted = JSON.parse(raw) as unknown; + if (!isEncryptedSession(encrypted) || typeof store.getOrCreateDeviceKey !== "function") { + // A v1 record contains a plaintext private key. Remove it rather than + // silently preserving the insecure cache; the user can sign in once. + await store.delete(SESSION_KEY); + return null; + } + const key = await store.getOrCreateDeviceKey(SESSION_DEVICE_KEY); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(fromBase64(encrypted.iv)) }, + key, + toArrayBuffer(fromBase64(encrypted.ciphertext)) + ); + const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as Partial; if ( typeof parsed.everydayPrivateKeyHex !== "string" || typeof parsed.accountId !== "string" || @@ -73,7 +141,10 @@ export async function loadCachedSession(store: KeyValueStore): Promise Number.parseInt(pair, 16) + ), accountId: parsed.accountId, recoveryPublicKey: parsed.recoveryPublicKey, activeCredentialEvent: parsed.activeCredentialEvent, @@ -85,9 +156,12 @@ export async function loadCachedSession(store: KeyValueStore): Promise { +export async function clearCachedSession(store: SessionStore): Promise { try { await store.delete(SESSION_KEY); + if (typeof store.deleteDeviceKey === "function") { + await store.deleteDeviceKey(SESSION_DEVICE_KEY); + } } catch { // ignore }