SkateSpots/src/lib/stashBackup.ts
SkateSpots 0b50ce684f 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.
2026-10-03 11:23:49 +07:00

210 lines
6.8 KiB
TypeScript

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