import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts'; import { IS_MAINNET } from '../shared/network.ts'; export type Wallet = { publicKey: string; address: string }; type Keys = Awaited>; type Vault = { format: 'wikideck-local-vault'; version: 1; revision: string; backup: WalletBackup }; type Stored = Vault | Keys; export type WalletStatus = 'empty' | 'legacy' | 'locked' | 'unlocked'; export const WALLET_IDLE_MS = 5 * 60_000; const ABSOLUTE_MS = 4 * 60 * 60_000; let session: { keys: Keys; handle: Wallet; revision: string; opened: number; touched: number } | undefined; let epoch = 0; const listeners = new Set<() => void>(); const isVault = (value: Stored): value is Vault => 'format' in value && value.format === 'wikideck-local-vault'; const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2'; const LOCK = `${DATABASE}:identity`; const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes))); function openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE, 1); let blocked = false; request.onupgradeneeded = () => request.result.createObjectStore('keys'); request.onsuccess = () => { if (blocked) { request.result.close(); return; } request.result.onversionchange = () => request.result.close(); resolve(request.result); }; request.onblocked = () => { blocked = true; reject(new Error('Close other Wikideck tabs to enable wallet storage, then retry.')); }; request.onerror = () => reject(request.error ?? new Error('Enable browser storage to use your local wallet.')); }); } // Resolve only on transaction commit, not a request's earlier success event. function identity(db: IDBDatabase, replacement?: Vault, expected?: Stored): Promise { return new Promise((resolve, reject) => { const tx = db.transaction('keys', replacement ? 'readwrite' : 'readonly'); const store = tx.objectStore('keys'); let result: Stored | undefined; let conflict: Error | undefined; const request = store.get('identity'); request.onsuccess = () => { try { result = request.result as Stored | undefined; if (replacement) { if (!same(result, expected)) { conflict = new Error('Wallet changed in another tab. Reload and review before replacing.'); tx.abort(); return; } store.put(replacement, 'identity'); result = replacement; } } catch (error) { tx.abort(); reject(error); } }; tx.oncomplete = () => resolve(result); tx.onabort = () => reject(conflict ?? tx.error ?? new DOMException('Wallet transaction aborted.', 'AbortError')); tx.onerror = () => reject(tx.error ?? new Error('Could not save the wallet.')); }); } // Serialize this tab even without Web Locks. Atomic create-only transactions // prevent first-use races across tabs without navigator.locks as well. let queue: Promise = Promise.resolve(); function withStorage(operation: (db: IDBDatabase) => Promise): Promise { const run = async () => { for (let attempt = 0; ; attempt++) { let db: IDBDatabase | undefined; try { db = await openDatabase(); return await operation(db); } catch (error) { if (attempt >= 2 || !(error instanceof DOMException) || !['AbortError', 'UnknownError', 'InvalidStateError'].includes(error.name)) throw error; } finally { db?.close(); } } }; const result = queue.then(async (): Promise => { if (typeof navigator !== 'undefined' && navigator.locks) { return await navigator.locks.request(LOCK, run); } return await run(); }); queue = result.catch(() => undefined); return result; } function same(a: Stored | undefined, b: Stored | undefined): boolean { if (!a || !b) return a === b; return isVault(a) && isVault(b) ? JSON.stringify(a) === JSON.stringify(b) : !isVault(a) && !isVault(b) && a.publicKey === b.publicKey && a.address === b.address; } const read = () => withStorage(db => identity(db)); let channel: BroadcastChannel | undefined; function notify() { for (const fn of listeners) fn(); } export function lockWallet(broadcast = true): void { epoch++; session = undefined; if (broadcast) channel?.postMessage('lock'); notify(); } function validSession() { if (session && (Date.now() - session.touched >= WALLET_IDLE_MS || Date.now() - session.opened >= ABSOLUTE_MS)) lockWallet(); return session; } export function touchWallet(): void { const current = validSession(); if (current) current.touched = Date.now(); } export function subscribeWallet(fn: () => void): () => void { listeners.add(fn); return () => { listeners.delete(fn); }; } export function watchWalletLifecycle(): () => void { const activity = (e: Event) => { if (e.isTrusted) touchWallet(); }; const visibility = () => { if (document.visibilityState !== 'visible') lockWallet(); }; const hide = () => lockWallet(); if (typeof BroadcastChannel !== 'undefined') { channel = new BroadcastChannel(`${DATABASE}:lock`); channel.onmessage = () => lockWallet(false); } document.addEventListener('pointerdown',activity); document.addEventListener('keydown',activity); document.addEventListener('visibilitychange',visibility); window.addEventListener('pagehide',hide); const timer = setInterval(() => { validSession(); },1000); return () => { clearInterval(timer); document.removeEventListener('pointerdown',activity); document.removeEventListener('keydown',activity); document.removeEventListener('visibilitychange',visibility); window.removeEventListener('pagehide',hide); channel?.close(); channel = undefined; lockWallet(false); }; } export async function walletStatus(): Promise { if (await getWallet()) return 'unlocked'; const value = await read(); return value ? isVault(value) ? 'locked' : 'legacy' : 'empty'; } export async function getWallet(): Promise { const current = validSession(); if (!current) return undefined; const saved = await read(); if (session !== current) return undefined; // An old async read must not lock a newer unlock session. if (!saved || !isVault(saved) || saved.revision !== current.revision) { lockWallet(); return undefined; } return validSession() === current ? current.handle : undefined; } function requireSession(wallet: Wallet) { const current = validSession(); if (!current || current.handle !== wallet) throw new Error('Wallet is locked or changed. Unlock and review again.'); return current; } async function install(keys: Keys, backup: WalletBackup, expected: Stored | undefined, token: number): Promise { if (token !== epoch) throw new Error('Wallet operation cancelled by locking.'); const vault: Vault = { format:'wikideck-local-vault', version:1, revision:crypto.randomUUID(), backup }; // CAS inside a single readwrite transaction protects tabs even without Web Locks. await withStorage(db => identity(db,vault,expected)); if (token !== epoch) throw new Error('Wallet saved encrypted but remains locked. Unlock to continue.'); channel?.postMessage('lock'); const handle = Object.freeze({address:keys.address,publicKey:keys.publicKey}); session = {keys,handle,revision:vault.revision,opened:Date.now(),touched:Date.now()}; notify(); return handle; } export async function protectWallet(password: string): Promise { const token = ++epoch; session = undefined; notify(); const existing = await read(); if (existing && isVault(existing)) throw new Error('An encrypted wallet already exists. Unlock or explicitly recover a backup.'); let keys: Keys; if (existing) keys = existing; else { const pair = await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']); const spki = await crypto.subtle.exportKey('spki',pair.publicKey); const digest = new Uint8Array(await crypto.subtle.digest('SHA-256',spki)); keys = {privateKey:pair.privateKey,publicKey:encode(spki),address:Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('')}; } const backup = await encryptWallet(keys.privateKey,keys.publicKey,password); const verified = await decryptWallet(backup,password); if (existing && verified.address !== existing.address) throw new Error('Legacy wallet identity mismatch; original record preserved.'); return install(verified,backup,existing,token); } export async function unlockWallet(password: string): Promise { const token = ++epoch; session = undefined; notify(); const saved = await read(); if (!saved || !isVault(saved) || saved.version !== 1) throw new Error('Create or encrypt the local wallet first.'); const keys = await decryptWallet(saved.backup,password); const latest = await read(); if (token !== epoch || !same(latest,saved)) throw new Error('Wallet changed or was locked while unlocking.'); const handle = Object.freeze({address:keys.address,publicKey:keys.publicKey}); session = {keys,handle,revision:saved.revision,opened:Date.now(),touched:Date.now()}; notify(); return handle; } export async function sign(wallet: Wallet, message: string): Promise { const current = requireSession(wallet), token = epoch; if (!await getWallet() || token !== epoch) throw new Error('Wallet locked or storage changed before signing.'); const signature = encode(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},current.keys.privateKey,new TextEncoder().encode(message))); if (!await getWallet() || token !== epoch || requireSession(wallet) !== current) throw new Error('Wallet locked during signing. Signature discarded.'); return signature; } export async function exportWallet(wallet: Wallet, password: string): Promise { const current = requireSession(wallet), token = epoch; if (!await getWallet()) throw new Error('Wallet storage changed.'); const backup = await encryptWallet(current.keys.privateKey,current.keys.publicKey,password); if (!await getWallet() || token !== epoch || requireSession(wallet) !== current) throw new Error('Wallet locked during export.'); return backup; } export async function restoreWallet(backup: unknown, password: string, localPassword: string, replace = false): Promise { const token = ++epoch; session = undefined; notify(); const existing = await read(); if (existing && !replace) throw new Error('Explicit replacement confirmation required for the stored wallet.'); const keys = await decryptWallet(backup,password); const encrypted = await encryptWallet(keys.privateKey,keys.publicKey,localPassword); await decryptWallet(encrypted,localPassword); return install(keys,encrypted,existing,token); }