encryption
This commit is contained in:
@@ -47,9 +47,9 @@ The node listens at **http://127.0.0.1:3001** by default. For development, `npm
|
||||
|
||||
## Wallets and backups
|
||||
|
||||
The browser creates a local, recoverable P-256 wallet. Open the wallet panel and use **Wallet backup & recovery** to download an encrypted backup. Choose a unique password of at least 12 characters, keep the file outside the repository, and store the password separately. Test recovery in another browser profile before relying on the backup.
|
||||
Open the wallet panel to explicitly create an encrypted local P-256 wallet, unlock an existing vault, or encrypt a legacy plaintext wallet without changing its identity. Nothing is silently generated or unlocked at startup. Choose a strong local password of at least 12 characters. **Wallet backup & recovery** downloads a separately password-encrypted portable backup; keep the file outside the repository and its password separately, and test recovery in another browser profile.
|
||||
|
||||
The password encrypts the downloaded backup; it does **not** encrypt the key stored in browser IndexedDB. Same-origin scripts and access to the browser profile may expose that key. Clearing site data without a usable backup loses access. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. A restored wallet controls the same address; it does not move assets between chains.
|
||||
New/migrated local vaults encrypt the key in IndexedDB; the unlocked key remains exportable in tab memory for signing and backup. Manual locking, five-minute inactivity, a four-hour absolute lifetime, reload, or hiding/leaving the tab ends the session. Same-origin compromise can still capture passwords or abuse an unlocked signer. Forgotten local passwords cannot be reset: recover from a usable backup. Legacy disk copies may retain old plaintext keys even after migration. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. Recovery does not move assets between chains. See [encrypted wallet lifecycle and remaining risks](docs/wallet-security.md).
|
||||
|
||||
The current testnet wallet is separate from any wallet used by an older demo. There is no automatic migration. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
[Stage-1 node resource controls and local load tooling](node-resources.md) are implemented. This is partial remediation, not capacity certification or closure of the public-node hold point.
|
||||
|
||||
[Stage-2 encrypted wallet storage/lifecycle](wallet-security.md) is implemented. Require explicit legacy migration, verified portable recovery, closure of old tabs and review of residual unlocked-page risks before considering the production wallet hold point satisfied. Local implementation/tests are not independent sign-off.
|
||||
|
||||
## 1. Safe inspection — does not launch anything
|
||||
|
||||
Use Node.js 24 and a clean reviewed checkout. For mainnet assessment on a host, select `WIKIDECK_NETWORK=mainnet` in the process environment, then run the `launch:preflight` package script. It runs the node entry point with `--launch-preflight`, prints JSON and exits. `--network-manifest` remains available for identity inspection only.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Stage-1 follow-up:** [Node resource controls](node-resources.md) adds global admission/byte budgets, bounded canonical pages, browser cache/history limits and marketplace read improvements. SEC-01 and SEC-06 remain open: full-history client/reconciliation, DAG/disk growth and production-scale capacity evidence are not solved. Original source line references below describe review-time locations and may shift as remediation proceeds.
|
||||
|
||||
**Stage-2 follow-up:** [Encrypted local wallets](wallet-security.md) now provide explicit same-identity migration, encrypted-at-rest vault records and lock-aware signing sessions. SEC-03's original new-record plaintext-storage behavior is remediated after migration, but the production wallet hold remains: old plaintext copies, unlocked-page compromise and independent multi-browser/recovery review are not resolved. The historical finding below records the pre-remediation state.
|
||||
|
||||
This is an internal, source-assisted review with automated regression and dependency checks, **not an independent security audit, penetration test, formal consensus proof, or production certification**. The candidate has not been launched and no approval/attestation was set. Do not interpret passing tests, the existence of a mainnet profile, or the arrival of its candidate genesis date as permission to launch.
|
||||
|
||||
Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, browser wallet/backups, marketplace authorization/accounting/reconciliation, Docker configuration and candidate startup gates. The operating hosts, real reverse proxy, public peers, SHKeeper instance, wallets and funds were **not** accessed or assessed. No active Internet-facing adversarial load test or independent native-code audit was performed.
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
# Encrypted local wallet — stage 2
|
||||
|
||||
## What changes
|
||||
|
||||
The browser no longer creates or returns a plaintext wallet at startup. Open **Wallet backup & recovery** and choose:
|
||||
|
||||
- **Create encrypted wallet:** explicitly generate a new P-256 identity and choose a local password of 12–1024 characters.
|
||||
- **Encrypt existing wallet:** migrate the previous same-origin plaintext record, preserving its exact public key/address. This is required before the old key can sign in the new UI.
|
||||
- **Unlock wallet:** decrypt an existing local vault in this tab with its local password.
|
||||
- **Recover and save encrypted wallet:** decrypt a portable backup with its backup password and re-encrypt it under a new local password. Existing storage, even when locked, requires explicit replacement confirmation. Back up that identity first; replacement does not transfer its assets.
|
||||
|
||||
The local password and backup password may differ. They have no reset endpoint, are not sent to a server, and are not stored in local/session storage. Forgotten local passwords require a working portable backup and its password. Without either a usable local vault/password or backup/password, access is lost.
|
||||
|
||||
## Storage and migration guarantees
|
||||
|
||||
Vault records use the existing reviewed backup encryption primitive: PBKDF2-SHA256 (600,000 iterations), random 16-byte salt, AES-256-GCM with random 12-byte IV and authenticated format/network/public-key headers. The local wrapper stores a format/version and random revision plus the encrypted backup. It stores **no plaintext private CryptoKey or password** for newly created/migrated vaults. Public-key metadata remains visible; encryption does not hide wallet identity or on-chain holdings.
|
||||
|
||||
The existing testnet/mainnet IndexedDB names remain separate and unchanged to find legacy records safely. Migration reads the legacy key only after the user opts in, encrypts and decrypts to verify identity, then replaces the record in a committed IndexedDB readwrite transaction. No automatic deletion, new identity or network migration occurs. Storage failure leaves the original record intact. The old CryptoKey is never removed before encrypted replacement commits.
|
||||
|
||||
Cross-tab readwrite compare-and-swap checks the expected stored revision/record in the same transaction; optional Web Locks additionally serialize storage. A competing create/migration/restore must fail rather than overwrite a newly stored identity. This is application consistency, not protection against a malicious script deliberately editing IndexedDB. Old application versions/tabs must be closed before migration; cached old bundles are not guaranteed to understand a vault record.
|
||||
|
||||
Replacing a record does **not** securely erase previous browser storage pages, OS backups, disk snapshots, old downloaded files or keys previously copied by other scripts. If the old identity may have been exposed, encryption is not revocation: a separately planned transfer to a fresh identity is required. This work never transfers assets or silently rotates keys.
|
||||
|
||||
## Session and locking
|
||||
|
||||
UI components receive a public `Wallet` handle only; private keys remain inside the wallet module. All NFT transfer, trade and marketplace signatures now go through its session signer. A stale handle from before locking/re-unlocking is rejected even for the same address.
|
||||
|
||||
- Explicit **Lock wallet now** clears the module's session references immediately.
|
||||
- Five minutes without trusted pointer/keyboard input locks the session. RPC polling and automated signing do **not** reset idle time.
|
||||
- Four hours since unlocking locks it even if active.
|
||||
- Background/hidden-tab, pagehide and reload end the session. Hiding a tab during encryption/export/unlock may cancel its result; encrypted storage may already have committed, in which case it remains locked and can be unlocked again.
|
||||
- BroadcastChannel messages contain only a lock signal, never keys, passwords or decrypted backups. Manual/background locks and replacement invalidate other tabs where supported. If unavailable, storage revision is checked before returning signatures/backups; do not assume immediate cross-tab notification on every browser.
|
||||
- Lock epochs are checked across asynchronous crypto/storage steps. A signature finishing after a detected lock/replacement is discarded. Signing/export also recheck vault revision. An already returned/submitted signature cannot be revoked by locking; signed transactions/offers can remain valid on-chain.
|
||||
|
||||
Timers can be delayed by the browser or event-loop load. Expiry is checked again on signer/export/get-wallet access so delayed timer callbacks do not authorize an expired session. JS does not provide guaranteed zeroization of strings or CryptoKey/browser memory; clearing references is not a memory-forensics guarantee.
|
||||
|
||||
Form password values clear after operations, lock notifications, hiding the page and unmounting. Browser/password-manager behavior is outside this application's memory guarantees. Backups remain the existing portable format for native miner compatibility. A downloaded backup password does not implicitly change the local password.
|
||||
|
||||
## Validation and limitations
|
||||
|
||||
Node/WebCrypto storage-boundary tests cover encrypted record-only persistence, explicit no-wallet startup, signature verification, locked/stale handles, idle/absolute expiry, lock during unlock/export, wrong passwords, replacement consent, storage retry/quota failure, conflicting storage revisions, same-identity legacy migration and preservation after failed migration. Existing cryptographic tamper, wrong-network and native-miner backup tests remain.
|
||||
|
||||
Real browser smoke checks on a disposable loopback origin passed create → lock → unlock and reload → locked. IndexedDB inspection confirmed the vault wrapper/ciphertext and absence of a top-level private key/password without retrieving private material. Tests use ephemeral test credentials only. Final Node 24 TypeScript/Vite build passed; the complete suite passed **86 tests, 0 failed, 0 skipped**, including real RandomX HTTP integration and expanded wallet storage/lifecycle assertions.
|
||||
|
||||
**Production wallet review remains open.** At-rest encryption reduces profile-theft exposure when locked but cannot protect an unlocked page or password entry from XSS, a malicious release, privileged extensions or a compromised browser/OS. The in-memory key intentionally remains extractable to support existing backup export. This is not hardware-isolated signing, a browser-extension wallet, or an independent audit. Multi-browser/background/cross-tab races and destructive crash/restore drills need broader coverage; no independent assurance or formal crypto verification is claimed. Do not use valuable assets merely because local encryption exists.
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date
|
||||
checks.push(
|
||||
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Initial RandomX difficulty, retarget behavior, final genesis and economic security have not been calibrated and independently reviewed for production.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Browser signing keys remain extractable and persist unencrypted in IndexedDB; production wallet policy and recovery review are required.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' },
|
||||
{ id: 'live-market-settlement', status: 'blocked', detail: 'No live SHKeeper adapter or reviewed payout/refund/reorg system exists. Mock payment cannot constitute real payment for valuable cards.' },
|
||||
{ id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' },
|
||||
{ id: 'deployment-rehearsal', status: 'unverified', detail: 'Verify real HTTPS endpoints, proxy/IP limits, independent peers and miners, alerts, incident response and backup/restore on the intended hosts.' },
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+77
-19
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync,
|
||||
randomBytes, sign as nativeSign, verify as nativeVerify, webcrypto } from 'node:crypto';
|
||||
import { encryptWallet, decryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
|
||||
import { getWallet, exportWallet, restoreWallet, sign, type Wallet } from '../src/wallet.ts';
|
||||
import { getWallet, exportWallet, restoreWallet, protectWallet, unlockWallet, walletStatus, lockWallet, sign, touchWallet, WALLET_IDLE_MS } from '../src/wallet.ts';
|
||||
|
||||
const subtle = webcrypto.subtle as SubtleCrypto;
|
||||
// Ephemeral test credentials only; no production identities or secrets.
|
||||
@@ -49,7 +49,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
assert.equal((wallet.privateKey.algorithm as EcKeyAlgorithm).namedCurve, 'P-256');
|
||||
assert.deepEqual(Buffer.from(await subtle.exportKey('pkcs8', wallet.privateKey)),
|
||||
Buffer.from(await subtle.exportKey('pkcs8', privateKey)));
|
||||
const signature = await sign(wallet, 'portable identity');
|
||||
const signature = encode(await subtle.sign({name:'ECDSA',hash:'SHA-256'},wallet.privateKey,new TextEncoder().encode('portable identity')));
|
||||
assert.equal(nativeVerify('sha256', Buffer.from('portable identity'), {
|
||||
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
|
||||
}, Buffer.from(signature, 'base64')), true);
|
||||
@@ -58,7 +58,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
|
||||
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
|
||||
const again = await exportWallet(wallet, password);
|
||||
const again = await encryptWallet(wallet.privateKey,wallet.publicKey,password);
|
||||
assert.notEqual(again.salt, backup.salt);
|
||||
assert.notEqual(again.iv, backup.iv);
|
||||
assert.notEqual(again.ciphertext, backup.ciphertext);
|
||||
@@ -145,11 +145,12 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
// Browser IndexedDB/Web Locks are additionally checked against the real API.
|
||||
const originalIDB = Object.getOwnPropertyDescriptor(globalThis, 'indexedDB');
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
let stored: Wallet | undefined;
|
||||
let stored: Record<string, unknown> | undefined;
|
||||
let failWrites = 0;
|
||||
let permanentFailure = false;
|
||||
let lockCalls = 0;
|
||||
let commits = 0;
|
||||
let beforeWrite: (()=>void)|undefined;
|
||||
const opened: string[] = [];
|
||||
const installNavigator = (locks: boolean) => Object.defineProperty(globalThis, 'navigator', { configurable: true,
|
||||
value: locks ? { locks: { request: async (name: string, callback: () => Promise<unknown>) => {
|
||||
@@ -161,16 +162,20 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
const request = { result: {
|
||||
close() {},
|
||||
transaction(_store: string, mode: string) {
|
||||
let replacement: Wallet | undefined;
|
||||
if(mode==='readwrite'&&beforeWrite){const mutate=beforeWrite;beforeWrite=undefined;mutate();}
|
||||
let replacement: Record<string, unknown> | undefined;
|
||||
let aborted=false;
|
||||
const tx = {
|
||||
error: null as DOMException | null,
|
||||
oncomplete: () => {}, onabort: () => {}, onerror: () => {},
|
||||
abort: () => { aborted=true;queueMicrotask(()=>tx.onabort()); },
|
||||
objectStore: () => ({
|
||||
get: () => {
|
||||
const get = { result: stored, onsuccess: () => {} };
|
||||
queueMicrotask(() => {
|
||||
get.onsuccess();
|
||||
setImmediate(() => {
|
||||
if(aborted)return;
|
||||
if (mode === 'readwrite' && (permanentFailure || failWrites-- > 0)) {
|
||||
tx.error = new DOMException('Simulated storage failure', permanentFailure ? 'QuotaExceededError' : 'AbortError');
|
||||
tx.onabort();
|
||||
@@ -183,7 +188,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
});
|
||||
return get;
|
||||
},
|
||||
put: (value: Wallet) => { replacement = value; },
|
||||
put: (value: Record<string,unknown>) => { replacement = value; },
|
||||
}),
|
||||
};
|
||||
return tx;
|
||||
@@ -194,35 +199,88 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
},
|
||||
} });
|
||||
try {
|
||||
lockWallet();
|
||||
installNavigator(false);
|
||||
const initial = await Promise.all(Array.from({ length: 4 }, () => getWallet()));
|
||||
assert.equal(new Set(initial.map(wallet => wallet.address)).size, 1);
|
||||
assert.equal(initial[0].privateKey.extractable, true);
|
||||
assert.equal(await getWallet(),undefined);
|
||||
assert.equal(await walletStatus(),'empty');
|
||||
assert.equal(stored,undefined); // Startup never silently creates a plaintext key.
|
||||
const initial = await protectWallet(password);
|
||||
assert.equal('privateKey' in initial,false);
|
||||
assert.equal((stored as Record<string,unknown>|undefined)?.format,'wikideck-local-vault');
|
||||
assert.equal('privateKey' in stored!,false);
|
||||
const exported=await exportWallet(initial,password);
|
||||
assert.equal((await decryptWallet(exported,password)).address,initial.address);
|
||||
const signature=await sign(initial,'session-signature');
|
||||
const publicKey=await subtle.importKey('spki',Buffer.from(initial.publicKey,'base64'),{name:'ECDSA',namedCurve:'P-256'},false,['verify']);
|
||||
assert.equal(await subtle.verify({name:'ECDSA',hash:'SHA-256'},publicKey,Buffer.from(signature,'base64'),new TextEncoder().encode('session-signature')),true);
|
||||
lockWallet(); assert.equal(await getWallet(),undefined);
|
||||
await assert.rejects(sign(initial,'locked'),/locked/);
|
||||
assert.equal(await walletStatus(),'locked');
|
||||
await assert.rejects(unlockWallet('incorrect-password'),/Cannot unlock/);
|
||||
const unlocked=await unlockWallet(password);
|
||||
assert.equal(unlocked.address,initial.address);
|
||||
await assert.rejects(sign(initial,'stale handle'),/locked or changed/);
|
||||
const pendingExport=exportWallet(unlocked,password);lockWallet();
|
||||
await assert.rejects(pendingExport,/locked|changed/);
|
||||
const unlocking=unlockWallet(password);lockWallet();
|
||||
await assert.rejects(unlocking,/locked|changed/);
|
||||
await unlockWallet(password);
|
||||
const originalNow=Date.now;
|
||||
try { const now=Date.now();Date.now=()=>now+WALLET_IDLE_MS+1;assert.equal(await getWallet(),undefined); }
|
||||
finally {Date.now=originalNow;}
|
||||
const { backup } = await fixture;
|
||||
const expected = await decryptWallet(backup, password);
|
||||
installNavigator(true);
|
||||
failWrites = 1;
|
||||
const before = commits;
|
||||
const recovered = await restoreWallet(backup, password);
|
||||
const recovered = await restoreWallet(backup, password,password,true);
|
||||
assert.ok(commits > before);
|
||||
assert.equal(stored?.address, expected.address);
|
||||
assert.equal((stored as Record<string,unknown>|undefined)?.format,'wikideck-local-vault');
|
||||
assert.equal(recovered.address, expected.address);
|
||||
assert.equal((await getWallet()).address, expected.address);
|
||||
assert.equal((await getWallet())?.address, expected.address);
|
||||
assert.ok(lockCalls >= 2);
|
||||
assert.ok(opened.every(name => name === 'wikideck-testnet-wallet-v2'));
|
||||
permanentFailure = true;
|
||||
await assert.rejects(restoreWallet(await exportWallet(initial[0], password), password), { name: 'QuotaExceededError' });
|
||||
assert.equal(stored?.address, expected.address);
|
||||
const preserved=JSON.stringify(stored);
|
||||
await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' });
|
||||
assert.equal(JSON.stringify(stored),preserved);
|
||||
permanentFailure = false;
|
||||
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64')));
|
||||
assert.equal((await getWallet()).address, expected.address);
|
||||
await assert.rejects(restoreWallet(backup,password,password),/replacement/);
|
||||
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64'),password,true));
|
||||
assert.equal((await unlockWallet(password)).address, expected.address);
|
||||
failWrites = 5;
|
||||
const attempts = opened.length;
|
||||
await assert.rejects(restoreWallet(backup, password), { name: 'AbortError' });
|
||||
assert.equal(opened.length - attempts, 3);
|
||||
await assert.rejects(restoreWallet(backup, password,password,true), { name: 'AbortError' });
|
||||
assert.equal(opened.length - attempts, 4); // One read, three write attempts.
|
||||
failWrites = 0;
|
||||
assert.equal((await restoreWallet(backup, password)).address, expected.address);
|
||||
assert.equal((await restoreWallet(backup, password,password,true)).address, expected.address);
|
||||
// Simulate another tab replacing a vault between our initial read and commit; CAS must reject.
|
||||
beforeWrite=()=>{stored={...stored,revision:'other-tab-revision'};};
|
||||
await assert.rejects(restoreWallet(exported,password,password,true),/changed in another tab/);
|
||||
assert.equal((stored as Record<string,unknown>|undefined)?.revision,'other-tab-revision');
|
||||
const current=await unlockWallet(password);
|
||||
stored={...(stored as Record<string,unknown>|undefined),revision:'another-tab-replaced'};
|
||||
await assert.rejects(sign(current,'cross-tab stale signer'),/changed/);
|
||||
assert.equal(await getWallet(),undefined);
|
||||
const expiry=await unlockWallet(password),baseNow=Date.now,openedAt=Date.now();
|
||||
try{
|
||||
let time=openedAt;Date.now=()=>time;
|
||||
// Trusted UI activity may extend idle time, never the four-hour absolute lifetime.
|
||||
for(let i=0;i<49;i++){time+=WALLET_IDLE_MS-1000;touchWallet();}
|
||||
await assert.rejects(sign(expiry,'absolute expiry'),/locked/);
|
||||
}finally{Date.now=baseNow;}
|
||||
// Seed the old same-origin plaintext shape; explicit migration must preserve its identity.
|
||||
lockWallet(); stored=expected as unknown as Record<string,unknown>;
|
||||
assert.equal(await walletStatus(),'legacy');assert.equal(await getWallet(),undefined);
|
||||
permanentFailure=true;
|
||||
await assert.rejects(protectWallet(password),{name:'QuotaExceededError'});
|
||||
assert.equal(stored.privateKey,expected.privateKey); // No destructive migration on failed commit.
|
||||
permanentFailure=false;
|
||||
assert.equal((await protectWallet(password)).address,expected.address);
|
||||
assert.equal('privateKey' in stored,false);
|
||||
lockWallet();assert.equal((await unlockWallet(password)).address,expected.address);
|
||||
} finally {
|
||||
lockWallet();
|
||||
if (originalIDB) Object.defineProperty(globalThis, 'indexedDB', originalIDB);
|
||||
else Reflect.deleteProperty(globalThis, 'indexedDB');
|
||||
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
||||
|
||||
Reference in New Issue
Block a user