208 lines
11 KiB
TypeScript
208 lines
11 KiB
TypeScript
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<ReturnType<typeof decryptWallet>>;
|
|
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<IDBDatabase> {
|
|
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<Stored | undefined> {
|
|
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<unknown> = Promise.resolve();
|
|
function withStorage<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T> {
|
|
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<T> => {
|
|
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<WalletStatus> {
|
|
if (await getWallet()) return 'unlocked';
|
|
const value = await read(); return value ? isVault(value) ? 'locked' : 'legacy' : 'empty';
|
|
}
|
|
export async function getWallet(): Promise<Wallet | undefined> {
|
|
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<Wallet> {
|
|
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<Wallet> {
|
|
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<Wallet> {
|
|
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<string> {
|
|
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<WalletBackup> {
|
|
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<Wallet> {
|
|
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);
|
|
}
|