From f316c77806283ceea49f7f0ec74fdf08187efc0a Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 24 Sep 2026 18:13:58 +0700 Subject: [PATCH] Security hardening (audit F1-F4): secure Cashu seed in native Keychain/KeyStore with mandatory first-time backup; verify event signatures for trust scores (WoT + NIP-85 Relatr); add safeJson guard on config boot parse; delete dead CashuWallet.tsx with raw alert(mnemonic) Co-authored-by: shakespeare.diy --- package-lock.json | 7 + package.json | 1 + src/components/AppProvider.tsx | 5 +- src/components/wallet/CashuWallet.tsx | 704 ------------------ src/components/wallet/CashuWalletCard.tsx | 11 +- .../wallet/RecoveryPhraseDialog.tsx | 47 +- src/hooks/useBatchTrustScores.ts | 13 +- src/hooks/useRelatrScore.ts | 11 +- src/hooks/useTrustScore.ts | 15 +- src/hooks/wallet/useCashu.ts | 91 ++- src/lib/safeJson.ts | 24 + src/lib/secureStorage.ts | 70 ++ src/lib/verifyEvents.ts | 38 + 13 files changed, 304 insertions(+), 733 deletions(-) delete mode 100644 src/components/wallet/CashuWallet.tsx create mode 100644 src/lib/safeJson.ts create mode 100644 src/lib/secureStorage.ts create mode 100644 src/lib/verifyEvents.ts diff --git a/package-lock.json b/package-lock.json index 2cc98c2..8859a85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@unhead/react": "^2.0.10", "bitcoinjs-lib": "^7.0.0", "buffer": "^6.0.3", + "capacitor-secure-storage-plugin": "^0.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -4646,6 +4647,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capacitor-secure-storage-plugin": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz", + "integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==", + "license": "MIT" + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", diff --git a/package.json b/package.json index c52c543..200c289 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@unhead/react": "^2.0.10", "bitcoinjs-lib": "^7.0.0", "buffer": "^6.0.3", + "capacitor-secure-storage-plugin": "^0.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 64e536a..04eb4d2 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,6 +1,7 @@ import { ReactNode, useEffect } from 'react'; import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { safeJson } from '@/lib/safeJson'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; interface AppProviderProps { @@ -43,7 +44,9 @@ export function AppProvider(props: AppProviderProps) { { serialize: JSON.stringify, deserialize: (value: string) => { - const parsed = JSON.parse(value); + // Guard the parse: a corrupted localStorage blob must not throw before + // the Zod schema runs (that would crash-loop the app on boot). + const parsed = safeJson(value, {}); return AppConfigSchema.partial().parse(parsed); } } diff --git a/src/components/wallet/CashuWallet.tsx b/src/components/wallet/CashuWallet.tsx deleted file mode 100644 index 0832adb..0000000 --- a/src/components/wallet/CashuWallet.tsx +++ /dev/null @@ -1,704 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useCashu } from '@/hooks/wallet/useCashu'; -import { useMintManager } from '@/hooks/wallet/useMintManager'; -import { useLightningOperations } from '@/hooks/wallet/useLightningOperations'; -import { useTokenOperations } from '@/hooks/wallet/useTokenOperations'; -import { useHistoryManager } from '@/hooks/wallet/useHistoryManager'; -import { useSatsToUsd } from '@/hooks/useExchangeRate'; -import { generateQRCode } from '@/lib/wallet/qrCodeGenerator'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Wallet, - ArrowDownCircle, - ArrowUpCircle, - History, - Copy, - ExternalLink, - AlertCircle, - Shield, - Trash2, - Plus, -} from 'lucide-react'; -import { cn } from '@/lib/utils'; - -export function CashuWallet() { - const { - coco, - repositories, - balances, - mints, - totalBalance, - isInitialized, - isLoading: walletLoading, - error: walletError, - mnemonic, - getEncodedToken, - refreshMints, - } = useCashu(); - - const usdValue = useSatsToUsd(totalBalance); - - const { - activeMintUrl, - setActiveMintUrl, - mintUrl, - setMintUrl, - handleAddMint, - handleRemoveMintClick, - handleConfirmRemoveMint, - mintToRemove, - showRemoveMintModal, - setShowRemoveMintModal, - getCleanMintLabel, - } = useMintManager({ coco, mints, repositories, refreshMints }); - - const { - quoteAmount, - setQuoteAmount, - showInvoiceModal, - setShowInvoiceModal, - currentInvoice, - handleLightningReceive, - lightningInvoice, - setLightningInvoice, - isProcessingPayment, - handleLightningSend, - } = useLightningOperations({ coco, activeMintUrl, generateQRCode }); - - const { - tokenInput, - setTokenInput, - handleTokenReceive, - sendAmount, - setSendAmount, - handleTokenSend, - sentToken, - setSentToken, - sentTokenQRCode, - } = useTokenOperations({ coco, activeMintUrl, generateQRCode, getEncodedToken }); - - const { history, isLoading: historyLoading } = useHistoryManager({ - coco, - repositories, - mints, - }); - - // Tracks the browser-only case where the user has mints but none are - // reachable (all CORS-blocked). Native shells never hit this. - const [noReachableMint, setNoReachableMint] = useState(false); - - // Pick an active mint when none is selected — prefer a REACHABLE mint so a - // user whose first saved mint is CORS-blocked (e.g. legacy minibits) doesn't - // get stuck with a mint that fails every invoice. - useEffect(() => { - if (mints.length === 0 || activeMintUrl) return; - let cancelled = false; - - (async () => { - const { probeMintReachability } = await import('@/lib/mintReachability'); - // Probe mints in order; select the first reachable one. - for (const m of mints) { - const probe = await probeMintReachability(m.url); - if (cancelled) return; - if (probe.reachability === 'ok') { - setActiveMintUrl(m.url); - setNoReachableMint(false); - return; - } - } - // Nothing reachable — select the first so the UI still renders, and flag - // the state so we can warn the user (browser CORS case). - if (!cancelled) { - setActiveMintUrl(mints[0].url); - setNoReachableMint(true); - } - })(); - - return () => { - cancelled = true; - }; - }, [mints, activeMintUrl, setActiveMintUrl]); - - // Show wallet loading state - if (walletLoading) { - return ( - - - - - Cashu Wallet - - Initializing wallet... - - - - - - - - ); - } - - // Show wallet error state - if (walletError) { - return ( - - - - - Wallet Error - - - - - - - {walletError.message} - - - - - ); - } - - // Copy to clipboard helper - const copyToClipboard = async (text: string, label: string) => { - try { - await navigator.clipboard.writeText(text); - // Could add toast notification here - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - return ( - <> - - - - - Cashu Wallet - - - Built-in wallet for easy payments - no external setup required - - - - {/* No reachable mint (browser CORS) warning */} - {noReachableMint && ( - - - -

This mint can't be reached from your browser

-

- Your saved mint doesn't allow web requests (no CORS). Add a web-friendly - mint below (e.g. mint.coinos.io) and set it active — or open - SkateSpots in the native app, where any mint (including Minibits) works. -

-
-
- )} - - {/* Balance Display */} -
-
Total Balance
-
- {totalBalance.toLocaleString()} sats -
- {usdValue !== null && ( -
- ≈ ${usdValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
- )} -
- - {/* Active Mint Selector */} - {mints.length > 0 && ( -
- - -
- )} - - {/* Main Actions */} - - - - - Receive - - - - Send - - - - {/* Receive Tab */} - - {/* Lightning Invoice */} -
- -
- setQuoteAmount(e.target.value)} - className="bg-gray-800 border-gray-700" - /> - -
-
- -
-
- -
-
- Or -
-
- - {/* Cashu Token */} -
- -
- setTokenInput(e.target.value)} - className="bg-gray-800 border-gray-700 font-mono text-xs" - /> - -
-
-
- - {/* Send Tab */} - - {/* Send Token */} -
- -
- setSendAmount(e.target.value)} - className="bg-gray-800 border-gray-700" - /> - -
-
- -
-
- -
-
- Or -
-
- - {/* Pay Lightning Invoice */} -
- -
- setLightningInvoice(e.target.value)} - className="bg-gray-800 border-gray-700 font-mono text-xs" - /> - -
-
-
-
- - {/* History */} - {history.length > 0 && ( -
-
- -
-
- {history.slice(0, 5).map((entry) => ( -
-
- {entry.type === 'mint' || entry.type === 'receive' ? ( - - ) : ( - - )} -
-
- {entry.type === 'mint' && 'Received Lightning'} - {entry.type === 'melt' && 'Sent Lightning'} - {entry.type === 'send' && 'Sent Token'} - {entry.type === 'receive' && 'Received Token'} -
-
- {new Date(entry.timestamp).toLocaleDateString()} -
-
-
-
-
- {entry.type === 'mint' || entry.type === 'receive' ? '+' : '-'} - {entry.amount.toLocaleString()} sats -
- - {entry.status} - -
-
- ))} -
-
- )} -
-
- - {/* What is Cashu Info Card */} - - - What is Cashu? - - -

