/** * @bytelyst/secure-storage-web — Encrypted web storage * * IndexedDB-backed key-value storage with a non-extractable AES-256-GCM * CryptoKey managed by Web Crypto. The encryption key never leaves the * browser's crypto subsystem. * * Falls back to localStorage if IndexedDB or SubtleCrypto is unavailable. */ const DB_NAME = 'bytelyst_secure_storage'; const DB_VERSION = 1; const KEY_STORE = 'crypto_keys'; const DATA_STORE = 'encrypted_data'; const MASTER_KEY_ID = '__master_key__'; const ALGORITHM = 'AES-GCM'; const KEY_SIZE = 256; const IV_BYTES = 12; const TAG_BITS = 128; // ── IndexedDB helpers ─────────────────────────────── function openDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(KEY_STORE)) { db.createObjectStore(KEY_STORE); } if (!db.objectStoreNames.contains(DATA_STORE)) { db.createObjectStore(DATA_STORE); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function idbGet(db: IDBDatabase, store: string, key: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readonly'); const req = tx.objectStore(store).get(key); req.onsuccess = () => resolve(req.result as T | undefined); req.onerror = () => reject(req.error); }); } function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readwrite'); const req = tx.objectStore(store).put(value, key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } function idbDelete(db: IDBDatabase, store: string, key: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readwrite'); const req = tx.objectStore(store).delete(key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } function idbClear(db: IDBDatabase, store: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readwrite'); const req = tx.objectStore(store).clear(); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } function idbGetAllKeys(db: IDBDatabase, store: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readonly'); const req = tx.objectStore(store).getAllKeys(); req.onsuccess = () => resolve(req.result as string[]); req.onerror = () => reject(req.error); }); } // ── Stored encrypted value format ─────────────────── interface StoredValue { /** Ciphertext bytes. */ ct: ArrayBuffer; /** Initialization vector. */ iv: ArrayBuffer; } // ── SecureStorage class ───────────────────────────── /** * Encrypted key-value storage backed by IndexedDB + Web Crypto. * * The AES-256-GCM master key is generated once and stored as a * **non-extractable** CryptoKey in IndexedDB. Values are encrypted * before storage and decrypted on retrieval. The raw key material * never leaves the browser's crypto subsystem. * * Falls back to plaintext localStorage if IndexedDB or SubtleCrypto * is not available (e.g., legacy browsers, server-side rendering). * * @example * ```typescript * const storage = new SecureStorage('com.myapp'); * await storage.set('auth_token', 'eyJhbGci...'); * const token = await storage.get('auth_token'); * await storage.delete('auth_token'); * await storage.clear(); * ``` */ export class SecureStorage { private readonly namespace: string; private db: IDBDatabase | null = null; private masterKey: CryptoKey | null = null; private readonly fallback: boolean; constructor(namespace: string) { this.namespace = namespace; this.fallback = !SecureStorage.isSupported(); } /** Check if IndexedDB + SubtleCrypto are available. */ static isSupported(): boolean { return ( typeof indexedDB !== 'undefined' && typeof globalThis !== 'undefined' && !!globalThis.crypto?.subtle ); } // ── Public API ────────────────────────────────── /** Store an encrypted value. */ async set(key: string, value: string): Promise { if (this.fallback) { localStorage.setItem(this.ns(key), value); return; } const { db, masterKey } = await this.ensureReady(); const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); const encoded = new TextEncoder().encode(value); const ciphertext = await crypto.subtle.encrypt( { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS }, masterKey, encoded.buffer as ArrayBuffer ); const stored: StoredValue = { ct: ciphertext, iv: iv.buffer as ArrayBuffer }; await idbPut(db, DATA_STORE, this.ns(key), stored); } /** Retrieve and decrypt a value. Returns `null` if not found. */ async get(key: string): Promise { if (this.fallback) { return localStorage.getItem(this.ns(key)); } const { db, masterKey } = await this.ensureReady(); const stored = await idbGet(db, DATA_STORE, this.ns(key)); if (!stored) return null; const plaintext = await crypto.subtle.decrypt( { name: ALGORITHM, iv: stored.iv, tagLength: TAG_BITS }, masterKey, stored.ct ); return new TextDecoder().decode(plaintext); } /** Delete a stored value. */ async delete(key: string): Promise { if (this.fallback) { localStorage.removeItem(this.ns(key)); return; } const { db } = await this.ensureReady(); await idbDelete(db, DATA_STORE, this.ns(key)); } /** Clear all stored values (keeps the master key). */ async clear(): Promise { if (this.fallback) { const prefix = this.ns(''); const toRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) toRemove.push(k); } toRemove.forEach(k => localStorage.removeItem(k)); return; } const { db } = await this.ensureReady(); await idbClear(db, DATA_STORE); } /** Check if a key exists. */ async has(key: string): Promise { if (this.fallback) { return localStorage.getItem(this.ns(key)) !== null; } const { db } = await this.ensureReady(); const stored = await idbGet(db, DATA_STORE, this.ns(key)); return stored !== undefined; } /** List all stored keys (without namespace prefix). */ async keys(): Promise { if (this.fallback) { const prefix = this.ns(''); const result: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) result.push(k.slice(prefix.length)); } return result; } const { db } = await this.ensureReady(); const allKeys = await idbGetAllKeys(db, DATA_STORE); const prefix = this.ns(''); return allKeys.filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); } // ── Internal ──────────────────────────────────── private ns(key: string): string { return `${this.namespace}:${key}`; } private async ensureReady(): Promise<{ db: IDBDatabase; masterKey: CryptoKey }> { if (this.db && this.masterKey) { return { db: this.db, masterKey: this.masterKey }; } const db = await openDb(); this.db = db; // Try to load existing master key let masterKey = await idbGet(db, KEY_STORE, MASTER_KEY_ID); if (!masterKey) { // Generate a new non-extractable master key masterKey = await crypto.subtle.generateKey( { name: ALGORITHM, length: KEY_SIZE }, false, // non-extractable — key material never leaves crypto subsystem ['encrypt', 'decrypt'] ); await idbPut(db, KEY_STORE, MASTER_KEY_ID, masterKey); } this.masterKey = masterKey; return { db, masterKey }; } }