Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/src/NativeConnectionWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {useEffect,useRef} from 'react';
import './native-connection-warning.css';

export default function NativeConnectionWarning({onCancel,onConfirm}:{onCancel:()=>void;onConfirm:()=>void}){
const dialog=useRef<HTMLDialogElement>(null);
useEffect(()=>{const node=dialog.current;if(node&&!node.open)node.showModal();return()=>node?.close();},[]);
return <dialog ref={dialog} className="native-connection-warning" aria-labelledby="direct-connection-title" aria-describedby="direct-connection-detail" onCancel={event=>{event.preventDefault();onCancel();}}>
<span className="native-connection-warning-eyebrow">Network privacy</span>
<h2 id="direct-connection-title">Turn off Tor?</h2>
<p id="direct-connection-detail">Wallet services will see your IP address. Your network and DNS provider can see which services you contact. Separate Tor circuits will no longer protect unrelated wallet items.</p>
<p>Your wallet will reconnect directly for this app session. Tor starts again the next time you launch Shielded.</p>
<div><button type="button" autoFocus onClick={onCancel}>Keep Tor on</button><button type="button" className="native-connection-confirm" onClick={onConfirm}>Turn off Tor and reconnect</button></div>
</dialog>;
}
16 changes: 9 additions & 7 deletions app/src/NativeTorHealth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import './native-tor-health.css';

export const initialTorHealth:NativeTorHealthState={status:{phase:'idle',generation:0},check:'idle'};

