168 lines
11 KiB
TypeScript
168 lines
11 KiB
TypeScript
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;
|
||
onRestore: (wallet: Wallet) => void;
|
||
onNotice: (message: string) => void;
|
||
};
|
||
|
||
export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
|
||
const id = useId();
|
||
const [password, setPassword] = useState('');
|
||
const [confirmation, setConfirmation] = useState('');
|
||
const [importPassword, setImportPassword] = useState('');
|
||
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();
|
||
if (inProgress.current || !wallet) return;
|
||
if (password.length < 12 || password.length > 1024 || password !== confirmation) {
|
||
onNotice('Use a password of 12–1024 characters and enter it identically twice.');
|
||
return;
|
||
}
|
||
inProgress.current = true;
|
||
setBusy(true);
|
||
try {
|
||
const backup = await exportWallet(wallet, password);
|
||
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${NETWORK}-wallet-${wallet.address}.json`;
|
||
document.body.append(link);
|
||
try { link.click(); }
|
||
finally {
|
||
link.remove();
|
||
// Let the browser start consuming the download before revocation.
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
onNotice('Encrypted backup download requested. Keep the file and password safe; test recovery before relying on it.');
|
||
} catch { onNotice('Could not export the wallet. Check browser support and try again.'); }
|
||
finally {
|
||
setPassword('');
|
||
setConfirmation('');
|
||
inProgress.current = false;
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function recover(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (inProgress.current) return;
|
||
if (!file || file.size === 0 || file.size > 32 * 1024) {
|
||
onNotice('Choose a nonempty wallet backup JSON file no larger than 32 KB.');
|
||
return;
|
||
}
|
||
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, localPassword, replace);
|
||
onRestore(recovered);
|
||
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 = '';
|
||
} catch {
|
||
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);
|
||
}
|
||
}
|
||
|
||
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
|
||
<h2 id={`${id}-title`}>Wallet backup & recovery</h2>
|
||
<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>Backups are bound to this exact chain ID; a backup for a different genesis cannot be restored here. 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,
|
||
unique password safe and separate. Anyone with both can control this wallet. Downloading does not guarantee the file was saved.</p>
|
||
<form onSubmit={download}>
|
||
<fieldset disabled={busy || !wallet}>
|
||
<legend>Download encrypted backup</legend>
|
||
<label htmlFor={`${id}-password`}>Backup password (at least 12 characters)</label>
|
||
<input id={`${id}-password`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
|
||
required value={password} onChange={event => setPassword(event.target.value)} />
|
||
<label htmlFor={`${id}-confirm`}>Confirm backup password</label>
|
||
<input id={`${id}-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
|
||
required value={confirmation} onChange={event => setConfirmation(event.target.value)} />
|
||
<button type="submit">Download encrypted backup</button>
|
||
</fieldset>
|
||
</form>
|
||
<form onSubmit={recover}>
|
||
<fieldset disabled={busy}>
|
||
<legend>Recover from a local backup</legend>
|
||
<label htmlFor={`${id}-file`}>Backup JSON file (maximum 32 KB)</label>
|
||
<input ref={fileInput} id={`${id}-file`} type="file" accept=".json,application/json" required
|
||
onChange={event => { setFile(event.target.files?.[0]); setReplace(false); }} />
|
||
<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)} />
|
||
<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={!status||!file || (status!=='empty' && !replace)}>Recover and save encrypted wallet</button>
|
||
</fieldset>
|
||
</form>
|
||
</section>;
|
||
}
|
||
|
||
export default WalletPanel;
|