/** * 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.'); } }