diff --git a/AGENTS.md b/AGENTS.md index 015e6cb..63b6b5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1324,7 +1324,15 @@ session with **no other memory of this project**. So: decrypted list.** Also added **Export/Import** on `/stash` (`Backup & transfer` card, desktop sidebar + mobile): export writes a JSON you control; import appends in ONE re-encrypt, skipping title+category+size duplicates - (`importItems`). **UX pass (same date):** the filter bar gained a **Sort** + (`importItems`). **Encrypted backups (2026-10-03):** `src/lib/stashBackup.ts` + wraps the export in a **version-2 JSON envelope** — `password` mode (scrypt + NIP-49 params 2^16/r8/p1 → **AES-256-GCM**, header bound as AAD so KDF/ + cipher can't be downgraded) or `nip44` mode (encrypted to your own key, no + password). Plaintext v1 files still import. `StashBackupExportDialog` + + `StashBackupUnlockDialog` (`components/StashBackupDialogs.tsx`); the picker + prompts for a password only for `password` files. ⚠️ Password mode needs no + signer (works with Amber); nip44 mode does. Verified round-trip / wrong + password / tamper / KDF-downgrade rejection in Node. **UX pass (same date):** the filter bar gained a **Sort** (Newest / Value / Condition / A–Z) and search now includes **`size`**; a **"Public stash"** card lists which items are public (with the profile link for "see what others see"); and **bulk actions** — a header **Select** toggle diff --git a/src/components/StashBackupDialogs.tsx b/src/components/StashBackupDialogs.tsx new file mode 100644 index 0000000..ffd8605 --- /dev/null +++ b/src/components/StashBackupDialogs.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Download, KeyRound, Lock, Eye, EyeOff, AlertTriangle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useTranslation } from '@/contexts/language'; +import { cn } from '@/lib/utils'; + +type ExportMode = 'password' | 'nip44' | 'plaintext'; + +/** Export the stash as a file, optionally password- or key-protected. */ +export function StashBackupExportDialog({ + open, + onOpenChange, + itemCount, + canUseKey, + onExport, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + itemCount: number; + canUseKey: boolean; + onExport: (opts: { mode: ExportMode; password?: string }) => Promise; +}) { + const { t } = useTranslation(); + const [mode, setMode] = useState<'password' | 'nip44'>('password'); + const [pw, setPw] = useState(''); + const [pw2, setPw2] = useState(''); + const [show, setShow] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setMode('password'); + setPw(''); + setPw2(''); + setShow(false); + setBusy(false); + setError(null); + } + }, [open]); + + const run = async (m: ExportMode) => { + if (m === 'password') { + if (pw.length < 8) return setError(t('walletUi.stash.pwTooShort')); + if (pw !== pw2) return setError(t('walletUi.stash.pwMismatch')); + } + setBusy(true); + setError(null); + try { + await onExport({ mode: m, password: m === 'password' ? pw : undefined }); + onOpenChange(false); + } catch (e) { + setError(e instanceof Error ? e.message : t('walletUi.stash.exportFailed')); + setBusy(false); + } + }; + + return ( + + + + + {t('walletUi.stash.exportTitle')} + + + {t('walletUi.stash.exportDialogDesc', { count: itemCount })} + + + +
+ {/* Mode toggle */} +
+ + +
+ + {mode === 'password' ? ( +
+
+ +
+ setPw(e.target.value)} + autoComplete="new-password" + className="bg-black/50 border-white/20 pr-9" + /> + +
+
+
+ + setPw2(e.target.value)} + autoComplete="new-password" + className="bg-black/50 border-white/20" + /> +
+

{t('walletUi.stash.passwordHint')}

+
+ ) : ( +

{t('walletUi.stash.keyModeHint')}

+ )} + + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} + +/** Prompt for the password of an encrypted backup on import. */ +export function StashBackupUnlockDialog({ + open, + onOpenChange, + onUnlock, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onUnlock: (password: string) => Promise; +}) { + const { t } = useTranslation(); + const [pw, setPw] = useState(''); + const [show, setShow] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setPw(''); + setShow(false); + setBusy(false); + setError(null); + } + }, [open]); + + const submit = async () => { + setBusy(true); + setError(null); + try { + await onUnlock(pw); + onOpenChange(false); + } catch (e) { + setError(e instanceof Error ? e.message : t('walletUi.stash.unlockFailed')); + setBusy(false); + } + }; + + return ( + + + + + {t('walletUi.stash.unlockTitle')} + + + {t('walletUi.stash.unlockDesc')} + + + +
+ +
+ setPw(e.target.value)} + autoComplete="current-password" + className="bg-black/50 border-white/20 pr-9" + onKeyDown={(e) => { if (e.key === 'Enter' && pw && !busy) void submit(); }} + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index fc6b5e6..ba6882f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6306,6 +6306,24 @@ walletUi: { noMatchDesc: 'Try adjusting your filters.', backupTransfer: 'Backup & transfer', backupDesc: "Your stash is encrypted on Nostr. Keep a copy you control — a dead relay or a new account can't lose your gear list.", + exportTitle: 'Encrypted backup', + exportDialogDesc: 'Save {{count}} item(s) to a file. Encrypt it so a leaked file is not a leak.', + modePassword: 'Password', + modeKey: 'My key', + password: 'Password', + confirmPassword: 'Confirm password', + passwordHint: "Use at least 8 characters. There is no recovery — don't forget it.", + keyModeHint: 'Encrypted to your Nostr key. Only that key can open it — nothing to remember.', + exportPlaintext: 'Export without encryption', + pwTooShort: 'Use at least 8 characters.', + pwMismatch: "Passwords don't match.", + exportFailed: 'Could not create the backup.', + unlockTitle: 'Unlock backup', + unlockDesc: 'This backup is password-protected.', + unlock: 'Unlock', + unlockFailed: 'Could not unlock the backup.', + show: 'Show', + hide: 'Hide', export: 'Export', import: 'Import', exported: 'Stash exported', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 22ef0aa..4bb4bf6 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6306,6 +6306,24 @@ walletUi: { noMatchDesc: 'Prueba a ajustar los filtros.', backupTransfer: 'Copia y transferencia', backupDesc: 'Tu stash está cifrado en Nostr. Guarda una copia que controles — así un relé caído o una cuenta nueva no te harán perder tu lista de material.', + exportTitle: 'Copia cifrada', + exportDialogDesc: 'Guarda {{count}} artículo(s) en un archivo. Cífralo para que un archivo filtrado no sea una filtración.', + modePassword: 'Contraseña', + modeKey: 'Mi clave', + password: 'Contraseña', + confirmPassword: 'Confirmar contraseña', + passwordHint: 'Usa al menos 8 caracteres. No hay recuperación: no la olvides.', + keyModeHint: 'Cifrado con tu clave de Nostr. Solo esa clave puede abrirlo: nada que recordar.', + exportPlaintext: 'Exportar sin cifrar', + pwTooShort: 'Usa al menos 8 caracteres.', + pwMismatch: 'Las contraseñas no coinciden.', + exportFailed: 'No se pudo crear la copia.', + unlockTitle: 'Desbloquear copia', + unlockDesc: 'Esta copia está protegida con contraseña.', + unlock: 'Desbloquear', + unlockFailed: 'No se pudo desbloquear la copia.', + show: 'Mostrar', + hide: 'Ocultar', export: 'Exportar', import: 'Importar', exported: 'Stash exportado', diff --git a/src/lib/stashBackup.ts b/src/lib/stashBackup.ts new file mode 100644 index 0000000..53287c2 --- /dev/null +++ b/src/lib/stashBackup.ts @@ -0,0 +1,210 @@ +/** + * Stash backup encryption — password- or key-protected export envelopes. + * + * The stash on Nostr is NIP-44-encrypted to you, but the *export file* used to + * be plaintext JSON, so a leaked file exposed the whole gear list. This wraps the + * same payload so a lost file isn't a leak: + * + * - `password` mode: scrypt (NIP-49 parameters: N=2^16, r=8, p=1) → AES-256-GCM. + * Independent of your Nostr key, so it survives a lost key / new account. + * - `nip44` mode: NIP-44-encrypted to your own key — nothing to remember, but + * only decryptable with that same nsec. + * + * Both are versioned JSON envelopes. Plaintext v1 exports still import, so this + * is fully backward compatible. + */ + +import { scrypt } from '@noble/hashes/scrypt.js'; + +export const STASH_EXPORT_VERSION = 2; + +/** scrypt work factor — 2^16 rounds, the same cost the app uses for NIP-49. */ +const LOG_N = 16; +const KDF = { algo: 'scrypt', logN: LOG_N, r: 8, p: 1 } as const; +const CIPHER = 'aes-256-gcm'; + +export type StashExportMode = 'plaintext' | 'password' | 'nip44'; + +interface PlaintextEnvelope { + app?: string; + type?: string; + version?: number; + exportedAt?: string; + items?: unknown; +} + +interface PasswordEnvelope { + app: 'SkateSpots'; + type: 'stash-export'; + version: number; + encrypted: true; + mode: 'password'; + kdf: { algo: 'scrypt'; logN: number; r: number; p: number; salt: string }; + cipher: string; + iv: string; + ciphertext: string; +} + +interface Nip44Envelope { + app: 'SkateSpots'; + type: 'stash-export'; + version: number; + encrypted: true; + mode: 'nip44'; + /** The pubkey the backup is encrypted to (must equal the logged-in key). */ + pubkey: string; + ciphertext: string; +} + +export interface StashSigner { + nip44?: { + encrypt(peer: string, plaintext: string): Promise; + decrypt(peer: string, ciphertext: string): Promise; + }; +} + +// ── base64 <-> bytes (browser + Node/JSDOM all provide btoa/atob) ──────────── +function toB64(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} +function fromB64(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +const utf8 = (s: string) => new TextEncoder().encode(s); +const fromUtf8 = (b: BufferSource) => new TextDecoder().decode(b); + +/** + * Associated data binds the header (mode + KDF params) to the ciphertext, so a + * tampered `logN`/`cipher`/`mode` breaks the AEAD tag instead of silently + * downgrading security. + */ +type Kdf = { algo: string; logN: number; r: number; p: number }; + +function aad(mode: string, kdf: Kdf = KDF, cipher: string = CIPHER): Uint8Array { + return utf8( + `SkateSpots|stash-export|v${STASH_EXPORT_VERSION}|${mode}|${cipher}|${kdf.algo}|logN=${kdf.logN}|r=${kdf.r}|p=${kdf.p}`, + ); +} + +async function deriveKey(password: string, salt: Uint8Array): Promise { + const raw = scrypt(password.normalize('NFKC'), salt, { N: 2 ** LOG_N, r: 8, p: 1, dkLen: 32 }); + return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']); +} + +/** Which mode a parsed file is in (`plaintext` for anything unrecognised). */ +export function stashExportMode(parsed: unknown): StashExportMode { + const o = parsed as { encrypted?: unknown; mode?: unknown } | null; + if (!o || typeof o !== 'object' || o.encrypted !== true) return 'plaintext'; + return o.mode === 'nip44' ? 'nip44' : 'password'; +} + +/** Build the standard plaintext payload (also what gets encrypted). */ +export function buildStashPayload(items: unknown): PlaintextEnvelope { + return { + app: 'SkateSpots', + type: 'stash-export', + version: STASH_EXPORT_VERSION, + exportedAt: new Date().toISOString(), + items, + }; +} + +export function createPlaintextExport(payload: unknown): string { + return JSON.stringify(payload, null, 2); +} + +/** Encrypt a payload under a password → JSON envelope string. */ +export async function createPasswordExport(payload: unknown, password: string): Promise { + if (!password) throw new Error('A password is required.'); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: aad('password') }, + key, + utf8(JSON.stringify(payload)), + ), + ); + const env: PasswordEnvelope = { + app: 'SkateSpots', + type: 'stash-export', + version: STASH_EXPORT_VERSION, + encrypted: true, + mode: 'password', + kdf: { ...KDF, salt: toB64(salt) }, + cipher: CIPHER, + iv: toB64(iv), + ciphertext: toB64(ciphertext), + }; + return JSON.stringify(env, null, 2); +} + +/** Encrypt a payload to the user's own Nostr key (NIP-44) → JSON envelope string. */ +export async function createNip44Export( + payload: unknown, + signer: StashSigner | undefined, + pubkey: string, +): Promise { + if (!signer?.nip44) throw new Error('Your signer does not support NIP-44 encryption.'); + const ciphertext = await signer.nip44.encrypt(pubkey, JSON.stringify(payload)); + const env: Nip44Envelope = { + app: 'SkateSpots', + type: 'stash-export', + version: STASH_EXPORT_VERSION, + encrypted: true, + mode: 'nip44', + pubkey, + ciphertext, + }; + return JSON.stringify(env, null, 2); +} + +/** + * Decrypt a parsed export back to its payload. `plaintext` passes through. + * Throws a human-readable error on a wrong password / wrong key / tampering. + */ +export async function decryptStashExport( + parsed: unknown, + opts: { password?: string; signer?: StashSigner; pubkey?: string }, +): Promise { + const mode = stashExportMode(parsed); + + if (mode === 'plaintext') return parsed; + + if (mode === 'password') { + const env = parsed as PasswordEnvelope; + if (!opts.password) throw new Error('A password is required.'); + const key = await deriveKey(opts.password, fromB64(env.kdf.salt)); + try { + const pt = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromB64(env.iv), additionalData: aad('password', env.kdf, env.cipher) }, + key, + fromB64(env.ciphertext), + ); + return JSON.parse(fromUtf8(pt)); + } catch { + throw new Error('Wrong password — or the file is damaged.'); + } + } + + // nip44 + const env = parsed as Nip44Envelope; + if (!opts.signer?.nip44) { + throw new Error('Log in with the account this backup was made for.'); + } + if (opts.pubkey && env.pubkey && opts.pubkey.toLowerCase() !== env.pubkey.toLowerCase()) { + throw new Error('This backup is encrypted to a different account.'); + } + try { + const pt = await opts.signer.nip44.decrypt(env.pubkey, env.ciphertext); + return JSON.parse(pt); + } catch { + throw new Error('Could not decrypt — log in as the account that made this backup.'); + } +} diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx index 627e1b3..d019865 100644 --- a/src/pages/StashPage.tsx +++ b/src/pages/StashPage.tsx @@ -26,6 +26,17 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash'; import { useStashShowcaseSync } from '@/hooks/useStashShowcase'; +import { StashBackupExportDialog, StashBackupUnlockDialog } from '@/components/StashBackupDialogs'; +import { + buildStashPayload, + createPlaintextExport, + createPasswordExport, + createNip44Export, + decryptStashExport, + stashExportMode, + type StashExportMode, + type StashSigner, +} from '@/lib/stashBackup'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSatsToUsd } from '@/hooks/useExchangeRate'; import { useUploadFile } from '@/hooks/useUploadFile'; @@ -648,18 +659,21 @@ export default function StashPage() { const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync(); const { toast } = useToast(); const fileInputRef = useRef(null); + const [exportOpen, setExportOpen] = useState(false); + const [unlockOpen, setUnlockOpen] = useState(false); + const [importParsed, setImportParsed] = useState(null); // ── Backup & transfer: your stash is encrypted on Nostr, but a local copy you // control means a dead relay (or a new account) can't lose your gear list. ── - const handleExport = () => { - const payload = { - app: 'SkateSpots', - type: 'stash-export', - version: 1, - exportedAt: new Date().toISOString(), - items, - }; - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const handleExport = async ({ mode, password }: { mode: StashExportMode; password?: string }) => { + const payload = buildStashPayload(items); + const text = + mode === 'plaintext' + ? createPlaintextExport(payload) + : mode === 'nip44' + ? await createNip44Export(payload, user?.signer, user?.pubkey ?? '') + : await createPasswordExport(payload, password ?? ''); + const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -671,24 +685,40 @@ export default function StashPage() { toast({ title: t('walletUi.stash.exported'), description: t('walletUi.stash.exportedDesc', { count: items.length }) }); }; + /** Decrypt (if needed) then merge an export into the stash. Throws on failure. */ + const runImport = async ( + parsed: unknown, + opts: { password?: string; signer?: StashSigner; pubkey?: string }, + ) => { + const payload = await decryptStashExport(parsed, opts); + const raw = Array.isArray(payload) ? payload : (payload as { items?: unknown })?.items; + if (!Array.isArray(raw)) throw new Error(t('walletUi.stash.noItemsInFile')); + const cleaned = raw.filter( + (i): i is Omit => + !!i && + typeof i === 'object' && + typeof (i as { title?: unknown }).title === 'string' && + typeof (i as { category?: unknown }).category === 'string', + ); + if (cleaned.length === 0) throw new Error(t('walletUi.stash.noValidItems')); + const res = await importItems.mutateAsync(cleaned); + toast({ + title: t('walletUi.stash.imported', { count: res.added }), + description: res.skipped > 0 ? t('walletUi.stash.importSkipped', { count: res.skipped }) : t('walletUi.stash.importAdded'), + }); + }; + const handleImportFile = async (file: File) => { try { const parsed = JSON.parse(await file.text()) as unknown; - const raw = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown })?.items; - if (!Array.isArray(raw)) throw new Error(t('walletUi.stash.noItemsInFile')); - const cleaned = raw.filter( - (i): i is Omit => - !!i && - typeof i === 'object' && - typeof (i as { title?: unknown }).title === 'string' && - typeof (i as { category?: unknown }).category === 'string', - ); - if (cleaned.length === 0) throw new Error(t('walletUi.stash.noValidItems')); - const res = await importItems.mutateAsync(cleaned); - toast({ - title: t('walletUi.stash.imported', { count: res.added }), - description: res.skipped > 0 ? t('walletUi.stash.importSkipped', { count: res.skipped }) : t('walletUi.stash.importAdded'), - }); + // Password-protected: prompt for it, then decrypt + import. + if (stashExportMode(parsed) === 'password') { + setImportParsed(parsed); + setUnlockOpen(true); + return; + } + // Plaintext or NIP-44 (needs the matching logged-in key). + await runImport(parsed, { signer: user?.signer, pubkey: user?.pubkey }); } catch (e) { toast({ title: t('walletUi.stash.importFailed'), @@ -698,6 +728,12 @@ export default function StashPage() { } }; + /** Called by the unlock dialog: throws (dialog shows the error) or resolves. */ + const handleUnlock = async (password: string) => { + await runImport(importParsed, { password }); + setImportParsed(null); + }; + const backupCard = (
@@ -712,7 +748,7 @@ export default function StashPage() { size="sm" variant="outline" className="flex-1 gap-1.5 border-white/15" - onClick={handleExport} + onClick={() => setExportOpen(true)} disabled={items.length === 0} > {t('walletUi.stash.export')} @@ -1526,6 +1562,19 @@ export default function StashPage() { + + + ); }