feat(stash): password/key-encrypted backups for Export/Import
The stash export was plaintext JSON, so a leaked/shared file exposed the whole gear list. Export now wraps the payload in a version-2 JSON envelope: - password mode: scrypt (NIP-49 params, 2^16/r8/p1) -> AES-256-GCM, with the header (mode+cipher+KDF) bound as AAD so it can't be downgraded. - nip44 mode: NIP-44-encrypted to your own key (no password to remember; needs the matching key to open). - plaintext v1 exports still import unchanged. New src/lib/stashBackup.ts (verified round-trip, wrong-password, tamper and KDF-downgrade rejection) + StashBackupExportDialog/StashBackupUnlockDialog. The import picker only prompts for a password on password-encrypted files. Password mode needs no signer (Amber-friendly); 'My key' is hidden unless the signer supports NIP-44.
This commit is contained in:
parent
86c9f6f856
commit
0b50ce684f
6 changed files with 590 additions and 26 deletions
10
AGENTS.md
10
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
|
||||
|
|
|
|||
261
src/components/StashBackupDialogs.tsx
Normal file
261
src/components/StashBackupDialogs.tsx
Normal file
|
|
@ -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<void>;
|
||||
}) {
|
||||
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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm bg-black border-white/10 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-[#ff6600]" /> {t('walletUi.stash.exportTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
{t('walletUi.stash.exportDialogDesc', { count: itemCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('password')}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors',
|
||||
mode === 'password' ? 'border-[#ccff00] bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/10 text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5" /> {t('walletUi.stash.modePassword')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUseKey}
|
||||
onClick={() => canUseKey && setMode('nip44')}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors',
|
||||
mode === 'nip44' ? 'border-[#ccff00] bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/10 text-gray-400 hover:text-white',
|
||||
!canUseKey && 'cursor-not-allowed opacity-40',
|
||||
)}
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" /> {t('walletUi.stash.modeKey')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="stash-pw" className="text-xs text-gray-400">{t('walletUi.stash.password')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="stash-pw"
|
||||
type={show ? 'text' : 'password'}
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="bg-black/50 border-white/20 pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
aria-label={show ? t('walletUi.stash.hide') : t('walletUi.stash.show')}
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="stash-pw2" className="text-xs text-gray-400">{t('walletUi.stash.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="stash-pw2"
|
||||
type={show ? 'text' : 'password'}
|
||||
value={pw2}
|
||||
onChange={(e) => setPw2(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="bg-black/50 border-white/20"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] leading-snug text-gray-500">{t('walletUi.stash.passwordHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">{t('walletUi.stash.keyModeHint')}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="flex items-start gap-1.5 rounded-lg bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
onClick={() => run(mode)}
|
||||
disabled={busy}
|
||||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{t('walletUi.stash.export')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => run('plaintext')}
|
||||
className="text-center text-[11px] text-gray-500 underline hover:text-gray-300"
|
||||
>
|
||||
{t('walletUi.stash.exportPlaintext')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [pw, setPw] = useState('');
|
||||
const [show, setShow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm bg-black border-white/10 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-[#ff6600]" /> {t('walletUi.stash.unlockTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
{t('walletUi.stash.unlockDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stash-unlock" className="text-xs text-gray-400">{t('walletUi.stash.password')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="stash-unlock"
|
||||
type={show ? 'text' : 'password'}
|
||||
value={pw}
|
||||
onChange={(e) => 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(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
aria-label={show ? t('walletUi.stash.hide') : t('walletUi.stash.show')}
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="flex items-start gap-1.5 rounded-lg bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={busy || !pw}
|
||||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
|
||||
{t('walletUi.stash.unlock')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
210
src/lib/stashBackup.ts
Normal file
210
src/lib/stashBackup.ts
Normal file
|
|
@ -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<string>;
|
||||
decrypt(peer: string, ciphertext: string): Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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<CryptoKey> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<unknown> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
const [importParsed, setImportParsed] = useState<unknown>(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<StashItem, 'id' | 'addedAt'> =>
|
||||
!!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<StashItem, 'id' | 'addedAt'> =>
|
||||
!!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 = (
|
||||
<div className="space-y-3 rounded-2xl border border-white/6 bg-white/2 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -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}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> {t('walletUi.stash.export')}
|
||||
|
|
@ -1526,6 +1562,19 @@ export default function StashPage() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<StashBackupExportDialog
|
||||
open={exportOpen}
|
||||
onOpenChange={setExportOpen}
|
||||
itemCount={items.length}
|
||||
canUseKey={!!user?.signer?.nip44}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<StashBackupUnlockDialog
|
||||
open={unlockOpen}
|
||||
onOpenChange={setUnlockOpen}
|
||||
onUnlock={handleUnlock}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue