mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
fix(widget): encrypt persistent session cache
This commit is contained in:
1 parent
fe9a7afde2
commit
e9a5a41ff7
3 files changed
+208
-16
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user