/** * Persistent nonce cache using IndexedDB (browser) and localStorage. * Implements NonceCacheLike interface for use with verifyLoginProof. */ export class PersistentNonceCache { ttlMs; storageKey; useIndexedDB; db = null; constructor(ttlMs = 3600000, storageKey = 'nonce_cache') { this.ttlMs = ttlMs; this.storageKey = storageKey; this.useIndexedDB = typeof window !== 'undefined' && 'indexedDB' in window; } /** * Initialize IndexedDB if available. */ async init() { if (!this.useIndexedDB) { return; } return new Promise((resolve, reject) => { const request = indexedDB.open('NonceCacheDB', 1); request.onerror = () => { reject(new Error('Failed to open IndexedDB')); }; request.onsuccess = () => { this.db = request.result; resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('nonces')) { const store = db.createObjectStore('nonces', { keyPath: 'nonce' }); store.createIndex('timestamp', 'timestamp', { unique: false }); } }; }); } /** * Check if nonce is valid (not seen within TTL). Records nonce on success. * Uses localStorage for synchronous access (required by NonceCacheLike interface). * Also persists to IndexedDB in background if available. */ isValid(nonce, timestamp) { const result = this.isValidSync(nonce, timestamp); // Persist to IndexedDB in background if available if (this.useIndexedDB && this.db !== null) { void this.persistToIndexedDB(nonce, timestamp); } return result; } /** * Synchronous validation using localStorage (primary storage). */ isValidSync(nonce, timestamp) { const now = Date.now(); const stored = localStorage.getItem(`${this.storageKey}_${nonce}`); if (stored !== null) { const storedTimestamp = parseInt(stored, 10); if (now - storedTimestamp < this.ttlMs) { return false; } localStorage.removeItem(`${this.storageKey}_${nonce}`); } localStorage.setItem(`${this.storageKey}_${nonce}`, timestamp.toString()); this.cleanupSync(now); return true; } /** * Persist nonce to IndexedDB in background (async, non-blocking). */ async persistToIndexedDB(nonce, timestamp) { if (this.db === null) { return; } try { const transaction = this.db.transaction(['nonces'], 'readwrite'); const store = transaction.objectStore('nonces'); await new Promise((resolve, reject) => { const request = store.put({ nonce, timestamp }); request.onsuccess = () => { resolve(); }; request.onerror = () => { reject(request.error); }; }); } catch (error) { // IndexedDB errors are non-critical, localStorage is the primary storage console.warn('Failed to persist nonce to IndexedDB:', error); } } /** * Cleanup expired entries (localStorage and IndexedDB). */ cleanupSync(now) { const keys = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key !== null && key.startsWith(`${this.storageKey}_`)) { keys.push(key); } } for (const key of keys) { const stored = localStorage.getItem(key); if (stored !== null) { const storedTimestamp = parseInt(stored, 10); if (now - storedTimestamp >= this.ttlMs) { localStorage.removeItem(key); } } } // Cleanup IndexedDB in background if (this.useIndexedDB && this.db !== null) { void this.cleanupIndexedDB(now); } } /** * Cleanup expired entries from IndexedDB (async, non-blocking). */ async cleanupIndexedDB(now) { if (this.db === null) { return; } try { const transaction = this.db.transaction(['nonces'], 'readwrite'); const store = transaction.objectStore('nonces'); const index = store.index('timestamp'); const range = IDBKeyRange.upperBound(now - this.ttlMs); await new Promise((resolve, reject) => { const request = index.openCursor(range); request.onsuccess = (event) => { const cursor = event.target.result; if (cursor !== null && cursor !== undefined) { cursor.delete(); cursor.continue(); } else { resolve(); } }; request.onerror = () => { reject(request.error); }; }); } catch (error) { // IndexedDB errors are non-critical console.warn('Failed to cleanup IndexedDB:', error); } } /** * Clear all entries. */ clear() { if (this.useIndexedDB && this.db !== null) { const transaction = this.db.transaction(['nonces'], 'readwrite'); const store = transaction.objectStore('nonces'); store.clear(); } const keys = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key !== null && key.startsWith(`${this.storageKey}_`)) { keys.push(key); } } for (const key of keys) { localStorage.removeItem(key); } } }