diff --git a/src/lnurlcash/nostr/events.ts b/src/lnurlcash/nostr/events.ts index 7a3b04d..bffeae5 100644 --- a/src/lnurlcash/nostr/events.ts +++ b/src/lnurlcash/nostr/events.ts @@ -43,7 +43,7 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings' export const BACKUP_D_TAGS: Record = { notes: 'notes', mints: 'mints', - settings: 'settings' + settings: 'settings', } // the decrypted payload of each part, without its envelope @@ -62,8 +62,7 @@ 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) +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 @@ -76,13 +75,9 @@ const selfConversationKey = (secretKey: Uint8Array): Uint8Array => // 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] ?? '' +export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' -const envelopeFor = ( - part: BackupPart, - payload: BackupPartPayload[BackupPart] -): string => { +const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { switch (part) { case 'notes': return JSON.stringify({version: 1, bearers: payload}) @@ -98,26 +93,23 @@ export const buildBackupEvent =

( secretKey: Uint8Array, part: P, payload: BackupPartPayload[P], - createdAt: number = Math.floor(Date.now() / 1000) + 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) - ) + content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)), }, - secretKey + secretKey, ) // one event per part present in `parts`, all sharing one timestamp export const buildBackupEvents = ( secretKey: Uint8Array, parts: Partial, - createdAt?: number + createdAt?: number, ): NostrEvent[] => { const at = createdAt ?? Math.floor(Date.now() / 1000) const events: NostrEvent[] = [] @@ -138,10 +130,7 @@ export type ParsedBackupEvent = // (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 => { +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 @@ -151,10 +140,10 @@ const parsePayload = ( const bearers = envelope.bearers as unknown[] if ( !bearers.every( - r => + (r) => typeof (r as EncryptedBearerRecord)?.id === 'string' && typeof (r as EncryptedBearerRecord)?.iv === 'string' && - typeof (r as EncryptedBearerRecord)?.ciphertext === 'string' + typeof (r as EncryptedBearerRecord)?.ciphertext === 'string', ) ) { return null @@ -166,9 +155,9 @@ const parsePayload = ( const mints = envelope.trustedMints as unknown[] if ( !mints.every( - m => + (m) => typeof (m as TrustedMint)?.server === 'string' && - typeof (m as TrustedMint)?.mintPubkey === 'string' + typeof (m as TrustedMint)?.mintPubkey === 'string', ) ) { return null @@ -180,10 +169,7 @@ const parsePayload = ( return null } const settings = envelope.settings as Record - if ( - settings.defaultMint !== undefined && - typeof settings.defaultMint !== 'string' - ) { + if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') { return null } return {part: 'settings', settings: settings as WalletSettings} @@ -197,7 +183,7 @@ const parsePayload = ( // skip nulls - one junk event must never sink a restore. export const parseBackupEvent = ( secretKey: Uint8Array, - event: NostrEvent + event: NostrEvent, ): ParsedBackupEvent | null => { if (event.kind !== BACKUP_EVENT_KIND) return null if (event.pubkey !== getPublicKey(secretKey)) return null diff --git a/src/lnurlcash/nostrBackup.codec.cases.ts b/src/lnurlcash/nostrBackup.codec.cases.ts new file mode 100644 index 0000000..8d84386 --- /dev/null +++ b/src/lnurlcash/nostrBackup.codec.cases.ts @@ -0,0 +1,191 @@ +// 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, linkingPubKeyHex, saveLinkingKey} 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, isMintUnconfirmed, readTrustedMints} from './trustedMints' +import {requiredValue, stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) + +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() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('buildBackupEvent / parseBackupEvent', () => { + const secretKey = deriveBackupKey(LINKING_KEY) + + const records = [ + // long unique sentinel id: a short id like 'r1' randomly appears in + // base64 ciphertext (~17% for 700 chars), which flakes the no-plaintext + // assertion below + {id: 'record-id-plaintext-sentinel-7f3a', 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, requiredValue(events[0]))).toEqual({ + part: 'notes', + bearers: records, + }) + expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ + part: 'mints', + trustedMints: mints, + }) + expect(parseBackupEvent(secretKey, requiredValue(events[2]))).toEqual({ + part: 'settings', + settings, + }) + }) + + it('leaves no plaintext in the payload', () => { + const event = buildBackupEvent(secretKey, 'notes', records) + expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') + 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(requiredValue(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() + }) +})