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.
210 lines
6.8 KiB
TypeScript
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.');
|
|
}
|
|
}
|