- Cashu is digital cash backed by Bitcoin Lightning. Think of it as Bitcoin tokens you hold in your browser. -

-
-
- ✅ - Works offline at skate spots -
-
- ✅ - Instant peer-to-peer payments -
-
- ✅ - Privacy-focused (mints don't track you) -
-
- ✅ - Always redeemable for Lightning sats -
-
- - - Find mints at mintradar.pedani.eu - -
-
- - {/* Mint Management */} - - - - - Trusted Mints ({mints.length}) - - - Manage which mints you trust. Multiple mints = redundancy (like multiple relays) - - - - {/* Mint List */} -
- {mints.map((mint) => ( -
-
-
- {getCleanMintLabel(mint)} -
-
- {balances[mint.url] || 0} sats -
-
- -
- ))} -
- - {/* Add Mint */} -
- -
- setMintUrl(e.target.value)} - className="bg-gray-800 border-gray-700 font-mono text-xs" - /> - -
-
- - - - Find more mints at mintradar.pedani.eu - -
-
- - {/* Backup Warning */} - {mnemonic && ( - - - - - Backup Your Wallet - - - -

- Your wallet is secured with a 12-word recovery phrase. Save it safely - you'll need it to restore your balance if you clear browser data. -

- -

- 💡 This is like your Nostr nsec - keep it secret and backed up! -

-
-
- )} - - {/* Invoice Modal */} - - - - Receive {currentInvoice?.amount} sats - - Pay this invoice from any Lightning wallet - - - {currentInvoice && ( -
-
- Invoice QR Code -
-
- -
- - -
-
-

- ⏳ Waiting for payment... -

-
- )} -
-
- - {/* Send Token Modal */} - !open && setSentToken(null)}> - - - Token Ready to Send - - Share this token with the recipient - - - {sentToken && ( -
- {sentTokenQRCode && ( -
- Token QR Code -
- )} -
- -
- -
- -
-
-
- - - - Anyone with this token can claim it - treat it like cash! - - -
- )} -
-
- - {/* Remove Mint Confirmation */} - - - - Remove Mint? - - Are you sure you want to remove {mintToRemove && getCleanMintLabel(mintToRemove)}? - - -
- {mintToRemove && (balances[mintToRemove.url] || 0) > 0 ? ( - // Real balance at risk — removing deletes the local proofs and - // strands these sats. Make the amount explicit and loud. - - - - Warning: This mint still holds{' '} - {(balances[mintToRemove.url] || 0).toLocaleString()} sats. - Removing it deletes those funds from this device — withdraw or send them first, - or they'll be lost. - - - ) : ( - - - - Make sure you have withdrawn all funds from this mint before removing it. - - - )} -
- - -
-
-
-
- - ); -} diff --git a/src/components/wallet/CashuWalletCard.tsx b/src/components/wallet/CashuWalletCard.tsx index 4078684..40490ea 100644 --- a/src/components/wallet/CashuWalletCard.tsx +++ b/src/components/wallet/CashuWalletCard.tsx @@ -146,6 +146,8 @@ function CashuWalletCardNative() { isLoading: walletLoading, error: walletError, mnemonic, + needsBackup, + confirmBackup, getEncodedToken, refreshMints, } = useCashu(); @@ -807,12 +809,17 @@ function CashuWalletCardNative() { {/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant, - hidden-by-default reveal */} + hidden-by-default reveal. + When needsBackup is set (a freshly auto-generated seed), it becomes a + MANDATORY, non-dismissable backup step: the user must reveal the words + and confirm before they can start using the wallet. */} {mnemonic && ( )} diff --git a/src/components/wallet/RecoveryPhraseDialog.tsx b/src/components/wallet/RecoveryPhraseDialog.tsx index cebd2b7..9493ca6 100644 --- a/src/components/wallet/RecoveryPhraseDialog.tsx +++ b/src/components/wallet/RecoveryPhraseDialog.tsx @@ -16,6 +16,16 @@ interface RecoveryPhraseDialogProps { onOpenChange: (open: boolean) => void; /** Space-separated BIP-39 mnemonic. */ mnemonic: string; + /** + * When true, the dialog is a MANDATORY first-time backup step for a freshly + * generated wallet seed: it cannot be dismissed by clicking outside, and the + * user must reveal the words and explicitly confirm they saved them. Used to + * ensure a silently auto-generated seed is never left un-backed-up (clearing + * app storage without a backup = permanent loss of funds). + */ + requireConfirm?: boolean; + /** Called when the user confirms they've backed up (only in requireConfirm mode). */ + onConfirm?: () => void; } /** @@ -30,7 +40,13 @@ interface RecoveryPhraseDialogProps { * leak); paper is the safe path. * • The seed is cleared from the reveal state whenever the dialog closes. */ -export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryPhraseDialogProps) { +export function RecoveryPhraseDialog({ + open, + onOpenChange, + mnemonic, + requireConfirm = false, + onConfirm, +}: RecoveryPhraseDialogProps) { const [revealed, setRevealed] = useState(false); const [copied, setCopied] = useState(false); const { toast } = useToast(); @@ -38,6 +54,9 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP const words = mnemonic.trim().split(/\s+/); const handleOpenChange = (next: boolean) => { + // In mandatory-backup mode the dialog can't be dismissed without confirming + // — swallow close attempts (outside-click / Esc) until the seed is saved. + if (requireConfirm && !next) return; // Re-hide on every close so re-opening always starts concealed. if (!next) { setRevealed(false); @@ -46,6 +65,13 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP onOpenChange(next); }; + const handleConfirm = () => { + onConfirm?.(); + setRevealed(false); + setCopied(false); + onOpenChange(false); + }; + const handleCopy = async () => { try { await navigator.clipboard.writeText(mnemonic); @@ -59,7 +85,11 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP return ( - + { if (requireConfirm) e.preventDefault(); }} + onInteractOutside={(e) => { if (requireConfirm) e.preventDefault(); }} + >
@@ -135,6 +165,19 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP

Writing it on paper is safest — copying to your clipboard can leak to other apps.

+ + {/* Mandatory first-time backup confirmation. Disabled until the user has + actually revealed the words, so they can't blindly dismiss it. */} + {requireConfirm && ( + + )}
); diff --git a/src/hooks/useBatchTrustScores.ts b/src/hooks/useBatchTrustScores.ts index db19b3c..db2d82f 100644 --- a/src/hooks/useBatchTrustScores.ts +++ b/src/hooks/useBatchTrustScores.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from './useCurrentUser'; +import { onlyVerified } from '@/lib/verifyEvents'; /** * Hook to fetch trust scores for multiple pubkeys in batch. @@ -23,10 +24,10 @@ export function useBatchTrustScores(pubkeys: string[]) { // Get user's follow list (kind 3) const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; - const followListEvents = await nostr.query( + const followListEvents = onlyVerified(await nostr.query( [{ kinds: [3], authors: [sourcePubkey], limit: 1 }], { signal } - ); + )); const followList = followListEvents[0]; if (!followList) { @@ -64,10 +65,10 @@ export function useBatchTrustScores(pubkeys: string[]) { const uncheckedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0); if (uncheckedPubkeys.length > 0) { - const friendsFollowLists = await nostr.query( + const friendsFollowLists = onlyVerified(await nostr.query( [{ kinds: [3], authors: Array.from(following), limit: 100 }], { signal } - ); + )); // Build a set of all friends-of-friends const friendsOfFriends = new Set(); @@ -89,10 +90,10 @@ export function useBatchTrustScores(pubkeys: string[]) { const unverifiedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0); if (unverifiedPubkeys.length > 0) { - const profileEvents = await nostr.query( + const profileEvents = onlyVerified(await nostr.query( [{ kinds: [0], authors: unverifiedPubkeys }], { signal } - ); + )); for (const profileEvent of profileEvents) { try { diff --git a/src/hooks/useRelatrScore.ts b/src/hooks/useRelatrScore.ts index 5a1e9a8..a86f2a4 100644 --- a/src/hooks/useRelatrScore.ts +++ b/src/hooks/useRelatrScore.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; +import { onlyVerified } from '@/lib/verifyEvents'; /** * Relatr NIP-85 Trust Score Hook @@ -81,7 +82,9 @@ export function useRelatrScore(targetPubkey: string | undefined, sourcePubkey?: // Query NIP-85 Trusted Assertion for this user // Kind 30382 = User trust assertions // d tag = target pubkey - const events = await nostr.relay(RELATR_CONFIG.relay).query( + // Signature-verify assertions: a forged kind-30382 (spoofed author, + // invalid sig) from a rogue relay must not grant fake trust. + const events = onlyVerified(await nostr.relay(RELATR_CONFIG.relay).query( [{ kinds: [30382], authors: [RELATR_CONFIG.servicePubkey], @@ -89,7 +92,7 @@ export function useRelatrScore(targetPubkey: string | undefined, sourcePubkey?: limit: 1, }], { signal } - ); + )); if (events.length === 0) { console.log(`[Relatr] No trust assertion found for ${targetPubkey.slice(0, 8)}`); @@ -161,7 +164,7 @@ export function useBatchRelatrScores(targetPubkeys: string[]) { try { // Query all trust assertions for these pubkeys - const events = await nostr.relay(RELATR_CONFIG.relay).query( + const events = onlyVerified(await nostr.relay(RELATR_CONFIG.relay).query( [{ kinds: [30382], authors: [RELATR_CONFIG.servicePubkey], @@ -169,7 +172,7 @@ export function useBatchRelatrScores(targetPubkeys: string[]) { limit: targetPubkeys.length, }], { signal } - ); + )); console.log(`[Relatr] Found ${events.length} trust assertions for ${targetPubkeys.length} pubkeys`); diff --git a/src/hooks/useTrustScore.ts b/src/hooks/useTrustScore.ts index 449c3f4..6dc14f8 100644 --- a/src/hooks/useTrustScore.ts +++ b/src/hooks/useTrustScore.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from './useCurrentUser'; +import { onlyVerified } from '@/lib/verifyEvents'; /** * Hook to calculate trust score for a Nostr pubkey using Web of Trust (WoT). @@ -35,10 +36,12 @@ export function useTrustScore(targetPubkey: string | undefined) { // Get user's follow list (kind 3) const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; - const followListEvents = await nostr.query( + // Signature-verify follow lists before deriving any trust from them — + // an unverified kind-3 from a malicious relay could inflate a score. + const followListEvents = onlyVerified(await nostr.query( [{ kinds: [3], authors: [sourcePubkey], limit: 1 }], { signal } - ); + )); const followList = followListEvents[0]; if (!followList) { @@ -58,10 +61,10 @@ export function useTrustScore(targetPubkey: string | undefined) { } // Check friend-of-friend (2 hops) - const friendsFollowLists = await nostr.query( + const friendsFollowLists = onlyVerified(await nostr.query( [{ kinds: [3], authors: Array.from(following), limit: 100 }], { signal } - ); + )); for (const friendList of friendsFollowLists) { const friendFollowing = friendList.tags @@ -74,10 +77,10 @@ export function useTrustScore(targetPubkey: string | undefined) { } // Check if they have NIP-05 verification (basic credibility) - const profileEvents = await nostr.query( + const profileEvents = onlyVerified(await nostr.query( [{ kinds: [0], authors: [targetPubkey], limit: 1 }], { signal } - ); + )); if (profileEvents[0]) { try { diff --git a/src/hooks/wallet/useCashu.ts b/src/hooks/wallet/useCashu.ts index 408f45a..80cf88c 100644 --- a/src/hooks/wallet/useCashu.ts +++ b/src/hooks/wallet/useCashu.ts @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { useLocalStorage } from '../useLocalStorage'; import * as bip39 from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core'; @@ -7,6 +6,37 @@ import { IndexedDbRepositories } from 'coco-cashu-indexeddb'; import type { Mint } from 'coco-cashu-core'; import { probeMintReachability } from '@/lib/mintReachability'; import { isNativePlatform } from '@/lib/platform'; +import { secureStorage } from '@/lib/secureStorage'; + +// Storage key for the wallet's BIP-39 mnemonic. On native it lives in the OS +// Keychain/KeyStore (via secureStorage); on web it falls back to localStorage. +const MNEMONIC_KEY = 'cashu-mnemonic'; + +/** + * Read the mnemonic from secure storage. Tolerates the legacy format where the + * old `useLocalStorage` hook JSON-stringified the value (i.e. a quoted string), + * so users upgrading from a plaintext-localStorage build are read correctly and + * transparently migrated to the Keychain/KeyStore on native. + */ +async function loadMnemonic(): Promise { + const raw = await secureStorage.getItem(MNEMONIC_KEY); + if (raw == null) return null; + // Legacy values were JSON.stringify'd ("\"word word…\""). Detect and unwrap. + if (raw.startsWith('"')) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'string') return parsed; + } catch { + // Not valid JSON — fall through and treat as a bare mnemonic. + } + } + return raw; +} + +/** Persist the mnemonic as a bare string (no JSON wrapping) to secure storage. */ +async function saveMnemonic(value: string): Promise { + await secureStorage.setItem(MNEMONIC_KEY, value); +} // Types from coco-cashu-core type CocoManager = Awaited>; @@ -37,7 +67,17 @@ export interface UseCashuReturn { mnemonic: string | null; setMnemonic: (mnemonic: string) => void; generateMnemonic: () => string; - + + /** + * True when a mnemonic was freshly auto-generated for the user this session + * and they have not yet confirmed they backed it up. The wallet UI should + * force a backup step (reveal + "I wrote it down") before letting funds + * accumulate — clearing app storage without a backup means permanent loss. + */ + needsBackup: boolean; + /** Mark the freshly-generated mnemonic as backed up (dismisses needsBackup). */ + confirmBackup: () => void; + // Utility functions clearError: () => void; getEncodedToken: typeof getEncodedToken; @@ -49,8 +89,26 @@ export function useCashu(): UseCashuReturn { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // Mnemonic management with localStorage - const [mnemonic, setMnemonicState] = useLocalStorage('cashu-mnemonic', null); + // Mnemonic management, backed by native secure storage (Keychain / KeyStore) + // with a localStorage fallback on web. Loaded asynchronously on mount. + const [mnemonic, setMnemonicState] = useState(null); + // True only for a mnemonic auto-generated this session that hasn't been + // acknowledged as backed up yet. Never set for a mnemonic loaded from storage + // (that one was created in a prior session and is assumed already handled). + const [needsBackup, setNeedsBackup] = useState(false); + + const confirmBackup = useCallback(() => setNeedsBackup(false), []); + + // Load any existing mnemonic from secure storage on mount (async). + useEffect(() => { + let cancelled = false; + loadMnemonic().then((m) => { + if (!cancelled && m) setMnemonicState(m); + }).catch((err) => { + console.warn('[Cashu] Failed to load mnemonic from secure storage:', err); + }); + return () => { cancelled = true; }; + }, []); // Manager instance ref const cocoRef = useRef(null); @@ -90,9 +148,15 @@ export function useCashu(): UseCashuReturn { throw new Error('Invalid mnemonic'); } setMnemonicState(newMnemonic); + // A user-supplied mnemonic (import/restore) is one they already possess, so + // no forced-backup prompt is needed. + setNeedsBackup(false); + saveMnemonic(newMnemonic).catch((err) => { + console.warn('[Cashu] Failed to persist mnemonic to secure storage:', err); + }); // Reinitialize manager with new mnemonic setIsInitialized(false); - }, [setMnemonicState]); + }, []); // Initialize the coco manager const initializeCocoManager = useCallback(async () => { @@ -127,11 +191,20 @@ export function useCashu(): UseCashuReturn { return; } - // Generate or retrieve mnemonic - let currentMnemonic = mnemonic; + // Generate or retrieve mnemonic. Prefer in-memory state, but fall back to + // a fresh async read of secure storage in case the mount-effect load + // hasn't resolved yet — this avoids generating a SECOND mnemonic and + // orphaning the user's existing funds. + let currentMnemonic = mnemonic ?? (await loadMnemonic()); if (!currentMnemonic) { currentMnemonic = bip39.generateMnemonic(wordlist); setMnemonicState(currentMnemonic); + // Freshly created for the user → require a backup acknowledgement. + setNeedsBackup(true); + await saveMnemonic(currentMnemonic); + } else if (currentMnemonic !== mnemonic) { + // Loaded from storage on this pass — sync it into state. + setMnemonicState(currentMnemonic); } // Create seed from mnemonic @@ -241,7 +314,7 @@ export function useCashu(): UseCashuReturn { } finally { setIsLoading(false); } - }, [isInitialized, isLoading, mnemonic, setMnemonicState]); + }, [isInitialized, isLoading, mnemonic]); // Initialize on mount and when mnemonic changes useEffect(() => { @@ -294,6 +367,8 @@ export function useCashu(): UseCashuReturn { mnemonic, setMnemonic, generateMnemonic, + needsBackup, + confirmBackup, clearError, getEncodedToken, refreshMints, diff --git a/src/lib/safeJson.ts b/src/lib/safeJson.ts new file mode 100644 index 0000000..2c0668d --- /dev/null +++ b/src/lib/safeJson.ts @@ -0,0 +1,24 @@ +/** + * Parse JSON without throwing. + * ───────────────────────────────────────────────────────────────────────────── + * `JSON.parse` on untrusted input (decrypted DM/gift-wrap payloads, relay event + * content, persisted localStorage blobs) throws on any malformed byte. A single + * bad value can crash an async message-processing loop (local DoS — messages + * stop rendering until reload) or crash-loop the app on boot before a Zod schema + * gets a chance to validate. + * + * `safeJson` returns the provided fallback instead of throwing, so callers can + * skip a bad item and keep going. Pair with schema validation (e.g. Zod) for the + * happy path — this only guarantees you never throw. + * + * @example + * const data = safeJson(raw, {} as MyShape); + * const parsed = MySchema.safeParse(safeJson(stored, {})); + */ +export function safeJson(raw: string, fallback: T): T { + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} diff --git a/src/lib/secureStorage.ts b/src/lib/secureStorage.ts new file mode 100644 index 0000000..9747c69 --- /dev/null +++ b/src/lib/secureStorage.ts @@ -0,0 +1,70 @@ +import { Capacitor } from '@capacitor/core'; +import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'; + +/** + * Storage adapter that uses native secure storage (iOS Keychain / Android + * KeyStore) on Capacitor builds and falls back to localStorage on web. + * + * API is async-compatible with the Web Storage interface (`getItem`, + * `setItem`, `removeItem`) — each method returns a Promise. + * + * On the first native read, if a key is not found in secure storage but + * exists in localStorage, it is automatically migrated to secure storage + * and the plaintext localStorage copy is removed. This means shipping a + * Capacitor build of an app that previously wrote to localStorage will + * transparently upgrade existing users' secrets to the Keychain/KeyStore + * the first time the app reads them. + * + * Use this for any sensitive state you don't want in plaintext on device: + * - The Cashu wallet BIP-39 mnemonic (master key to all ecash funds) + * - NWC (Nostr Wallet Connect) connection strings + * - Encryption passphrases / derived keys + * + * @example + * await secureStorage.setItem('cashu-mnemonic', 'word word word …'); + * const seed = await secureStorage.getItem('cashu-mnemonic'); + */ +export const secureStorage = { + async getItem(key: string): Promise { + if (!Capacitor.isNativePlatform()) { + return localStorage.getItem(key); + } + + try { + const { value } = await SecureStoragePlugin.get({ key }); + return value; + } catch { + // Key not found in secure storage — check localStorage for migration. + const legacy = localStorage.getItem(key); + if (legacy !== null) { + // Migrate to secure storage and remove the plaintext copy. + await SecureStoragePlugin.set({ key, value: legacy }); + localStorage.removeItem(key); + return legacy; + } + return null; + } + }, + + async setItem(key: string, value: string): Promise { + if (!Capacitor.isNativePlatform()) { + localStorage.setItem(key, value); + return; + } + + await SecureStoragePlugin.set({ key, value }); + }, + + async removeItem(key: string): Promise { + if (!Capacitor.isNativePlatform()) { + localStorage.removeItem(key); + return; + } + + try { + await SecureStoragePlugin.remove({ key }); + } catch { + // Key didn't exist — ignore. + } + }, +}; diff --git a/src/lib/verifyEvents.ts b/src/lib/verifyEvents.ts new file mode 100644 index 0000000..36644fe --- /dev/null +++ b/src/lib/verifyEvents.ts @@ -0,0 +1,38 @@ +import { verifyEvent } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Signature-verify a batch of events, dropping any that fail. + * ───────────────────────────────────────────────────────────────────────────── + * Relays are untrusted transport. Rendering event content is already XSS-safe + * throughout the app, but *trust decisions* (trust scores, moderation flags, + * listing authorship) must not be made on unverified data — a malicious or + * buggy relay can otherwise inject forged events that were never signed by the + * claimed author. + * + * `verifyEvent` checks both the event id (hash of the serialized event) and the + * Schnorr signature against the pubkey, so a passing event is provably authored + * by `event.pubkey`. Verification is ~1ms/event; memoize upstream if a hot path + * verifies the same events repeatedly. + * + * Never throws — a malformed event that makes `verifyEvent` throw is simply + * treated as invalid and filtered out. + */ +export function onlyVerified(events: NostrEvent[]): NostrEvent[] { + return events.filter((e) => { + try { + return verifyEvent(e); + } catch { + return false; + } + }); +} + +/** Single-event convenience wrapper. Returns false on any error. */ +export function isVerified(event: NostrEvent): boolean { + try { + return verifyEvent(event); + } catch { + return false; + } +}