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)
}
}