fix(widget): encrypt persistent session cache

This commit is contained in:
thePR0M3TH3AN committed 2026-07-25 23:28:41 -04:00
1 parent fe9a7afde2
commit e9a5a41ff7
3 files changed
+208 -16

No files matched your search

+56 -1
View File
@@ -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<IDBDatabase> {
return new Promise((resolve, reject) => {
@@ -13,6 +14,9 @@ function openDb(): Promise<IDBDatabase> {
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<CryptoKey> {
const db = await this.db();
const existing = await new Promise<CryptoKey | undefined>((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<void>((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<CryptoKey | undefined>((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<void> {
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);
});
}
}
@@ -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<string, string>();
keys = new Map<string, CryptoKey>();
async get(key: string): Promise<string | undefined> { return this.values.get(key); }
async set(key: string, value: string): Promise<void> { this.values.set(key, value); }
async delete(key: string): Promise<void> { this.values.delete(key); }
async getOrCreateDeviceKey(key: string): Promise<CryptoKey> {
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<void> { 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);
});
});
+89 -15
View File
@@ -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<void> {
const stored: StoredSession = {
everydayPrivateKeyHex: bytesToHex(data.everydayPrivateKey),
interface EncryptedSession {
v: 2;
iv: string;
ciphertext: string;
}
interface DeviceKeyStore {
getOrCreateDeviceKey(key: string): Promise<CryptoKey>;
deleteDeviceKey(key: string): Promise<void>;
}
type SessionStore = KeyValueStore & Partial<DeviceKeyStore>;
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<void> {
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<CachedSessionData | null> {
export async function loadCachedSession(store: SessionStore): Promise<CachedSessionData | null> {
let raw: string | undefined;
try {
raw = await store.get(SESSION_KEY);
@@ -62,7 +117,20 @@ export async function loadCachedSession(store: KeyValueStore): Promise<CachedSes
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<StoredSession>;
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<PlainSession>;
if (
typeof parsed.everydayPrivateKeyHex !== "string" ||
typeof parsed.accountId !== "string" ||
@@ -73,7 +141,10 @@ export async function loadCachedSession(store: KeyValueStore): Promise<CachedSes
return null;
}
return {
everydayPrivateKey: hexToBytes(parsed.everydayPrivateKeyHex),
everydayPrivateKey: Uint8Array.from(
parsed.everydayPrivateKeyHex.match(/.{1,2}/g) || [],
(pair) => Number.parseInt(pair, 16)
),
accountId: parsed.accountId,
recoveryPublicKey: parsed.recoveryPublicKey,
activeCredentialEvent: parsed.activeCredentialEvent,
@@ -85,9 +156,12 @@ export async function loadCachedSession(store: KeyValueStore): Promise<CachedSes
}
}
export async function clearCachedSession(store: KeyValueStore): Promise<void> {
export async function clearCachedSession(store: SessionStore): Promise<void> {
try {
await store.delete(SESSION_KEY);
if (typeof store.deleteDeviceKey === "function") {
await store.deleteDeviceKey(SESSION_DEVICE_KEY);
}
} catch {
// ignore
}