feat: nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore

This commit is contained in:
2026-08-19 23:55:59 +02:00
parent 70e61c2f34
commit 7ecade32ee
9 changed files with 1186 additions and 2 deletions
+221
View File
@@ -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<BackupPart, string> = {
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 = <P extends BackupPart>(
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<BackupPartPayload>,
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<string, unknown>
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<string, unknown>
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)
}
+75
View File
@@ -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<BackupPartPayload>) => void
// publish any pending snapshot immediately (app backgrounding, logout)
flush: () => Promise<void>
// drop any pending snapshot without publishing
cancel: () => void
}
export type BackupPublisherOptions = {
publish: (parts: Partial<BackupPartPayload>) => Promise<void>
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<typeof setTimeout> | null = null
let pending: Partial<BackupPartPayload> | null = null
let running: Promise<void> | null = null
const clearTimer = (): void => {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
const drain = async (): Promise<void> => {
// 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<void> => {
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
}
}
}
+152
View File
@@ -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<BackupPartPayload>,
relays: string[],
options: PublishBackupOptions = {}
): Promise<PublishBackupResult> => {
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<Partial<BackupPartPayload>> => {
const transport = options.transport ?? (await defaultTransport())
const events = await transport.fetch(relays, {
kinds: [BACKUP_EVENT_KIND],
authors: [pubkey]
})
const byTag = new Map<string, NostrEvent[]>()
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<BackupPartPayload> = {}
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<NostrRestoreResult> => {
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)
}
}
+32
View File
@@ -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<void>
fetch: (relays: string[], filter: NostrFilter) => Promise<NostrEvent[]>
}
export const defaultTransport = async (): Promise<BackupTransport> => {
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)
}
}
+525
View File
@@ -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> = {}): 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<BackupPartPayload>[] = []
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<BackupPartPayload>[] = []
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<BackupPartPayload>[] = []
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {}
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<BackupPartPayload>[] = []
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<BackupPartPayload>[] = []
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'}}])
})
})
+47
View File
@@ -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'
+28 -2
View File
@@ -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<string, unknown>).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
}
}