This commit is contained in:
2026-10-19 15:42:39 -04:00
parent 6d3f470922
commit 4b47386853
218 changed files with 25348 additions and 383 deletions
+93 -30
View File
@@ -1,41 +1,104 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
export type Wallet = { privateKey: CryptoKey; publicKey: string; address: string };
const DATABASE = 'wikideck-testnet-wallet-v2';
const LOCK = `${DATABASE}:identity`;
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
async function loadOrCreate(): Promise<Wallet> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('wikideck-wallet', 1);
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 = () => resolve(request.result);
request.onerror = () => reject(new Error('Enable browser storage to create your local wallet.'));
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.'));
});
try {
const saved = await new Promise<Wallet | undefined>((resolve, reject) => {
const req = db.transaction('keys').objectStore('keys').get('identity');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (saved) return saved;
const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
}
// Resolve only on transaction commit, not a request's earlier success event.
function identity(db: IDBDatabase, replacement?: Wallet, createOnly = false): Promise<Wallet | undefined> {
return new Promise((resolve, reject) => {
const tx = db.transaction('keys', replacement ? 'readwrite' : 'readonly');
const store = tx.objectStore('keys');
let result: Wallet | undefined;
const request = store.get('identity');
request.onsuccess = () => {
try {
result = request.result as Wallet | undefined;
if (replacement && (!createOnly || result === undefined)) {
store.put(replacement, 'identity');
result = replacement;
}
} catch (error) {
tx.abort();
reject(error);
}
};
tx.oncomplete = () => resolve(result);
tx.onabort = () => reject(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;
}
export function getWallet(): Promise<Wallet> {
return withStorage(async db => {
const saved = await identity(db);
if (saved !== undefined) return saved;
// Intentional exportability: IndexedDB is NOT password-encrypted. Only the
// separate portable backup is protected by the user's backup password.
const keys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const spki = await crypto.subtle.exportKey('spki', keys.publicKey);
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
const wallet = { privateKey: keys.privateKey, publicKey: encode(spki),
address: Array.from(digest, b => b.toString(16).padStart(2, '0')).join('') };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put(wallet, 'identity');
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return wallet;
} finally { db.close(); }
}
let pending: Promise<Wallet> | undefined;
export function getWallet(): Promise<Wallet> {
return pending ??= (async () => {
if (navigator.locks) return await navigator.locks.request('wikideck-wallet-init', loadOrCreate);
return loadOrCreate();
})().catch(error => { pending = undefined; throw error; });
const wallet: Wallet = { privateKey: keys.privateKey, publicKey: encode(spki),
address: Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('') };
return (await identity(db, wallet, true))!;
});
}
export async function sign(wallet: Wallet, message: string): Promise<string> {
return encode(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey, new TextEncoder().encode(message)));
}
export function exportWallet(wallet: Wallet, password: string): Promise<WalletBackup> {
return encryptWallet(wallet.privateKey, wallet.publicKey, password);
}
export async function restoreWallet(backup: unknown, password: string): Promise<Wallet> {
const wallet = await decryptWallet(backup, password);
await withStorage(db => identity(db, wallet));
return wallet;
}