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
+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>;