mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: nostr kind-30078 backup engine with nip-44 self-encryption and merge-on-restore
This commit is contained in:
Generated
+105
@@ -17,6 +17,7 @@
|
|||||||
"@scure/bip32": "^2.3.0",
|
"@scure/bip32": "^2.3.0",
|
||||||
"@scure/bip39": "^2.3.0",
|
"@scure/bip39": "^2.3.0",
|
||||||
"lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install",
|
"lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install",
|
||||||
|
"nostr-tools": "2.24.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
"quasar": "^2.25.1",
|
"quasar": "^2.25.1",
|
||||||
@@ -1604,6 +1605,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/curves": {
|
"node_modules/@noble/curves": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz",
|
||||||
@@ -5614,6 +5627,98 @@
|
|||||||
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
|
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nostr-tools": {
|
||||||
|
"version": "2.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.3.tgz",
|
||||||
|
"integrity": "sha512-8vPgdWuje+H0v2bnxo1/hZOi8VLLMqGjfO+Oxqkx+J22r76rIt/iICKZKBoTXWAP2qTYKCQv89M4A3PRc48JYw==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "2.1.1",
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0",
|
||||||
|
"@scure/bip32": "2.0.1",
|
||||||
|
"@scure/bip39": "2.0.1",
|
||||||
|
"nostr-wasm": "0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@noble/curves": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@scure/base": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@scure/bip32": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@scure/bip39": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-wasm": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/npm-run-path": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@scure/bip32": "^2.3.0",
|
"@scure/bip32": "^2.3.0",
|
||||||
"@scure/bip39": "^2.3.0",
|
"@scure/bip39": "^2.3.0",
|
||||||
"lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install",
|
"lnurlcash-kit": "github:tompro/lnurlcash-kit#prepare-git-install",
|
||||||
|
"nostr-tools": "2.24.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"qrcode.vue": "^3.10.0",
|
"qrcode.vue": "^3.10.0",
|
||||||
"quasar": "^2.25.1",
|
"quasar": "^2.25.1",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'}}])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -16,6 +16,8 @@ import type {TrustedMint} from '../trustedMints'
|
|||||||
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
|
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
|
||||||
import type {EncryptedBearerRecord} from './bearers'
|
import type {EncryptedBearerRecord} from './bearers'
|
||||||
import {readEncryptedBearers, writeEncryptedBearers} from './bearers'
|
import {readEncryptedBearers, writeEncryptedBearers} from './bearers'
|
||||||
|
import type {WalletSettings} from './settings'
|
||||||
|
import {loadSettings, persistSettings} from './settings'
|
||||||
|
|
||||||
export type BackupFile = {
|
export type BackupFile = {
|
||||||
type: 'sattle-backup'
|
type: 'sattle-backup'
|
||||||
@@ -24,6 +26,7 @@ export type BackupFile = {
|
|||||||
linkingKey?: StoredSecret
|
linkingKey?: StoredSecret
|
||||||
bearers: EncryptedBearerRecord[]
|
bearers: EncryptedBearerRecord[]
|
||||||
trustedMints?: TrustedMint[]
|
trustedMints?: TrustedMint[]
|
||||||
|
settings?: WalletSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildBackup = (): BackupFile => {
|
export const buildBackup = (): BackupFile => {
|
||||||
@@ -32,7 +35,8 @@ export const buildBackup = (): BackupFile => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
bearers: readEncryptedBearers(),
|
bearers: readEncryptedBearers(),
|
||||||
trustedMints: readTrustedMints()
|
trustedMints: readTrustedMints(),
|
||||||
|
settings: loadSettings()
|
||||||
}
|
}
|
||||||
const storedKey = getSavedLinkingKeyStored()
|
const storedKey = getSavedLinkingKeyStored()
|
||||||
if (savedKeyIsEncrypted() && storedKey) {
|
if (savedKeyIsEncrypted() && storedKey) {
|
||||||
@@ -54,6 +58,10 @@ export type RestoreResult = {
|
|||||||
// let that read as a silent no-op.
|
// let that read as a silent no-op.
|
||||||
linkingKeySkipped: boolean
|
linkingKeySkipped: boolean
|
||||||
trustedMintsAdded: number
|
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
|
// 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)
|
? mergeTrustedMints(backup.trustedMints)
|
||||||
: 0
|
: 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 {
|
return {
|
||||||
added,
|
added,
|
||||||
skipped,
|
skipped,
|
||||||
linkingKeyRestored,
|
linkingKeyRestored,
|
||||||
linkingKeySkipped,
|
linkingKeySkipped,
|
||||||
trustedMintsAdded
|
trustedMintsAdded,
|
||||||
|
settingsRestored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user