encryption
This commit is contained in:
+6
-2
@@ -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
@@ -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
@@ -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 & 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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user