export default function NativeTorHealth({health=initialTorHealth,tone='light'}:{health?:NativeTorHealthState;tone?:'light'|'dark'}){
export default function NativeTorHealth({health=initialTorHealth,tone='light',onToggleTor,transportBusy=false}:{health?:NativeTorHealthState;tone?:'light'|'dark';onToggleTor?:()=>void;transportBusy?:boolean}){
const {status,check,checkedAt,latencyMs}=health;
const direct=(status.mode??'tor')==='direct';
const ready=status.phase==='ready',connected=ready&&check==='ok';
const label=status.phase==='paused'?'Tor paused':status.phase==='error'?'Tor unavailable':status.phase==='bootstrapping'?'Connecting to Tor':ready?(connected?'Tor connected':check==='failed'?'Tor check failed':check==='checking'?'Checking Tor':'Tor ready'):'Starting Tor';
const state=connected?'connected':status.phase==='error'||ready&&check==='failed'?'error':status.phase==='paused'?'paused':'connecting';
const progress=status.phase==='bootstrapping'&&status.progress&&/^\d{1,3}%$/.test(status.progress)?status.progress:undefined;
const detail=status.phase==='paused'?'The private connection is paused. Unlock the wallet to reconnect.':status.phase==='error'?(status.error||'Tor could not start. Reconnect to try again.'):ready&&check==='failed'?'The latest connection check through Tor failed. Your wallet never falls back to a direct connection.':connected?'A recent connection to the wallet service succeeded through Tor.':ready?'Tor is ready. Checking the connection to the wallet service.':'Tor starts automatically and keeps wallet traffic private while it connects.';
const label=direct?status.phase==='error'||check==='failed'?'Direct connection failed · Tor off':status.phase==='paused'?'Direct paused':ready?(connected?'Tor off · Direct connection':check==='checking'?'Checking direct connection':'Direct ready'):'Connecting directly':status.phase==='paused'?'Tor paused':status.phase==='error'?'Tor unavailable':status.phase==='bootstrapping'?'Connecting to Tor':ready?(connected?'Tor connected':check==='failed'?'Tor check failed':check==='checking'?'Checking Tor':'Tor ready'):'Starting Tor';
const state=direct&&status.phase==='error'||ready&&check==='failed'?'error':direct?'direct':connected?'connected':status.phase==='error'||ready&&check==='failed'?'error':status.phase==='paused'?'paused':'connecting';
const progress=!direct&&status.phase==='bootstrapping'&&status.progress&&/^\d{1,3}%$/.test(status.progress)?status.progress:undefined;
const detail=direct?(status.phase==='paused'?'The direct connection is paused. Unlock the wallet to reconnect.':status.phase==='error'?(status.error||'The direct connection is unavailable. Reconnect to try again.'):ready&&check==='failed'?'The latest direct connection check failed. Tor remains off.':connected?'A recent direct connection to the wallet service succeeded.':ready?'Checking the direct connection to the wallet service.':'Connecting directly to the wallet services.'):status.phase==='paused'?'The private connection is paused. Unlock the wallet to reconnect.':status.phase==='error'?(status.error||'Tor could not start. Reconnect to try again.'):ready&&check==='failed'?'The latest connection check through Tor failed. Your wallet never falls back to a direct connection.':connected?'A recent connection to the wallet service succeeded through Tor.':ready?'Tor is ready. Checking the connection to the wallet service.':'Tor starts automatically and keeps wallet traffic private while it connects.';
return <details className="native-tor-health" data-tone={tone} data-health={state}>
<summary aria-label={`Tor connection: ${label}`}><span className="native-tor-dot" aria-hidden="true"/><span role="status" aria-live="polite">{label}{progress&&<span className="native-tor-percent"> {progress}</span>}</span><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m5 6 3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg></summary>
<summary aria-label={`Wallet connection: ${label}`}><span className="native-tor-dot" aria-hidden="true"/><span role="status" aria-live="polite">{label}{progress&&<span className="native-tor-percent"> {progress}</span>}</span><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m5 6 3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg></summary>
<div className="native-tor-panel"><strong>{label}</strong><p>{detail}</p>
{progress&&<progress max={100} value={Math.min(100,parseInt(progress,10))} aria-label="Tor bootstrap progress"/>}
{ready&&checkedAt&&<p className="native-tor-checked">Last checked {new Date(checkedAt).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'})}{latencyMs!==undefined?` · ${(latencyMs/1000).toFixed(1)}s`:''}</p>}
{ready&&check==='failed'&&health.error&&<p className="native-tor-problem">{health.error}</p>}
<p className="native-tor-privacy">Separate Tor scopes protect unrelated wallet items. Repeated identifiers and timing can still reveal links. Wallet services may be unavailable even when Tor is connected.</p>
{onToggleTor&&<button className="native-tor-switch" type="button" role="switch" aria-checked={!direct} disabled={transportBusy} onClick={()=>{if(!transportBusy)onToggleTor();}}><span>Use Tor</span><span className="native-tor-switch-track" aria-hidden="true"/></button>}
<p className="native-tor-privacy">{direct?'Your IP address and DNS lookups are exposed. Direct connections have no Tor circuit isolation.':'Separate Tor scopes protect unrelated wallet items. Repeated identifiers and timing can still reveal links. Wallet services may be unavailable even when Tor is connected.'}</p>
</div>
</details>;
}
73 changes: 56 additions & 17 deletions app/src/NativeWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
import {invoke} from '@tauri-apps/api/core';
import {bytesToHex} from '@noble/hashes/utils.js';
import {parseMasterSecret} from '../../packages/protocol/src/wallet-keys.ts';
import {installNativeTransport,nativeNetwork} from '../../src/native/index.ts';
import {installNativeTransport,nativeNetwork,type NativeMode} from '../../src/native/index.ts';
import {createNativeStorage} from './native-storage.ts';
import {createNativeCleanup} from './native-cleanup.ts';
import {openNativeVault} from './native-open.ts';
import {saveNativeSetup} from './native-setup.ts';
import NativeConnectionWarning from './NativeConnectionWarning.tsx';
import {createNativeTorMonitor,type NativeTorHealthState} from './native-tor-monitor.ts';
import WalletOnboarding from './WalletOnboarding.tsx';
import NativeTorHealth from './NativeTorHealth.tsx';
Expand All @@ -15,27 +17,33 @@ installNativeTransport();
const Wallet=lazy(()=>import('./RollupWallet'));
const getSecret=()=>invoke<string|null>('plugin:shielded-platform|secret_get');
const putSecret=(value:string)=>invoke<void>('plugin:shielded-platform|secret_set',{value});
const pauseNetwork=async()=>{await nativeNetwork.pause();};
type Session={generation:number;cache:ReturnType<typeof createNativeStorage>};

export default function NativeWallet(){
const [session,setSession]=useState<Session>(),[phase,setPhase]=useState('Connecting to Tor'),[error,setError]=useState('');
const [setup,setSetup]=useState(false),[phrase,setPhrase]=useState(''),[busy,setBusy]=useState(true);
const [torHealth,setTorHealth]=useState<NativeTorHealthState>({status:{phase:'idle',generation:0},check:'idle'});
const [externalLink,setExternalLink]=useState('');
const [confirmDirect,setConfirmDirect]=useState(false),[switching,setSwitching]=useState(false),[saving,setSaving]=useState(false);
const selectedMode=useRef<NativeMode>('tor'),transition=useRef(false),creating=useRef<Promise<void>|undefined>(undefined);
const setupAllowed=useRef(false);
const active=useRef<Session|undefined>(undefined),attempt=useRef(0),cleanup=useRef(createNativeCleanup()),closed=useRef<Promise<void>>(Promise.resolve()),manualLock=useRef(false);
const opening=useRef<Promise<void>|undefined>(undefined);
const openingGeneration=useRef<number|undefined>(undefined);
const healthExpired=useRef(false);
const stop=useRef<(()=>Promise<void>)|undefined>(undefined);
const onShutdown=useCallback((shutdown:(()=>Promise<void>)|undefined)=>{stop.current=shutdown;},[]);
const fail=(cause:unknown)=>setError(cause instanceof Error?cause.message:String(cause));
const pause=useCallback(()=>{
++attempt.current;
setupAllowed.current=false;setSetup(false);
const previous=active.current;active.current=undefined;
const shutdown=stop.current;stop.current=undefined;
const writing=previous?.cache.flush(),pending=opening.current;
setSession(undefined);setBusy(false);setPhase('Private connection paused');previous?.cache.clearMemory();
// An ambiguous vault write must settle before a new open reconciles its metadata.
const closing=cleanup.current.add(pauseNetwork,...(shutdown?[shutdown]:[]),...(writing?[()=>writing.catch(()=>{})]:[]));
const pendingWrite=creating.current;
const closing=cleanup.current.add(async()=>{await pendingWrite?.catch(()=>{});await nativeNetwork.pause();},...(shutdown?[shutdown]:[]),...(writing?[()=>writing.catch(()=>{})]:[]));
closed.current=Promise.all([closed.current.catch(()=>{}),closing,pending]).then(()=>{});
void closed.current.catch(fail);return closed.current;
},[]);
Expand All @@ -44,35 +52,61 @@ export default function NativeWallet(){
if(document.hidden||manualLock.current){setBusy(false);setPhase('Private connection paused');return Promise.resolve();}
const previousClose=closed.current;
const run=(async()=>{
const mine=++attempt.current;setBusy(true);setError('');
healthExpired.current=false;
const mine=++attempt.current;setupAllowed.current=false;setSetup(false);setBusy(true);setError('');
try{
await cleanup.current.drain();
await previousClose.catch(()=>{});
if(mine!==attempt.current||document.hidden||manualLock.current)return;
setPhase('Connecting to Tor');
const opened=await openNativeVault(nativeNetwork.start,getSecret,()=>mine===attempt.current&&!document.hidden&&!manualLock.current);
setPhase(selectedMode.current==='tor'?'Connecting to Tor':'Opening direct connection');
const generationGuard=()=>mine===attempt.current&&!document.hidden&&!manualLock.current&&!healthExpired.current;
const opened=await openNativeVault(async()=>{
const status=await nativeNetwork.start(selectedMode.current);
if(status.phase==='ready')openingGeneration.current=status.generation;
return status;
},getSecret,generationGuard,{generation:(await nativeNetwork.status()).generation,mode:selectedMode.current,current:generationGuard});
openingGeneration.current=undefined;
if(!opened||mine!==attempt.current||document.hidden||manualLock.current)return;
if(healthExpired.current)return;
const {secret,status}=opened;
if(secret===null){
if(localStorage.getItem('shielded-native-wallet')==='1')throw new Error('The native wallet secret is unavailable. Keep the app data and restore access to its vault.');
setSetup(true);setPhase('Create or restore your wallet');return;
setupAllowed.current=true;setSetup(true);setPhase('Create or restore your wallet');return;
}
setSetup(false);const cache=createNativeStorage(secret,localStorage,putSecret);
manualLock.current=false;const next={generation:status.generation,cache};active.current=next;setSession(next);setPhase('Tor connected');
manualLock.current=false;const next={generation:status.generation,cache};active.current=next;setSession(next);setPhase(status.mode==='direct'?'Tor off · Direct connection':'Tor connected');
}catch(cause){if(mine===attempt.current)fail(cause);}
finally{if(mine===attempt.current)setBusy(false);}
finally{if(mine===attempt.current){setBusy(false);openingGeneration.current=undefined;}}
})();
opening.current=run.finally(()=>{opening.current=undefined;});return opening.current;
},[]);
const create=async(restore:boolean)=>{
setBusy(true);setError('');
if(creating.current||transition.current||manualLock.current||!setupAllowed.current)return;
const mine=attempt.current;setBusy(true);setSaving(true);setError('');
healthExpired.current=false;
const writing=saveNativeSetup(torHealth.status,nativeNetwork.status,()=>mine===attempt.current&&!document.hidden&&!manualLock.current&&!transition.current,
()=>restore?bytesToHex(parseMasterSecret(phrase)):bytesToHex(crypto.getRandomValues(new Uint8Array(32))),async secret=>{
setupAllowed.current=false;setSetup(false);setPhase('Reopen the wallet to confirm secure storage');
await putSecret(secret);localStorage.setItem('shielded-native-wallet','1');
if(!restore){localStorage.setItem('shielded-rollup-wallet-born-v1','new');localStorage.setItem('shielded-rollup-wallet-created-v1',String(Date.now()));}
});
creating.current=writing;
try{
const secret=restore?bytesToHex(parseMasterSecret(phrase)):bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
await putSecret(secret);localStorage.setItem('shielded-native-wallet','1');
if(!restore){localStorage.setItem('shielded-rollup-wallet-born-v1','new');localStorage.setItem('shielded-rollup-wallet-created-v1',String(Date.now()));}
setPhrase('');await open();
}catch(cause){fail(cause);setBusy(false);}
await writing;
if(creating.current===writing)creating.current=undefined;
if(mine===attempt.current){setPhrase('');await open();}
}catch(cause){if(mine===attempt.current){fail(cause);setBusy(false);}}
finally{if(creating.current===writing)creating.current=undefined;setSaving(false);}
};
const switchMode=async(mode:NativeMode)=>{
if(transition.current||creating.current||manualLock.current)return;
transition.current=true;setSwitching(true);setConfirmDirect(false);setError('');
const mine=attempt.current+1;
try{await pause();if(mine!==attempt.current||document.hidden||manualLock.current||!transition.current)return;selectedMode.current=mode;await open();}
catch(cause){fail(cause);}
finally{transition.current=false;setSwitching(false);}
};
const toggleTor=()=>{if(transition.current||creating.current||manualLock.current)return;if(selectedMode.current==='direct')void switchMode('tor');else setConfirmDirect(true);};
useEffect(()=>{
let live=true;void open();
const visibility=()=>{
Expand All @@ -89,6 +123,10 @@ export default function NativeWallet(){
const monitor=createNativeTorMonitor(nativeNetwork,health=>{
if(!live)return;setTorHealth(health);
const observed=active.current;
const connecting=openingGeneration.current;
if(connecting!==undefined&&(health.status.phase!=='ready'||health.status.generation!==connecting)){
openingGeneration.current=undefined;healthExpired.current=true;
}
if(observed&&(health.status.phase!=='ready'||health.status.generation!==observed.generation))void pause();
},cause=>{if(live){fail(cause);if(active.current)void pause();}},()=>!document.hidden);
return()=>{live=false;monitor.stop();document.removeEventListener('visibilitychange',visibility);document.removeEventListener('click',external,true);void pause();};
Expand All @@ -102,9 +140,10 @@ export default function NativeWallet(){
}).catch(cause=>{if(active.current===current&&attempt.current===mine){fail(cause);void pause();}});
},[open,pause]);
return <>
{session&&<div className="native-wallet-topbar"><div className="app-header-status"><NativeTorHealth health={torHealth}/><AppUpdateNotice/></div><button className="native-wallet-lock" type="button" onClick={()=>{manualLock.current=true;void pause();}}>Lock wallet</button></div>}
{session&&<div className="native-wallet-topbar"><div className="app-header-status"><NativeTorHealth health={torHealth} onToggleTor={toggleTor} transportBusy={switching||saving}/><AppUpdateNotice/></div><button className="native-wallet-lock" type="button" onClick={()=>{manualLock.current=true;setConfirmDirect(false);void pause();}}>Lock wallet</button></div>}
{confirmDirect&&<NativeConnectionWarning onCancel={()=>setConfirmDirect(false)} onConfirm={()=>void switchMode('direct')}/>}
{externalLink&&<section className="stock-warning" role="dialog" aria-label="External link"><p>Copy this link to open it outside Shielded. Your browser uses its own network connection.</p><input aria-label="Link to copy" readOnly value={externalLink} onFocus={event=>event.target.select()}/><button type="button" onClick={()=>setExternalLink('')}>Close</button></section>}
{session?<Suspense fallback={<p role="status">Opening wallet</p>}><Wallet key={session.generation} storage={session.cache.storage} reload={reload} onShutdown={onShutdown}/></Suspense>
:<WalletOnboarding transport="tor" setup={setup} busy={busy} phase={phase} error={error} phrase={phrase} torHealth={torHealth} onPhraseChange={setPhrase} onCreate={()=>void create(false)} onRestore={()=>void create(true)} onReconnect={()=>{manualLock.current=false;void open();}}/>}
:<WalletOnboarding transport="tor" setup={setup} busy={busy||switching||saving} phase={phase} error={error} phrase={phrase} torHealth={torHealth} onToggleTor={manualLock.current?undefined:toggleTor} transportBusy={switching||saving} onPhraseChange={setPhrase} onCreate={()=>void create(false)} onRestore={()=>void create(true)} onReconnect={()=>{if(transition.current||creating.current)return;manualLock.current=false;void open();}}/>}
</>;
}
Loading
Loading