encryption

This commit is contained in:
2026-10-20 05:27:18 -04:00
parent 3cee89d023
commit 4080114085
10 changed files with 324 additions and 72 deletions
+6 -2
View File
@@ -5,7 +5,7 @@ import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Chec
import { rarity } from '../shared/protocol.ts';
import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { getWallet, sign, type Wallet } from './wallet.ts';
import { getWallet, sign, subscribeWallet, watchWalletLifecycle, type Wallet } from './wallet.ts';
import { Card, samples, imageCredit, type CardData } from './Card.tsx';
import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
@@ -79,10 +79,14 @@ export default function App() {
};
useEffect(() => {
let active = true;
const stopLifecycle = watchWalletLifecycle();
const unsubscribe = subscribeWallet(() => {
void getWallet().then(value => { if(active) {setWallet(value);setRecipient('');setTransferError('');} }).catch(()=>{if(active)setWallet(undefined);});
});
getWallet().then(value => { if (active) setWallet(value); }).catch(e => { if (active) setError(message(e)); });
void refresh().catch(() => {});
const timer = setInterval(() => { void refresh().catch(() => {}); }, 8000);
return () => { active = false; clearInterval(timer); refreshVersion.current++; };
return () => { active = false; unsubscribe(); stopLifecycle(); clearInterval(timer); refreshVersion.current++; };
}, []);
useEffect(() => {
if (toast) { const timer = setTimeout(() => setToast(''), 8000); return () => clearTimeout(timer); }
+4 -7
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { ArrowLeftRight, Check, Clipboard, LoaderCircle, RefreshCw, Send } from 'lucide-react';
import { CONSENSUS, NETWORK, isNftTrade, nftTradeMessage, type NftTrade, type TestnetDeck, type TestnetSnapshot } from '../shared/testnet.ts';
import { tradeOfferSchema, type TradeOffer } from '../shared/trade-offer.ts';
import type { Wallet } from './wallet.ts';
import { sign, type Wallet } from './wallet.ts';
import './trade.css';
import { StateClient } from '../shared/rpc-client.ts';
const stateClient = new StateClient();
@@ -148,9 +148,7 @@ export function TradeDesk({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
});
const terms = { network: NETWORK, chainId: fresh.chainId, proposer: wallet.address,
counterparty: remoteDeck.address, offered: offeredLegs, requested: requestedLegs };
const proposerSignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
new TextEncoder().encode(nftTradeMessage(terms)));
const signatureText = btoa(String.fromCharCode(...new Uint8Array(proposerSignature)));
const signatureText = await sign(wallet,nftTradeMessage(terms));
const offer = { format: 'wikideck-trade-offer' as const, version: 1 as const, ...terms,
proposerPublicKey: wallet.publicKey, proposerSignature: signatureText };
setOfferText(JSON.stringify(offer, null, 2));
@@ -180,13 +178,12 @@ export function TradeDesk({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
if (!block || !ownsFrom(fresh, leg.cardHash, wallet.address) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('One of your requested cards is no longer owned, current, or mature.');
}
const counterpartySignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
new TextEncoder().encode(nftTradeMessage(offer)));
const counterpartySignature = await sign(wallet,nftTradeMessage(offer));
const accepted: NftTrade = { type: 'trade', network: offer.network, chainId: offer.chainId,
proposer: offer.proposer, counterparty: offer.counterparty, offered: offer.offered, requested: offer.requested,
proposerPublicKey: offer.proposerPublicKey, proposerSignature: offer.proposerSignature,
counterpartyPublicKey: wallet.publicKey,
counterpartySignature: btoa(String.fromCharCode(...new Uint8Array(counterpartySignature))) };
counterpartySignature };
const result = await api<{ status: string; message: string }>('trades', accepted);
onNotice(result.message);
setOfferText(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
+54 -12
View File
@@ -1,5 +1,7 @@
import { useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, type Wallet } from './wallet.ts';
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, protectWallet, unlockWallet, lockWallet, walletStatus, subscribeWallet,
type WalletStatus, type Wallet } from './wallet.ts';
import { NETWORK } from '../shared/network.ts';
type Props = {
wallet: Wallet | undefined;
@@ -15,8 +17,33 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
const [file, setFile] = useState<File>();
const [replace, setReplace] = useState(false);
const [busy, setBusy] = useState(false);
const [status,setStatus] = useState<WalletStatus>();
const [localPassword,setLocalPassword] = useState('');
const [localConfirmation,setLocalConfirmation] = useState('');
const [error,setError] = useState('');
const inProgress = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(()=>{
let active=true;
const load=()=>{void walletStatus().then(s=>{if(active)setStatus(s);}).catch(()=>{if(active)setError('Wallet storage unavailable.');});};
load();const unsubscribe=subscribeWallet(()=>{
setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');load();
});
const clear=()=>{if(document.visibilityState!=='visible'){setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}};
document.addEventListener('visibilitychange',clear);
return()=>{active=false;unsubscribe();document.removeEventListener('visibilitychange',clear);};
},[]);
async function open(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); if(inProgress.current||!status)return;
if(status!=='locked'&&(localPassword.length<12||localPassword!==localConfirmation)) {setError('Choose at least 12 characters and confirm the local password.');return;}
inProgress.current=true;setBusy(true);setError('');
try {
const opened= status==='locked'?await unlockWallet(localPassword):await protectWallet(localPassword);
onRestore(opened);onNotice('Wallet unlocked in this tab. Locking or leaving the tab clears its signing session.');
} catch(e) {setError(e instanceof Error?e.message:'Could not unlock wallet.');}
finally {setLocalPassword('');setLocalConfirmation('');inProgress.current=false;setBusy(false);setStatus(await walletStatus());}
}
async function download(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -32,7 +59,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = `wikideck-testnet-wallet-${wallet.address}.json`;
link.download = `${NETWORK}-wallet-${wallet.address}.json`;
document.body.append(link);
try { link.click(); }
finally {
@@ -57,18 +84,19 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
onNotice('Choose a nonempty wallet backup JSON file no larger than 32 KB.');
return;
}
if (wallet && !replace) {
if (status !== 'empty' && !replace) {
onNotice('Confirm replacement of the active wallet first. Back it up before continuing.');
return;
}
inProgress.current = true;
setBusy(true);
try {
if(localPassword.length<12||localPassword!==localConfirmation)throw new Error('Choose and confirm a local encryption password of at least 12 characters.');
// Size checked before reading; the shared decoder validates all JSON fields.
const backup: unknown = JSON.parse(await file.text());
const recovered = await restoreWallet(backup, importPassword);
const recovered = await restoreWallet(backup, importPassword, localPassword, replace);
onRestore(recovered);
onNotice('Wallet recovered and saved locally. Other open tabs should reload before signing.');
onNotice('Wallet recovered into encrypted local storage. Other tabs are locked; keep the backup safe.');
setFile(undefined);
setReplace(false);
if (fileInput.current) fileInput.current.value = '';
@@ -76,6 +104,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
onNotice('Recovery failed. Check the password, backup format and network, and browser storage. The backup must be intact.');
} finally {
setImportPassword('');
setLocalPassword('');setLocalConfirmation('');
inProgress.current = false;
setBusy(false);
}
@@ -83,10 +112,19 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-6, separate from your old demo wallet.</p>
<p>The exportable private key is stored locally in this browser’s IndexedDB, not encrypted by this password.
Scripts running on this origin or someone with access to your browser profile may access it.
Clearing site data without a backup loses access.</p>
<p>Local P-256 wallet for {NETWORK}. Status: <strong>{status??'checking storage'}</strong>.</p>
<p>New and migrated keys are encrypted in IndexedDB. Unlocking keeps an exportable signing key only in this tab’s memory.
Lock manually, after five idle minutes, after four hours, or automatically when the tab is hidden.
Encryption does not protect an unlocked page from malicious same-origin scripts or extensions. Clearing site data without a backup loses access.</p>
{status==='legacy'&&<p role="alert"><strong>Existing unencrypted wallet detected.</strong> Encrypt the same identity below; migration never generates a replacement key. The original record is replaced only after encrypted storage commits. Existing disk copies/backups may still contain the old plaintext key.</p>}
{error&&<p role="alert" className="transfer-error">{error}</p>}
{wallet?<button type="button" onClick={()=>{lockWallet();setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}} disabled={busy}>Lock wallet now</button>:<form onSubmit={open}><fieldset disabled={busy||!status}>
<legend>{status==='locked'?'Unlock encrypted local wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</legend>
<label htmlFor={`${id}-local-password`}>Local wallet password {status!=='locked'?'(at least 12 characters)':''}</label>
<input id={`${id}-local-password`} type="password" autoComplete={status==='locked'?'current-password':'new-password'} required maxLength={1024} minLength={status==='locked'?1:12} value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
{status!=='locked'&&<><label htmlFor={`${id}-local-confirm`}>Confirm local password</label><input id={`${id}-local-confirm`} type="password" autoComplete="new-password" required minLength={12} maxLength={1024} value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/></>}
<button type="submit">{status==='locked'?'Unlock wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</button>
</fieldset></form>}
<p>Backup encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
by this panel. Public addresses, public keys and signatures may be shared during normal wallet use.</p>
<p><strong>Forgotten backup passwords cannot be reset or recovered.</strong> Keep the encrypted file and a strong,
@@ -112,11 +150,15 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
<label htmlFor={`${id}-import-password`}>Backup password</label>
<input id={`${id}-import-password`} type="password" autoComplete="off" required maxLength={1024}
value={importPassword} onChange={event => setImportPassword(event.target.value)} />
{wallet && <label>
<label htmlFor={`${id}-recover-local`}>New local encryption password (at least 12 characters)</label>
<input id={`${id}-recover-local`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
<label htmlFor={`${id}-recover-confirm`}>Confirm new local password</label>
<input id={`${id}-recover-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/>
{status!=='empty' && <label>
<input type="checkbox" required checked={replace} onChange={event => setReplace(event.target.checked)} />
I have backed up my active wallet and confirm replacing it. Recovery does not transfer its assets.
</label>}
<button type="submit" disabled={!file || (!!wallet && !replace)}>Recover and save wallet</button>
<button type="submit" disabled={!status||!file || (status!=='empty' && !replace)}>Recover and save encrypted wallet</button>
</fieldset>
</form>
</section>;
+131 -29
View File
@@ -1,7 +1,17 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { IS_MAINNET } from '../shared/network.ts';
export type Wallet = { privateKey: CryptoKey; publicKey: string; address: string };
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)));
@@ -25,16 +35,18 @@ function openDatabase(): Promise<IDBDatabase> {
}
// Resolve only on transaction commit, not a request's earlier success event.
function identity(db: IDBDatabase, replacement?: Wallet, createOnly = false): Promise<Wallet | undefined> {
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: Wallet | undefined;
let result: Stored | undefined;
let conflict: Error | undefined;
const request = store.get('identity');
request.onsuccess = () => {
try {
result = request.result as Wallet | undefined;
if (replacement && (!createOnly || result === undefined)) {
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;
}
@@ -44,7 +56,7 @@ function identity(db: IDBDatabase, replacement?: Wallet, createOnly = false): Pr
}
};
tx.oncomplete = () => resolve(result);
tx.onabort = () => reject(tx.error ?? new DOMException('Wallet transaction aborted.', 'AbortError'));
tx.onabort = () => reject(conflict ?? tx.error ?? new DOMException('Wallet transaction aborted.', 'AbortError'));
tx.onerror = () => reject(tx.error ?? new Error('Could not save the wallet.'));
});
}
@@ -75,31 +87,121 @@ function withStorage<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T>
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: 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))!;
});
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> {
return encode(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey, new TextEncoder().encode(message)));
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 function exportWallet(wallet: Wallet, password: string): Promise<WalletBackup> {
return encryptWallet(wallet.privateKey, wallet.publicKey, password);
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): Promise<Wallet> {
const wallet = await decryptWallet(backup, password);
await withStorage(db => identity(db, wallet));
return wallet;
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);
}