// 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('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)), ) }) })