Docs + a11y: document Cashu wallet seed storage/backup as tracked accepted-risk in SECURITY.md (F1 follow-up); add skip-to-content link and main landmark id (UX U4)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-24 18:22:30 +07:00
parent 00482fc332
commit 8e02c77ba1
2 changed files with 49 additions and 3 deletions

View file

@ -247,13 +247,51 @@ oversights:
no real protection (the decryption key would live in the same JS context). The
meaningful mitigation is preventing script injection (CSP shipped in the bundle
so it also covers mobile + no-`innerHTML` discipline) and offering
extension/bunker login for users who want keys off the page.
extension/bunker login for users who want keys off the page. (The Cashu wallet
*seed* is a partial exception — see "Known limitations — Cashu wallet" below.)
- **Broad `connect-src`/`img-src`.** Necessary for user-chosen relays, mints,
and media hosts. This permits data exfiltration *if* script execution is ever
achieved — which is why preventing XSS is the top priority.
- **`style-src 'unsafe-inline'`.** Required by the CSS framework; CSS-injection
defense is handled by input sanitization rather than CSP.
### Known limitations — Cashu wallet
The built-in Cashu ecash wallet is **native-only** (browsers block most mints via
CORS). Its BIP-39 mnemonic is the master key to all ecash funds, so it gets
stronger handling than other secrets:
- **Seed storage location (per platform).**
- **Native (Android / iOS):** the mnemonic is stored in the OS-backed secure
store — Android **KeyStore** / iOS **Keychain** — via
`capacitor-secure-storage-plugin` (`src/lib/secureStorage.ts`), **not** in
plaintext `localStorage`. Existing users on an older build are migrated
transparently on first read (the legacy plaintext `cashu-mnemonic` copy is
moved into secure storage and the plaintext removed).
- **Web:** falls back to `localStorage` (same accepted-risk posture as `nsec`
above). The wallet is effectively native-only in practice, so web plaintext
exposure is a minor surface.
- Ecash **proofs** (the spendable tokens) still live in IndexedDB
(`cashu-wallet`) in plaintext on device — a stolen unlocked device can spend
them, exactly like cash. Loss of the seed without a backup = permanent loss.
- **Backup behavior (Android / native).** A freshly auto-generated seed sets a
`needsBackup` flag that forces a **mandatory, non-dismissable backup step**
(`RecoveryPhraseDialog` in `requireConfirm` mode): the user must reveal the 12
words and explicitly confirm ("I've written it down") before the wallet is
usable. This prevents a silently generated seed from being left un-backed-up —
clearing app storage without a backup means the funds are unrecoverable. The
phrase can be re-viewed any time from the wallet ("View Recovery Phrase").
- **Planned / status.** Secure-storage migration is **shipped** (native seed now
in Keychain/KeyStore). Remaining accepted risk: proofs-at-rest in IndexedDB and
the web-only `localStorage` fallback. Encrypting proofs on-device is tracked as
a future hardening item but offers limited value while the key must live in the
same JS context (same reasoning as `nsec` above).
Relevant files: `src/hooks/wallet/useCashu.ts`, `src/lib/secureStorage.ts`,
`src/components/wallet/RecoveryPhraseDialog.tsx`.
---
## For auditors: where to look first
@ -266,7 +304,7 @@ oversights:
| Rich text rendering | `src/components/NoteContent.tsx` |
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`; seed in OS Keychain/KeyStore (secure storage), localStorage fallback on web | `src/hooks/wallet/useCashu.ts`, `src/lib/secureStorage.ts`, `src/lib/platform.ts` |
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` |
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |

View file

@ -484,6 +484,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
return (
<div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused, so keyboard and
screen-reader users can jump past the nav on every page. */}
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg"
>
Skip to content
</a>
{/* Desktop Header */}
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
<div className="container flex items-center justify-between h-16 px-4">
@ -967,7 +975,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* noPadding: full-bleed pages (map) manage their own height */}
<main className={noPadding ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
<main id="main" className={noPadding ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
{children}
</main>