From 7ecade32eeb277d9bd6df734ddc76232f06bdee0 Mon Sep 17 00:00:00 2001 From: protom Date: Wed, 19 Aug 2026 23:55:59 +0200 Subject: [PATCH] feat: nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore --- package-lock.json | 105 ++++++ package.json | 1 + src/lnurlcash/nostr/events.ts | 221 +++++++++++++ src/lnurlcash/nostr/publisher.ts | 75 +++++ src/lnurlcash/nostr/sync.ts | 152 +++++++++ src/lnurlcash/nostr/transport.ts | 32 ++ src/lnurlcash/nostrBackup.test.ts | 525 ++++++++++++++++++++++++++++++ src/lnurlcash/nostrBackup.ts | 47 +++ src/lnurlcash/storage/backup.ts | 30 +- 9 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 src/lnurlcash/nostr/events.ts create mode 100644 src/lnurlcash/nostr/publisher.ts create mode 100644 src/lnurlcash/nostr/sync.ts create mode 100644 src/lnurlcash/nostr/transport.ts create mode 100644 src/lnurlcash/nostrBackup.test.ts create mode 100644 src/lnurlcash/nostrBackup.ts diff --git a/package-lock.json b/package-lock.json index 231513c..162b1b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", "lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install", + "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", "quasar": "^2.25.1", @@ -1604,6 +1605,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", @@ -5614,6 +5627,98 @@ "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", "license": "MIT" }, + "node_modules/nostr-tools": { + "version": "2.24.3", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.3.tgz", + "integrity": "sha512-8vPgdWuje+H0v2bnxo1/hZOi8VLLMqGjfO+Oxqkx+J22r76rIt/iICKZKBoTXWAP2qTYKCQv89M4A3PRc48JYw==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index 3d23e70..c85f9f2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", "lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install", + "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", "quasar": "^2.25.1", diff --git a/src/lnurlcash/nostr/events.ts b/src/lnurlcash/nostr/events.ts new file mode 100644 index 0000000..7a3b04d --- /dev/null +++ b/src/lnurlcash/nostr/events.ts @@ -0,0 +1,221 @@ +// The pure half of nostr backup: backup-key derivation and the backup +// event codec - build, sign, verify, decrypt - with no relay I/O. +// +// The backup key is derived, not generated: sha256 over the linking key +// plus a fixed context string, the same construction as keys.ts's +// deriveBearerAesKey. The seed phrase itself is never stored, so the +// derivation starts from the linking key - deterministic all the way down, +// which is what makes restore-from-seed work on a fresh device. +// +// Three d-tags carry three payloads in separate replaceable slots, so a +// notes publish never clobbers settings: +// notes - the encrypted bearer records exactly as they sit in +// localStorage (each note is already an AES-GCM ciphertext +// under the seed-derived bearer key, so the blob goes up +// as-is; the linking key itself is NEVER part of a payload - +// the seed phrase is its recovery path) +// mints - the trusted-mint registry +// settings - plaintext wallet settings + +import {sha256} from '@noble/hashes/sha2.js' +import {utf8ToBytes} from '@noble/hashes/utils.js' +import type {Event as NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey, verifyEvent} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' + +import type {EncryptedBearerRecord} from '../storage/bearers' +import type {TrustedMint} from '../trustedMints' +import type {WalletSettings} from '../storage/settings' + +export type {NostrEvent} + +// addressable (parametrized-replaceable) app-data event - relays keep only +// the newest event per (pubkey, kind, d-tag) +export const BACKUP_EVENT_KIND = 30078 + +export type BackupPart = 'notes' | 'mints' | 'settings' + +export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'] + +// the backup key is dedicated to this wallet (derived with a +// sattle-specific context, see below), so its pubkey is ours alone and the +// d-tags need no further namespacing +export const BACKUP_D_TAGS: Record = { + notes: 'notes', + mints: 'mints', + settings: 'settings' +} + +// the decrypted payload of each part, without its envelope +export type BackupPartPayload = { + notes: EncryptedBearerRecord[] + mints: TrustedMint[] + settings: WalletSettings +} + +const BACKUP_KEY_CONTEXT = 'sattle-nostr-backup-v1' + +// Deterministic: sha256(linking key || context), mirroring keys.ts's +// deriveBearerAesKey. The result is a secp256k1 secret key used ONLY for +// backup - it signs and decrypts backup events, nothing else. +export const deriveBackupKey = (linkingPrivKey: Uint8Array): Uint8Array => + sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)])) + +// the x-only nostr pubkey identifying this wallet's backup events +export const backupPubkey = (secretKey: Uint8Array): string => + getPublicKey(secretKey) + +// NIP-44 "self-DM": the conversation key between the backup key and its own +// pubkey - decryptable by the seed holder and nobody else +const selfConversationKey = (secretKey: Uint8Array): Uint8Array => + nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) + +// a relay could serve a multi-megabyte content string; JSON.parse and +// NIP-44 decrypt of that would hang the tab. A real backup is a handful of +// kilobytes per part - this is far beyond generous (applyBackup's own +// per-record bounds still apply on top after decrypt) +const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 + +export const dTagOf = (event: NostrEvent): string => + event.tags.find(t => t[0] === 'd')?.[1] ?? '' + +const envelopeFor = ( + part: BackupPart, + payload: BackupPartPayload[BackupPart] +): string => { + switch (part) { + case 'notes': + return JSON.stringify({version: 1, bearers: payload}) + case 'mints': + return JSON.stringify({version: 1, trustedMints: payload}) + case 'settings': + return JSON.stringify({version: 1, settings: payload}) + } +} + +// signs one addressable backup event for a single part +export const buildBackupEvent =

( + secretKey: Uint8Array, + part: P, + payload: BackupPartPayload[P], + createdAt: number = Math.floor(Date.now() / 1000) +): NostrEvent => + finalizeEvent( + { + kind: BACKUP_EVENT_KIND, + created_at: createdAt, + tags: [['d', BACKUP_D_TAGS[part]]], + content: nip44v2.encrypt( + envelopeFor(part, payload), + selfConversationKey(secretKey) + ) + }, + secretKey + ) + +// one event per part present in `parts`, all sharing one timestamp +export const buildBackupEvents = ( + secretKey: Uint8Array, + parts: Partial, + createdAt?: number +): NostrEvent[] => { + const at = createdAt ?? Math.floor(Date.now() / 1000) + const events: NostrEvent[] = [] + for (const part of BACKUP_PARTS) { + const payload = parts[part] + if (payload === undefined) continue + events.push(buildBackupEvent(secretKey, part, payload, at)) + } + return events +} + +export type ParsedBackupEvent = + | {part: 'notes'; bearers: EncryptedBearerRecord[]} + | {part: 'mints'; trustedMints: TrustedMint[]} + | {part: 'settings'; settings: WalletSettings} + +// Light shape checks so parse returns typed values; the strict bounds +// (record counts, field lengths, pubkey patterns) are enforced by +// applyBackup / mergeTrustedMints on the restore path, same as file +// backups. +const parsePayload = ( + dTag: BackupPart, + data: unknown +): ParsedBackupEvent | null => { + if (typeof data !== 'object' || data === null) return null + const envelope = data as Record + if (envelope.version !== 1) return null + switch (dTag) { + case 'notes': { + if (!Array.isArray(envelope.bearers)) return null + const bearers = envelope.bearers as unknown[] + if ( + !bearers.every( + r => + typeof (r as EncryptedBearerRecord)?.id === 'string' && + typeof (r as EncryptedBearerRecord)?.iv === 'string' && + typeof (r as EncryptedBearerRecord)?.ciphertext === 'string' + ) + ) { + return null + } + return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]} + } + case 'mints': { + if (!Array.isArray(envelope.trustedMints)) return null + const mints = envelope.trustedMints as unknown[] + if ( + !mints.every( + m => + typeof (m as TrustedMint)?.server === 'string' && + typeof (m as TrustedMint)?.mintPubkey === 'string' + ) + ) { + return null + } + return {part: 'mints', trustedMints: mints as TrustedMint[]} + } + case 'settings': { + if (typeof envelope.settings !== 'object' || envelope.settings === null) { + return null + } + const settings = envelope.settings as Record + if ( + settings.defaultMint !== undefined && + typeof settings.defaultMint !== 'string' + ) { + return null + } + return {part: 'settings', settings: settings as WalletSettings} + } + } +} + +// Validates and decrypts one backup event. Returns null for anything that +// isn't a genuine, untampered backup of THIS key: wrong kind, wrong d-tag, +// wrong author, bad signature, undecryptable or malformed payload. Callers +// skip nulls - one junk event must never sink a restore. +export const parseBackupEvent = ( + secretKey: Uint8Array, + event: NostrEvent +): ParsedBackupEvent | null => { + if (event.kind !== BACKUP_EVENT_KIND) return null + if (event.pubkey !== getPublicKey(secretKey)) return null + const dTag = dTagOf(event) + if (dTag !== 'notes' && dTag !== 'mints' && dTag !== 'settings') return null + if (!verifyEvent(event)) return null + if (event.content.length > MAX_BACKUP_CONTENT_CHARS) return null + let plaintext: string + try { + plaintext = nip44v2.decrypt(event.content, selfConversationKey(secretKey)) + } catch { + return null + } + let data: unknown + try { + data = JSON.parse(plaintext) + } catch { + return null + } + return parsePayload(dTag, data) +} diff --git a/src/lnurlcash/nostr/publisher.ts b/src/lnurlcash/nostr/publisher.ts new file mode 100644 index 0000000..8c0e79b --- /dev/null +++ b/src/lnurlcash/nostr/publisher.ts @@ -0,0 +1,75 @@ +// The debounced publisher the stores wire to their change events: rapid +// edits (a mint plus a receive plus a reorder) collapse into one publish +// of the final state. Trailing-edge debounce with in-flight coalescing. + +import type {BackupPartPayload} from './events' + +export type BackupPublisher = { + // record that local state changed - the latest snapshot wins, and only + // one publish fires per quiet window no matter how many changes landed + schedule: (parts: Partial) => void + // publish any pending snapshot immediately (app backgrounding, logout) + flush: () => Promise + // drop any pending snapshot without publishing + cancel: () => void +} + +export type BackupPublisherOptions = { + publish: (parts: Partial) => Promise + delayMs: number + // a debounced publish has no caller to throw to - errors surface here; + // the next scheduled change retries + onError?: (error: unknown) => void +} + +export const createBackupPublisher = ( + options: BackupPublisherOptions +): BackupPublisher => { + let timer: ReturnType | null = null + let pending: Partial | null = null + let running: Promise | null = null + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const drain = async (): Promise => { + // a change can land mid-publish - keep looping until the newest + // snapshot is the one that's out + while (pending !== null) { + const snapshot = pending + pending = null + try { + await options.publish(snapshot) + } catch (error) { + options.onError?.(error) + } + } + } + + const fire = (): Promise => { + clearTimer() + // an in-flight drain picks up anything pending itself - a second drain + // would double-publish the same snapshot + running ??= drain().finally(() => { + running = null + }) + return running + } + + return { + schedule: parts => { + pending = parts + clearTimer() + timer = setTimeout(() => void fire(), options.delayMs) + }, + flush: fire, + cancel: () => { + clearTimer() + pending = null + } + } +} diff --git a/src/lnurlcash/nostr/sync.ts b/src/lnurlcash/nostr/sync.ts new file mode 100644 index 0000000..9a0dc43 --- /dev/null +++ b/src/lnurlcash/nostr/sync.ts @@ -0,0 +1,152 @@ +// Publish / fetch / restore for nostr backup, over an injected transport. +// +// v1 is single-device last-writer-wins at the relay (addressable events +// replace). Restore merges locally through storage/backup.ts's applyBackup +// - the SAME entry point as file restore (union by record id, mints merged +// unconfirmed, settings fill-only) - so the two restore paths can't drift +// apart. The note-level dedupe (same note under different record ids, +// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly +// as with a file backup. + +import type {RestoreResult} from '../storage/backup' +import {applyBackup} from '../storage/backup' + +import type { + BackupPart, + BackupPartPayload, + NostrEvent +} from './events' +import { + BACKUP_EVENT_KIND, + BACKUP_PARTS, + backupPubkey, + buildBackupEvent, + deriveBackupKey, + dTagOf, + parseBackupEvent +} from './events' +import type {BackupTransport} from './transport' +import {defaultTransport} from './transport' + +export type PublishBackupOptions = { + transport?: BackupTransport + createdAt?: number +} + +export type PublishBackupResult = { + published: BackupPart[] +} + +// builds and publishes one event per part present in `parts` +export const publishBackup = async ( + secretKey: Uint8Array, + parts: Partial, + relays: string[], + options: PublishBackupOptions = {} +): Promise => { + const at = options.createdAt ?? Math.floor(Date.now() / 1000) + const present = BACKUP_PARTS.filter(part => parts[part] !== undefined) + if (present.length === 0) return {published: []} + const transport = options.transport ?? (await defaultTransport()) + const published: BackupPart[] = [] + for (const part of present) { + const payload = parts[part] + if (payload === undefined) continue + await transport.publish( + relays, + buildBackupEvent(secretKey, part, payload, at) + ) + published.push(part) + } + return {published} +} + +export type FetchBackupOptions = { + // the pubkey alone can fetch the ciphertext, but only the key holder + // reads it - decryption is part of fetching + secretKey: Uint8Array + transport?: BackupTransport +} + +// fetches the newest valid event per d-tag and decrypts it. Honest relays +// already replace addressable events, but a stale or misbehaving relay may +// serve older copies, so the newest is picked client-side; events that +// fail validation or decryption are skipped, not fatal. +export const fetchBackup = async ( + pubkey: string, + relays: string[], + options: FetchBackupOptions +): Promise> => { + const transport = options.transport ?? (await defaultTransport()) + const events = await transport.fetch(relays, { + kinds: [BACKUP_EVENT_KIND], + authors: [pubkey] + }) + const byTag = new Map() + for (const event of events) { + const d = dTagOf(event) + if (!d) continue + const candidates = byTag.get(d) + if (candidates) candidates.push(event) + else byTag.set(d, [event]) + } + const parts: Partial = {} + for (const candidates of byTag.values()) { + // a hostile relay may serve a tampered "newest" copy - walk + // newest-first and take the first that validates and decrypts + candidates.sort((a, b) => b.created_at - a.created_at) + for (const event of candidates) { + const parsed = parseBackupEvent(options.secretKey, event) + if (!parsed) continue + switch (parsed.part) { + case 'notes': + parts.notes = parsed.bearers + break + case 'mints': + parts.mints = parsed.trustedMints + break + case 'settings': + parts.settings = parsed.settings + break + } + break + } + } + return parts +} + +export type NostrRestoreResult = RestoreResult & { + // which d-tags carried a valid payload at all - lets the caller + // distinguish "nothing backed up yet" from "restored an empty wallet" + found: BackupPart[] +} + +// The restore path end to end: re-derive the backup key from the linking +// key (the holder already re-entered the seed phrase to get that far), +// fetch every part, and hand the lot to storage's applyBackup as an +// ordinary sattle backup - the same merge entry point as file restore, so +// union-by-id, mint trust rules and settings fill-only behave identically +// no matter where the backup came from. +export const restoreFromNostr = async ( + linkingPrivKey: Uint8Array, + relays: string[], + options: {transport?: BackupTransport} = {} +): Promise => { + const secretKey = deriveBackupKey(linkingPrivKey) + const parts = await fetchBackup(backupPubkey(secretKey), relays, { + secretKey, + transport: options.transport + }) + const result = applyBackup({ + type: 'sattle-backup', + version: 1, + createdAt: Date.now(), + bearers: parts.notes ?? [], + trustedMints: parts.mints, + settings: parts.settings + }) + return { + ...result, + found: BACKUP_PARTS.filter(part => parts[part] !== undefined) + } +} diff --git a/src/lnurlcash/nostr/transport.ts b/src/lnurlcash/nostr/transport.ts new file mode 100644 index 0000000..95e1870 --- /dev/null +++ b/src/lnurlcash/nostr/transport.ts @@ -0,0 +1,32 @@ +// The relay-facing transport for nostr backup, kept injectable so tests +// never touch a network. The default is nostr-tools' SimplePool, imported +// lazily: merely importing the backup module must never open (or even +// reference) a WebSocket. + +import type {Filter as NostrFilter} from 'nostr-tools/filter' + +import type {NostrEvent} from './events' + +export type {NostrFilter} + +// publish throws when the event was accepted NOWHERE +export type BackupTransport = { + publish: (relays: string[], event: NostrEvent) => Promise + fetch: (relays: string[], filter: NostrFilter) => Promise +} + +export const defaultTransport = async (): Promise => { + const {SimplePool} = await import('nostr-tools/pool') + const pool = new SimplePool() + return { + publish: async (relays, event) => { + const results = await Promise.allSettled(pool.publish(relays, event)) + // one honest relay keeping the event is enough - addressable events + // are re-publishable, and the next debounced publish retries anyway + if (!results.some(r => r.status === 'fulfilled')) { + throw new Error('No relay accepted the backup event.') + } + }, + fetch: (relays, filter) => pool.querySync(relays, filter) + } +} diff --git a/src/lnurlcash/nostrBackup.test.ts b/src/lnurlcash/nostrBackup.test.ts new file mode 100644 index 0000000..919e8d4 --- /dev/null +++ b/src/lnurlcash/nostrBackup.test.ts @@ -0,0 +1,525 @@ +// Nostr backup: key derivation stability, event build/parse round-trips, +// tamper rejection, publish/fetch and restore against an in-memory relay +// (the transport is injected - no network), and the debounced publisher. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' +import type {NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey} from './keys' +import { + BACKUP_EVENT_KIND, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + createBackupPublisher, + deriveBackupKey, + fetchBackup, + parseBackupEvent, + publishBackup, + restoreFromNostr +} from './nostrBackup' +import type {BackupPartPayload, BackupTransport} from './nostrBackup' +import { + loadBearers, + loadSettings, + mergeBearers, + persistBearer, + persistSettings, + readEncryptedBearers +} from './storage' +import type {Bearer} from './types' +import { + addTrustedMint, + clearTrustedMints, + isMintUnconfirmed, + readTrustedMints +} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const MINT_PUBKEY = 'ab'.repeat(33) + +// never connected - the recording transport below stands in for the relays +const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example'] + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: 'fixture', + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides +}) + +// an in-memory relay set. It serves EVERY event it ever accepted, older +// addressable copies included - like a relay that never replaces - which +// is exactly the case fetchBackup's client-side latest-pick exists for +const createRecordingTransport = (): { + transport: BackupTransport + events: NostrEvent[] +} => { + const events: NostrEvent[] = [] + const transport: BackupTransport = { + publish: (_relays, event) => { + events.push(event) + return Promise.resolve() + }, + fetch: (_relays, filter) => + Promise.resolve( + events.filter( + e => + (!filter.kinds || filter.kinds.includes(e.kind)) && + (!filter.authors || filter.authors.includes(e.pubkey)) + ) + ) + } + return {transport, events} +} + +// flips the end of a base64 payload to different-but-valid characters +const tamperContent = (content: string): string => + content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA') + +beforeEach(() => { + stubLocalStorage() + // the trusted-mint registry caches module-level - reset it alongside + // the storage stub + clearTrustedMints() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('deriveBackupKey', () => { + it('derives a stable key from the linking key', () => { + // pinned: changing the context string or the construction would + // silently orphan every backup ever published - the wallet would + // derive a different pubkey and find nothing to restore + expect(bytesToHex(deriveBackupKey(LINKING_KEY))).toBe( + 'a583f5740869d240d3052442957a46ec5f2534f8ae0284f7f7f8b03d602edad9' + ) + }) + + it('derives a different key from a different linking key', () => { + expect(bytesToHex(deriveBackupKey(OTHER_KEY))).not.toBe( + bytesToHex(deriveBackupKey(LINKING_KEY)) + ) + }) +}) + +describe('buildBackupEvent / parseBackupEvent', () => { + const secretKey = deriveBackupKey(LINKING_KEY) + + const records = [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}] + const mints = [ + {server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true} + ] + const settings = {defaultMint: 'mint.example'} + + it('round-trips all three parts through build and parse', () => { + const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) + expect(events).toHaveLength(3) + expect(events.map(e => e.kind)).toEqual([ + BACKUP_EVENT_KIND, + BACKUP_EVENT_KIND, + BACKUP_EVENT_KIND + ]) + expect(events.map(e => e.tags)).toEqual([ + [['d', 'notes']], + [['d', 'mints']], + [['d', 'settings']] + ]) + expect(events.every(e => e.pubkey === backupPubkey(secretKey))).toBe(true) + + expect(parseBackupEvent(secretKey, events[0]!)).toEqual({ + part: 'notes', + bearers: records + }) + expect(parseBackupEvent(secretKey, events[1]!)).toEqual({ + part: 'mints', + trustedMints: mints + }) + expect(parseBackupEvent(secretKey, events[2]!)).toEqual({ + part: 'settings', + settings + }) + }) + + it('leaves no plaintext in the payload', () => { + const event = buildBackupEvent(secretKey, 'notes', records) + expect(event.content).not.toContain('r1') + expect(event.content).not.toContain('ciphertext') + }) + + it('builds events only for the parts present', () => { + const events = buildBackupEvents(secretKey, {settings}, 1000) + expect(events).toHaveLength(1) + expect(events[0]!.tags).toEqual([['d', 'settings']]) + }) + + it('rejects a payload encrypted for a different key', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(deriveBackupKey(OTHER_KEY), event)).toBeNull() + }) + + it('rejects the wrong kind', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(secretKey, {...event, kind: 30079})).toBeNull() + }) + + it('rejects an unknown d-tag', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + expect(parseBackupEvent(secretKey, {...event, tags: [['d', 'secrets']]})).toBeNull() + }) + + it('rejects a modified ciphertext - the signature no longer matches', () => { + const event = buildBackupEvent(secretKey, 'settings', settings) + const tampered = {...event, content: tamperContent(event.content)} + expect(parseBackupEvent(secretKey, tampered)).toBeNull() + }) + + it('rejects an event signed by a different key', () => { + const foreign = buildBackupEvent(deriveBackupKey(OTHER_KEY), 'settings', settings) + expect(parseBackupEvent(secretKey, foreign)).toBeNull() + }) + + it('rejects a validly signed event whose payload is not a backup envelope', () => { + // a same-key event of the right kind and d-tag, but its decrypted + // content is not a version-1 envelope + const conversationKey = nip44v2.utils.getConversationKey( + secretKey, + getPublicKey(secretKey) + ) + const event = finalizeEvent( + { + kind: BACKUP_EVENT_KIND, + created_at: 1000, + tags: [['d', 'settings']], + content: nip44v2.encrypt( + JSON.stringify({version: 2, settings: {}}), + conversationKey + ) + }, + secretKey + ) + expect(parseBackupEvent(secretKey, event)).toBeNull() + }) +}) + +describe('publishBackup / fetchBackup', () => { + const secretKey = deriveBackupKey(LINKING_KEY) + + it('publishes every part and fetches them back decrypted', async () => { + const {transport} = createRecordingTransport() + const parts = { + notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], + mints: [ + {server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false} + ], + settings: {defaultMint: 'mint.example'} + } + const published = await publishBackup(secretKey, parts, RELAYS, {transport}) + expect(published.published).toEqual(['notes', 'mints', 'settings']) + + const fetched = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport + }) + expect(fetched).toEqual(parts) + }) + + it('publishes nothing when no parts are given', async () => { + const {transport, events} = createRecordingTransport() + const result = await publishBackup(secretKey, {}, RELAYS, {transport}) + expect(result.published).toEqual([]) + expect(events).toEqual([]) + }) + + it('picks the newest event per d-tag when a relay serves stale copies', async () => { + const {transport} = createRecordingTransport() + await publishBackup(secretKey, {settings: {defaultMint: 'old.example'}}, RELAYS, { + transport, + createdAt: 1000 + }) + await publishBackup(secretKey, {settings: {defaultMint: 'new.example'}}, RELAYS, { + transport, + createdAt: 2000 + }) + // the recording transport serves BOTH - the newer must win + const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport + }) + expect(parts.settings).toEqual({defaultMint: 'new.example'}) + }) + + it('falls back to an older valid copy when the newest event is tampered', async () => { + const {transport, events} = createRecordingTransport() + await publishBackup(secretKey, {settings: {defaultMint: 'mint.example'}}, RELAYS, { + transport, + createdAt: 1000 + }) + events.push({ + ...events[0]!, + content: tamperContent(events[0]!.content), + created_at: 3000 + }) + const parts = await fetchBackup(backupPubkey(secretKey), RELAYS, { + secretKey, + transport + }) + expect(parts.settings).toEqual({defaultMint: 'mint.example'}) + }) +}) + +describe('restoreFromNostr', () => { + it('restores notes, mints and settings onto a fresh device through applyBackup', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A: one note, one trusted mint, one setting - all published + await persistBearer(aesKey, bearerFixture({id: 'note-a'})) + addTrustedMint('mint.example', MINT_PUBKEY) + persistSettings({defaultMint: 'mint.example'}) + await publishBackup( + secretKey, + { + notes: readEncryptedBearers(), + mints: readTrustedMints(), + settings: loadSettings() + }, + RELAYS, + {transport} + ) + + // device B: the same seed on empty storage + stubLocalStorage() + clearTrustedMints() + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + expect(result.found).toEqual(['notes', 'mints', 'settings']) + expect(result.added).toBe(1) + expect(result.trustedMintsAdded).toBe(1) + expect(result.settingsRestored).toBe(true) + // the linking key is never part of a nostr backup - the seed phrase + // the holder entered is its recovery path + expect(result.linkingKeyRestored).toBe(false) + + // the note decrypts under this device's bearer key - same seed + expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) + expect(loadSettings()).toEqual({defaultMint: 'mint.example'}) + // a file/backup-sourced mint pin stays unconfirmed until a live + // response corroborates it - nostr restore inherits that rule from + // applyBackup unchanged + expect(isMintUnconfirmed('mint.example')).toBe(true) + }) + + it('unions records by id and lets a spent copy win after decrypt', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A publishes its store holding the spendable note + await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) + await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + transport, + createdAt: 1000 + }) + + // device B restores, then marks the same note spent under its OWN + // record id, and republishes its full store + stubLocalStorage() + clearTrustedMints() + await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) + await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + transport, + createdAt: 2000 + }) + + // device C restores from the final published state + stubLocalStorage() + clearTrustedMints() + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + // union by record id: both copies landed + expect(result.added).toBe(2) + expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['rec-a', 'rec-b']) + + // after decrypt, the note-level merge (same server + k1) collapses + // them, and the spent copy wins even though its record is the newer + // arrival - a restored backup must never resurrect spendable money + const merged = mergeBearers([], await loadBearers(aesKey)) + expect(merged).toHaveLength(1) + expect(merged[0]!.id).toBe('rec-b') + expect(merged[0]!.spent).toBe(true) + }) + + it('never overwrites local state: records union, settings keep local values', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + await persistBearer(aesKey, bearerFixture({id: 'remote'})) + persistSettings({defaultMint: 'remote.example'}) + await publishBackup( + secretKey, + {notes: readEncryptedBearers(), settings: loadSettings()}, + RELAYS, + {transport, createdAt: 1000} + ) + + // this device already has its own wallet state + stubLocalStorage() + clearTrustedMints() + await persistBearer( + aesKey, + bearerFixture({id: 'local', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) + ) + persistSettings({defaultMint: 'local.example'}) + + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + expect(result.added).toBe(1) + expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['local', 'remote']) + expect(result.settingsRestored).toBe(false) + expect(loadSettings()).toEqual({defaultMint: 'local.example'}) + }) + + it('reports nothing found when the relays hold no backup', async () => { + const {transport} = createRecordingTransport() + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + expect(result.found).toEqual([]) + expect(result.added).toBe(0) + }) +}) + +describe('createBackupPublisher', () => { + it('coalesces rapid schedules into a single publish of the latest snapshot', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: p => { + published.push(p) + return Promise.resolve() + }, + delayMs: 1000 + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + publisher.schedule({settings: {defaultMint: 'b'}}) + publisher.schedule({settings: {defaultMint: 'c'}}) + await vi.advanceTimersByTimeAsync(999) + expect(published).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + expect(published).toEqual([{settings: {defaultMint: 'c'}}]) + }) + + it('publishes again when a change lands after the quiet window', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: p => { + published.push(p) + return Promise.resolve() + }, + delayMs: 1000 + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await vi.advanceTimersByTimeAsync(1000) + publisher.schedule({settings: {defaultMint: 'b'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([ + {settings: {defaultMint: 'a'}}, + {settings: {defaultMint: 'b'}} + ]) + }) + + it('publishes a snapshot that lands mid-publish instead of losing it', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + // the publish callback re-schedules on the publisher being created - + // a holder indirection keeps both const + const holder: {publisher?: ReturnType} = {} + const publisher = createBackupPublisher({ + publish: p => { + published.push(p) + // a local change lands while the first publish is in flight + if (published.length === 1) { + holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}}) + } + return Promise.resolve() + }, + delayMs: 1000 + }) + holder.publisher = publisher + + publisher.schedule({settings: {defaultMint: 'first'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([ + {settings: {defaultMint: 'first'}}, + {settings: {defaultMint: 'mid-flight'}} + ]) + }) + + it('flush publishes immediately; cancel drops the pending snapshot', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const publisher = createBackupPublisher({ + publish: p => { + published.push(p) + return Promise.resolve() + }, + delayMs: 60_000 + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await publisher.flush() + expect(published).toEqual([{settings: {defaultMint: 'a'}}]) + + publisher.schedule({settings: {defaultMint: 'b'}}) + publisher.cancel() + await vi.advanceTimersByTimeAsync(60_000) + expect(published).toHaveLength(1) + }) + + it('reports a failed publish via onError and retries on the next change', async () => { + vi.useFakeTimers() + const published: Partial[] = [] + const errors: unknown[] = [] + let failing = true + const publisher = createBackupPublisher({ + publish: p => { + if (failing) return Promise.reject(new Error('relay down')) + published.push(p) + return Promise.resolve() + }, + delayMs: 1000, + onError: e => { + errors.push(e) + } + }) + + publisher.schedule({settings: {defaultMint: 'a'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([]) + expect(errors).toHaveLength(1) + + failing = false + publisher.schedule({settings: {defaultMint: 'b'}}) + await vi.advanceTimersByTimeAsync(1000) + expect(published).toEqual([{settings: {defaultMint: 'b'}}]) + }) +}) diff --git a/src/lnurlcash/nostrBackup.ts b/src/lnurlcash/nostrBackup.ts new file mode 100644 index 0000000..572efd7 --- /dev/null +++ b/src/lnurlcash/nostrBackup.ts @@ -0,0 +1,47 @@ +// Nostr backup: the wallet's bearer store, trusted-mint registry and +// settings mirrored onto public relays as addressable events (kind 30078, +// the NIP-78 app-data range), every payload NIP-44 v2 encrypted to the +// backup key's own pubkey - only the seed holder can read them, and the +// seed phrase alone (via keys.ts's linking key) re-derives everything. +// +// Framework-free, and no WebSocket is touched at import time: the default +// transport (nostr-tools' SimplePool) is imported lazily on first use, and +// tests inject a fake transport instead. +// +// Split by concern; this façade re-exports everything: +// nostr/events.ts - backup-key derivation + the event codec +// (build/sign, verify/decrypt), no I/O +// nostr/transport.ts - the injectable relay transport (SimplePool by +// default, lazily imported) +// nostr/sync.ts - publishBackup / fetchBackup / restoreFromNostr +// nostr/publisher.ts - the debounced publisher for store change events + +export { + BACKUP_EVENT_KIND, + BACKUP_PARTS, + BACKUP_D_TAGS, + deriveBackupKey, + backupPubkey, + buildBackupEvent, + buildBackupEvents, + parseBackupEvent +} from './nostr/events' +export type { + NostrEvent, + BackupPart, + BackupPartPayload, + ParsedBackupEvent +} from './nostr/events' + +export type {BackupTransport, NostrFilter} from './nostr/transport' + +export {publishBackup, fetchBackup, restoreFromNostr} from './nostr/sync' +export type { + PublishBackupOptions, + PublishBackupResult, + FetchBackupOptions, + NostrRestoreResult +} from './nostr/sync' + +export {createBackupPublisher} from './nostr/publisher' +export type {BackupPublisher, BackupPublisherOptions} from './nostr/publisher' diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts index 1e2b394..89bf71a 100644 --- a/src/lnurlcash/storage/backup.ts +++ b/src/lnurlcash/storage/backup.ts @@ -16,6 +16,8 @@ import type {TrustedMint} from '../trustedMints' import {readTrustedMints, mergeTrustedMints} from '../trustedMints' import type {EncryptedBearerRecord} from './bearers' import {readEncryptedBearers, writeEncryptedBearers} from './bearers' +import type {WalletSettings} from './settings' +import {loadSettings, persistSettings} from './settings' export type BackupFile = { type: 'sattle-backup' @@ -24,6 +26,7 @@ export type BackupFile = { linkingKey?: StoredSecret bearers: EncryptedBearerRecord[] trustedMints?: TrustedMint[] + settings?: WalletSettings } export const buildBackup = (): BackupFile => { @@ -32,7 +35,8 @@ export const buildBackup = (): BackupFile => { version: 1, createdAt: Date.now(), bearers: readEncryptedBearers(), - trustedMints: readTrustedMints() + trustedMints: readTrustedMints(), + settings: loadSettings() } const storedKey = getSavedLinkingKeyStored() if (savedKeyIsEncrypted() && storedKey) { @@ -54,6 +58,10 @@ export type RestoreResult = { // let that read as a silent no-op. linkingKeySkipped: boolean trustedMintsAdded: number + // true when the backup's settings filled in a field this device had never + // set - never when it would overwrite one, same merge direction as the + // trusted mints (the device's own current state always wins) + settingsRestored: boolean } // restore-time bounds - a crafted or corrupt file must not be able to fill @@ -144,11 +152,29 @@ export const applyBackup = (data: unknown): RestoreResult => { ? mergeTrustedMints(backup.trustedMints) : 0 + // settings merge: fill only fields this device has never set. Flat + // optional fields (see settings.ts), so the merge is field by field - + // today that is just defaultMint + let settingsRestored = false + if (typeof backup.settings === 'object' && backup.settings !== null) { + const incoming = (backup.settings as Record).defaultMint + const local = loadSettings() + if ( + local.defaultMint === undefined && + typeof incoming === 'string' && + incoming.length <= MAX_BACKUP_FIELD_LENGTH + ) { + persistSettings({...local, defaultMint: incoming}) + settingsRestored = true + } + } + return { added, skipped, linkingKeyRestored, linkingKeySkipped, - trustedMintsAdded + trustedMintsAdded, + settingsRestored } }