refactor: validate Nostr backup event payloads

This commit is contained in:
2026-08-22 16:54:29 +02:00
parent b6fff605a1
commit 2a90343e7e
2 changed files with 206 additions and 29 deletions
+15 -29
View File
@@ -43,7 +43,7 @@ export const BACKUP_PARTS: readonly BackupPart[] = ['notes', 'mints', 'settings'
export const BACKUP_D_TAGS: Record<BackupPart, string> = { export const BACKUP_D_TAGS: Record<BackupPart, string> = {
notes: 'notes', notes: 'notes',
mints: 'mints', mints: 'mints',
settings: 'settings' settings: 'settings',
} }
// the decrypted payload of each part, without its envelope // 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)])) sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BACKUP_KEY_CONTEXT)]))
// the x-only nostr pubkey identifying this wallet's backup events // the x-only nostr pubkey identifying this wallet's backup events
export const backupPubkey = (secretKey: Uint8Array): string => export const backupPubkey = (secretKey: Uint8Array): string => getPublicKey(secretKey)
getPublicKey(secretKey)
// NIP-44 "self-DM": the conversation key between the backup key and its own // NIP-44 "self-DM": the conversation key between the backup key and its own
// pubkey - decryptable by the seed holder and nobody else // 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) // per-record bounds still apply on top after decrypt)
const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
export const dTagOf = (event: NostrEvent): string => export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? ''
event.tags.find(t => t[0] === 'd')?.[1] ?? ''
const envelopeFor = ( const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
part: BackupPart,
payload: BackupPartPayload[BackupPart]
): string => {
switch (part) { switch (part) {
case 'notes': case 'notes':
return JSON.stringify({version: 1, bearers: payload}) return JSON.stringify({version: 1, bearers: payload})
@@ -98,26 +93,23 @@ export const buildBackupEvent = <P extends BackupPart>(
secretKey: Uint8Array, secretKey: Uint8Array,
part: P, part: P,
payload: BackupPartPayload[P], payload: BackupPartPayload[P],
createdAt: number = Math.floor(Date.now() / 1000) createdAt: number = Math.floor(Date.now() / 1000),
): NostrEvent => ): NostrEvent =>
finalizeEvent( finalizeEvent(
{ {
kind: BACKUP_EVENT_KIND, kind: BACKUP_EVENT_KIND,
created_at: createdAt, created_at: createdAt,
tags: [['d', BACKUP_D_TAGS[part]]], tags: [['d', BACKUP_D_TAGS[part]]],
content: nip44v2.encrypt( content: nip44v2.encrypt(envelopeFor(part, payload), selfConversationKey(secretKey)),
envelopeFor(part, payload),
selfConversationKey(secretKey)
)
}, },
secretKey secretKey,
) )
// one event per part present in `parts`, all sharing one timestamp // one event per part present in `parts`, all sharing one timestamp
export const buildBackupEvents = ( export const buildBackupEvents = (
secretKey: Uint8Array, secretKey: Uint8Array,
parts: Partial<BackupPartPayload>, parts: Partial<BackupPartPayload>,
createdAt?: number createdAt?: number,
): NostrEvent[] => { ): NostrEvent[] => {
const at = createdAt ?? Math.floor(Date.now() / 1000) const at = createdAt ?? Math.floor(Date.now() / 1000)
const events: NostrEvent[] = [] const events: NostrEvent[] = []
@@ -138,10 +130,7 @@ export type ParsedBackupEvent =
// (record counts, field lengths, pubkey patterns) are enforced by // (record counts, field lengths, pubkey patterns) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file // applyBackup / mergeTrustedMints on the restore path, same as file
// backups. // backups.
const parsePayload = ( const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
dTag: BackupPart,
data: unknown
): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null if (typeof data !== 'object' || data === null) return null
const envelope = data as Record<string, unknown> const envelope = data as Record<string, unknown>
if (envelope.version !== 1) return null if (envelope.version !== 1) return null
@@ -151,10 +140,10 @@ const parsePayload = (
const bearers = envelope.bearers as unknown[] const bearers = envelope.bearers as unknown[]
if ( if (
!bearers.every( !bearers.every(
r => (r) =>
typeof (r as EncryptedBearerRecord)?.id === 'string' && typeof (r as EncryptedBearerRecord)?.id === 'string' &&
typeof (r as EncryptedBearerRecord)?.iv === 'string' && typeof (r as EncryptedBearerRecord)?.iv === 'string' &&
typeof (r as EncryptedBearerRecord)?.ciphertext === 'string' typeof (r as EncryptedBearerRecord)?.ciphertext === 'string',
) )
) { ) {
return null return null
@@ -166,9 +155,9 @@ const parsePayload = (
const mints = envelope.trustedMints as unknown[] const mints = envelope.trustedMints as unknown[]
if ( if (
!mints.every( !mints.every(
m => (m) =>
typeof (m as TrustedMint)?.server === 'string' && typeof (m as TrustedMint)?.server === 'string' &&
typeof (m as TrustedMint)?.mintPubkey === 'string' typeof (m as TrustedMint)?.mintPubkey === 'string',
) )
) { ) {
return null return null
@@ -180,10 +169,7 @@ const parsePayload = (
return null return null
} }
const settings = envelope.settings as Record<string, unknown> const settings = envelope.settings as Record<string, unknown>
if ( if (settings.defaultMint !== undefined && typeof settings.defaultMint !== 'string') {
settings.defaultMint !== undefined &&
typeof settings.defaultMint !== 'string'
) {
return null return null
} }
return {part: 'settings', settings: settings as WalletSettings} return {part: 'settings', settings: settings as WalletSettings}
@@ -197,7 +183,7 @@ const parsePayload = (
// skip nulls - one junk event must never sink a restore. // skip nulls - one junk event must never sink a restore.
export const parseBackupEvent = ( export const parseBackupEvent = (
secretKey: Uint8Array, secretKey: Uint8Array,
event: NostrEvent event: NostrEvent,
): ParsedBackupEvent | null => { ): ParsedBackupEvent | null => {
if (event.kind !== BACKUP_EVENT_KIND) return null if (event.kind !== BACKUP_EVENT_KIND) return null
if (event.pubkey !== getPublicKey(secretKey)) return null if (event.pubkey !== getPublicKey(secretKey)) return null
+191
View File
@@ -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> = {}): 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()
})
})