feat(backup): preserve BIP-32 counters

This commit is contained in:
2026-09-05 08:22:21 +02:00
parent 0cec423d6f
commit 641608eff0
11 changed files with 374 additions and 76 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ const lookForBackup = async (): Promise<void> => {
looked.value = true; looked.value = true;
if (parts.notes || parts.mints || parts.settings) { if (parts.notes || parts.mints || parts.settings) {
found.value = { found.value = {
notes: parts.notes?.length ?? 0, notes: parts.notes?.bearers.length ?? 0,
mints: parts.mints?.length ?? 0, mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined, settings: parts.settings !== undefined,
}; };
+41 -13
View File
@@ -9,11 +9,14 @@
// //
// Three d-tags carry three payloads in separate replaceable slots, so a // Three d-tags carry three payloads in separate replaceable slots, so a
// notes publish never clobbers settings: // notes publish never clobbers settings:
// notes - the encrypted bearer records exactly as they sit in // notes - the funds projection: encrypted bearer records exactly as
// localStorage (each note is already an AES-GCM ciphertext // they sit in the funds document (each note is already an
// under the seed-derived bearer key, so the blob goes up // AES-GCM ciphertext under the seed-derived bearer key, so the
// as-is; the linking key itself is NEVER part of a payload - // blob goes up as-is; the linking key itself is NEVER part of a
// the seed phrase is its recovery path) // payload - the seed phrase is its recovery path) plus the
// per-host BIP-32 next-unused counters, so a restored device
// never reissues a burned index. Pending journal state is
// device-local recovery data and is never published.
// mints - the trusted-mint registry // mints - the trusted-mint registry
// settings - plaintext wallet settings // settings - plaintext wallet settings
@@ -46,9 +49,16 @@ export const BACKUP_D_TAGS: Record<BackupPart, string> = {
settings: 'settings', settings: 'settings',
} }
// the notes payload: bearers plus the counter map, from one consistent
// funds-document read
export type NotesBackupPayload = {
bearers: EncryptedBearerRecord[]
nextByHost: Record<string, number>
}
// the decrypted payload of each part, without its envelope // the decrypted payload of each part, without its envelope
export type BackupPartPayload = { export type BackupPartPayload = {
notes: EncryptedBearerRecord[] notes: NotesBackupPayload
mints: TrustedMint[] mints: TrustedMint[]
settings: WalletSettings settings: WalletSettings
} }
@@ -77,10 +87,21 @@ const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024
export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? ''
// per-part envelope versions: the notes part went v2 with the funds
// document (counters joined the payload - a breaking change); mints and
// settings envelopes are unchanged
const PART_ENVELOPE_VERSION: Record<BackupPart, number> = {
notes: 2,
mints: 1,
settings: 1,
}
const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => {
switch (part) { switch (part) {
case 'notes': case 'notes': {
return JSON.stringify({version: 1, bearers: payload}) const notes = payload as NotesBackupPayload
return JSON.stringify({version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost})
}
case 'mints': case 'mints':
return JSON.stringify({version: 1, trustedMints: payload}) return JSON.stringify({version: 1, trustedMints: payload})
case 'settings': case 'settings':
@@ -122,18 +143,18 @@ export const buildBackupEvents = (
} }
export type ParsedBackupEvent = export type ParsedBackupEvent =
| {part: 'notes'; bearers: EncryptedBearerRecord[]} | {part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number>}
| {part: 'mints'; trustedMints: TrustedMint[]} | {part: 'mints'; trustedMints: TrustedMint[]}
| {part: 'settings'; settings: WalletSettings} | {part: 'settings'; settings: WalletSettings}
// Light shape checks so parse returns typed values; the strict bounds // Light shape checks so parse returns typed values; the strict bounds
// (record counts, field lengths, pubkey patterns) are enforced by // (record counts, field lengths, counter ranges and caps) are enforced by
// applyBackup / mergeTrustedMints on the restore path, same as file // applyBackup / commitFundsRestore on the restore path, same as file
// backups. // backups.
const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => { const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => {
if (typeof data !== 'object' || data === null) return null if (typeof data !== 'object' || data === null) return null
const envelope = data as Record<string, unknown> const envelope = data as Record<string, unknown>
if (envelope.version !== 1) return null if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null
switch (dTag) { switch (dTag) {
case 'notes': { case 'notes': {
if (!Array.isArray(envelope.bearers)) return null if (!Array.isArray(envelope.bearers)) return null
@@ -148,7 +169,14 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null
) { ) {
return null return null
} }
return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]} if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null
const counters = envelope.nextByHost as Record<string, unknown>
if (!Object.values(counters).every((v) => typeof v === 'number')) return null
return {
part: 'notes',
bearers: bearers as EncryptedBearerRecord[],
nextByHost: counters as Record<string, number>,
}
} }
case 'mints': { case 'mints': {
if (!Array.isArray(envelope.trustedMints)) return null if (!Array.isArray(envelope.trustedMints)) return null
+9 -8
View File
@@ -2,11 +2,11 @@
// //
// v1 is single-device last-writer-wins at the relay (addressable events // v1 is single-device last-writer-wins at the relay (addressable events
// replace). Restore merges locally through storage/backup.ts's applyBackup // replace). Restore merges locally through storage/backup.ts's applyBackup
// - the SAME entry point as file restore (union by record id, mints merged // - the SAME entry point as file restore (union by record id, counters
// unconfirmed, settings fill-only) - so the two restore paths can't drift // merged upward-only, mints merged unconfirmed, settings fill-only) - so the
// apart. The note-level dedupe (same note under different record ids, // two restore paths can't drift apart. The note-level dedupe (same note
// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly // under different record ids, spent-wins) happens after decrypt in
// as with a file backup. // bearers.ts's mergeBearers, exactly as with a file backup.
import type {RestoreResult} from '../storage/backup' import type {RestoreResult} from '../storage/backup'
import {applyBackup} from '../storage/backup' import {applyBackup} from '../storage/backup'
@@ -94,7 +94,7 @@ export const fetchBackup = async (
if (!parsed) continue if (!parsed) continue
switch (parsed.part) { switch (parsed.part) {
case 'notes': case 'notes':
parts.notes = parsed.bearers parts.notes = {bearers: parsed.bearers, nextByHost: parsed.nextByHost}
break break
case 'mints': case 'mints':
parts.mints = parsed.trustedMints parts.mints = parsed.trustedMints
@@ -134,9 +134,10 @@ export const restoreFromNostr = async (
const result = await applyBackup( const result = await applyBackup(
{ {
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: Date.now(), createdAt: Date.now(),
bearers: parts.notes ?? [], bearers: parts.notes?.bearers ?? [],
nextByHost: parts.notes?.nextByHost ?? {},
trustedMints: parts.mints, trustedMints: parts.mints,
settings: parts.settings, settings: parts.settings,
}, },
+25 -2
View File
@@ -103,11 +103,12 @@ describe('buildBackupEvent / parseBackupEvent', () => {
// assertion below // assertion below
{id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
] ]
const notes = {bearers: records, nextByHost: {'mint.example': 3}}
const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}] const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}]
const settings = {defaultMint: 'mint.example'} const settings = {defaultMint: 'mint.example'}
it('round-trips all three parts through build and parse', () => { it('round-trips all three parts through build and parse', () => {
const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) const events = buildBackupEvents(secretKey, {notes, mints, settings}, 1000)
expect(events).toHaveLength(3) expect(events).toHaveLength(3)
expect(events.map((e) => e.kind)).toEqual([ expect(events.map((e) => e.kind)).toEqual([
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
@@ -124,6 +125,7 @@ describe('buildBackupEvent / parseBackupEvent', () => {
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
part: 'notes', part: 'notes',
bearers: records, bearers: records,
nextByHost: {'mint.example': 3},
}) })
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
part: 'mints', part: 'mints',
@@ -136,7 +138,7 @@ describe('buildBackupEvent / parseBackupEvent', () => {
}) })
it('leaves no plaintext in the payload', () => { it('leaves no plaintext in the payload', () => {
const event = buildBackupEvent(secretKey, 'notes', records) const event = buildBackupEvent(secretKey, 'notes', notes)
expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a')
expect(event.content).not.toContain('ciphertext') expect(event.content).not.toContain('ciphertext')
}) })
@@ -188,4 +190,25 @@ describe('buildBackupEvent / parseBackupEvent', () => {
) )
expect(parseBackupEvent(secretKey, event)).toBeNull() expect(parseBackupEvent(secretKey, event)).toBeNull()
}) })
it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => {
const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey))
const build = (payload: unknown): NostrEvent =>
finalizeEvent(
{
kind: BACKUP_EVENT_KIND,
created_at: 1000,
tags: [['d', 'notes']],
content: nip44v2.encrypt(JSON.stringify(payload), conversationKey),
},
secretKey,
)
// v1 notes carried bare bearer arrays and no counters - pre-funds-document
expect(parseBackupEvent(secretKey, build({version: 1, bearers: records}))).toBeNull()
expect(parseBackupEvent(secretKey, build({version: 2, bearers: records}))).toBeNull()
// a v2 envelope whose counters are malformed is rejected, not coerced
expect(
parseBackupEvent(secretKey, build({version: 2, bearers: records, nextByHost: 'junk'})),
).toBeNull()
})
}) })
+52 -4
View File
@@ -30,7 +30,9 @@ import {
persistBearer, persistBearer,
persistSettings, persistSettings,
readEncryptedBearers, readEncryptedBearers,
readNextByHost,
} from './storage' } from './storage'
import {readFundsDocument, writeFundsDocument} from './storage/bearers'
import type {Bearer} from './types' import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils' import {requiredValue, stubLocalStorage} from './test-utils'
@@ -108,7 +110,7 @@ describe('restoreFromNostr', () => {
await publishBackup( await publishBackup(
secretKey, secretKey,
{ {
notes: readEncryptedBearers(), notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()},
mints: readTrustedMints(OWNER_ID), mints: readTrustedMints(OWNER_ID),
settings: loadSettings(), settings: loadSettings(),
}, },
@@ -145,7 +147,7 @@ describe('restoreFromNostr', () => {
// device A publishes its store holding the spendable note // device A publishes its store holding the spendable note
await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, {
transport, transport,
createdAt: 1000, createdAt: 1000,
}) })
@@ -155,7 +157,7 @@ describe('restoreFromNostr', () => {
stubLocalStorage() stubLocalStorage()
await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000}))
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, {
transport, transport,
createdAt: 2000, createdAt: 2000,
}) })
@@ -190,7 +192,7 @@ describe('restoreFromNostr', () => {
persistSettings({defaultMint: 'remote.example'}) persistSettings({defaultMint: 'remote.example'})
await publishBackup( await publishBackup(
secretKey, secretKey,
{notes: readEncryptedBearers(), settings: loadSettings()}, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, settings: loadSettings()},
RELAYS, RELAYS,
{transport, createdAt: 1000}, {transport, createdAt: 1000},
) )
@@ -220,4 +222,50 @@ describe('restoreFromNostr', () => {
expect(result.found).toEqual([]) expect(result.found).toEqual([])
expect(result.added).toBe(0) expect(result.added).toBe(0)
}) })
it('merges relayed counters upward-only and never carries pending state', async () => {
const aesKey = await deriveBearerAesKey(LINKING_KEY)
const secretKey = deriveBackupKey(LINKING_KEY)
const {transport} = createRecordingTransport()
// device A: counter at 5 for mint.example, one note, all published
await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
writeFundsDocument({
version: 2,
bearers: readEncryptedBearers(),
pending: [
{id: 'device-local-pending', kind: 'cash-allocation', phase: 'reserved', iv: '00', ciphertext: '00'},
],
nextByHost: {'mint.example': 5},
revision: 3,
})
await publishBackup(
secretKey,
{notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}},
RELAYS,
{transport, createdAt: 1000},
)
// device B: same seed, a HIGHER local counter for one host and a lower
// incoming one for the other; the pending record must not cross over
stubLocalStorage()
writeFundsDocument({
version: 2,
bearers: [],
pending: [],
nextByHost: {'mint.example': 2, 'local-only.example': 7},
revision: 1,
})
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.found).toEqual(['notes'])
expect(result.added).toBe(1)
expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5,
'local-only.example': 7,
})
expect(readFundsDocument().pending).toEqual([])
expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})])
})
}) })
+4 -1
View File
@@ -100,7 +100,10 @@ describe('publishBackup / fetchBackup', () => {
it('publishes every part and fetches them back decrypted', async () => { it('publishes every part and fetches them back decrypted', async () => {
const {transport} = createRecordingTransport() const {transport} = createRecordingTransport()
const parts = { const parts = {
notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], notes: {
bearers: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}],
nextByHost: {'mint.example': 2},
},
mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}], mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}],
settings: {defaultMint: 'mint.example'}, settings: {defaultMint: 'mint.example'},
} }
+107 -4
View File
@@ -1,12 +1,16 @@
// Backup restore waits for owner-bound trusted-mint convergence before it // Backup restore waits for owner-bound trusted-mint convergence before it
// reports success, while retaining the hostile-file merge policy. // reports success, while retaining the hostile-file merge policy. The v2
// format also projects the BIP-32 counters: backups carry nextByHost,
// restore merges upward-only, and pending journal state never leaves the
// device.
import {beforeEach, describe, expect, it, vi} from 'vitest' import {beforeEach, describe, expect, it, vi} from 'vitest'
import {linkingPubKeyHex, saveLinkingKey} from './keys' import {linkingPubKeyHex, saveLinkingKey} from './keys'
import {applyBackup, buildBackup} from './storage' import {applyBackup, buildBackup, parseBackupFile} from './storage'
import {readFundsDocument, writeFundsDocument} from './storage/bearers'
import {addTrustedMint, readTrustedMints} from './trustedMints' import {addTrustedMint, readTrustedMints} from './trustedMints'
import {stubLocalStorage} from './test-utils' import {requiredValue, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
@@ -40,9 +44,10 @@ class DeferredLocks {
const backup = (server: string, mintPubkey: string) => ({ const backup = (server: string, mintPubkey: string) => ({
type: 'sattle-backup' as const, type: 'sattle-backup' as const,
version: 1 as const, version: 2 as const,
createdAt: 1, createdAt: 1,
bearers: [], bearers: [],
nextByHost: {},
trustedMints: [ trustedMints: [
{ {
server, server,
@@ -153,3 +158,101 @@ describe('owner-bound backup restore', () => {
]) ])
}) })
}) })
describe('backup counter projection', () => {
const fundsDoc = (nextByHost: Record<string, number>) => ({
version: 2 as const,
bearers: [] as {id: string; iv: string; ciphertext: string}[],
pending: [] as {id: string; kind: string; phase: string; iv: string; ciphertext: string}[],
nextByHost,
revision: 1,
})
it('exports the counters and never the pending journal', () => {
const doc = fundsDoc({'mint.example': 5})
doc.pending.push({
id: 'pending-sentinel-never-exported',
kind: 'cash-allocation',
phase: 'reserved',
iv: '00',
ciphertext: '00',
})
writeFundsDocument(doc)
const built = buildBackup(OWNER_ID)
expect(built.version).toBe(2)
expect(built.nextByHost).toEqual({'mint.example': 5})
expect('pending' in built).toBe(false)
expect(JSON.stringify(built)).not.toContain('pending-sentinel-never-exported')
})
it('round-trips counters through a restore and merges upward-only', async () => {
writeFundsDocument(fundsDoc({'mint.example': 5, 'other.example': 2}))
const result = await applyBackup({
type: 'sattle-backup',
version: 2,
createdAt: 1,
bearers: [],
nextByHost: {'mint.example': 3, 'other.example': 9, 'new.example': 4},
})
expect(result.added).toBe(0)
// lower incoming values never rewind a counter; higher ones advance it;
// unknown hosts adopt the backup's value
expect(readFundsDocument().nextByHost).toEqual({
'mint.example': 5,
'other.example': 9,
'new.example': 4,
})
})
it('skips out-of-bounds counter entries instead of rejecting the restore', async () => {
writeFundsDocument(fundsDoc({'mint.example': 5}))
await applyBackup({
type: 'sattle-backup',
version: 2,
createdAt: 1,
bearers: [],
nextByHost: {
[`${'h'.repeat(254)}`]: 9,
'overflow.example': 2 ** 31,
'negative.example': -1,
'fractional.example': 1.5,
'valid.example': 7,
},
})
expect(readFundsDocument().nextByHost).toEqual({'mint.example': 5, 'valid.example': 7})
})
it('never grows the counter map beyond ten hosts on restore', async () => {
const incoming: Record<string, number> = {}
for (let i = 0; i < 12; i += 1) incoming[`mint-${i}.example`] = i + 1
await applyBackup({
type: 'sattle-backup',
version: 2,
createdAt: 1,
bearers: [],
nextByHost: incoming,
})
const merged = readFundsDocument().nextByHost
expect(Object.keys(merged)).toHaveLength(10)
})
it('rejects a legacy version-1 backup file', () => {
expect(() =>
parseBackupFile({type: 'sattle-backup', version: 1, createdAt: 1, bearers: []}),
).toThrow('Not a valid sattle backup file.')
})
it('rejects a v2 file without a counter map', () => {
expect(() =>
parseBackupFile({type: 'sattle-backup', version: 2, createdAt: 1, bearers: []}),
).toThrow('Not a valid sattle backup file.')
})
})
+47 -23
View File
@@ -1,8 +1,14 @@
// Backup files: everything exactly as it sits in localStorage - bearer // Backup files: everything exactly as it sits in localStorage - bearer
// ciphertexts always, the linking-key record only when it is itself // ciphertexts and the BIP-32 counter map always, the linking-key record only
// password-encrypted. A plaintext linking key never leaves the device in a // when it is itself password-encrypted. A plaintext linking key never leaves
// backup; the seed phrase is the recovery path for it instead. Trusted // the device in a backup; the seed phrase is the recovery path for it
// mints are plain (not secret - a mintPubkey is public), included as-is. // instead. Trusted mints are plain (not secret - a mintPubkey is public),
// included as-is. The pending-mutation journal is device-local recovery
// state and is deliberately NOT part of any backup.
//
// Format v2 (breaking): the funds document made bearers + counters one
// atomic unit, so the backup projects both from the same single read -
// they can never disagree about which generation they came from.
import type {StoredSecret} from '../keys' import type {StoredSecret} from '../keys'
import { import {
@@ -16,39 +22,45 @@ import type {TrustedMint} from '../trustedMints'
import {readTrustedMints, mergeTrustedMints} from '../trustedMints' import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
import {isWalletOwnerId} from './walletOwner' import {isWalletOwnerId} from './walletOwner'
import type {EncryptedBearerRecord} from './bearers' import type {EncryptedBearerRecord} from './bearers'
import {readEncryptedBearers, writeEncryptedBearers} from './bearers' import {commitFundsRestore, readFundsDocument} from './bearers'
import type {WalletSettings} from './settings' import type {WalletSettings} from './settings'
import {loadSettings, persistSettings} from './settings' import {loadSettings, persistSettings} from './settings'
import {isJsonObject} from '../jsonParsing' import {isJsonObject} from '../jsonParsing'
export type BackupFile = { export type BackupFile = {
type: 'sattle-backup' type: 'sattle-backup'
version: 1 version: 2
createdAt: number createdAt: number
ownerId?: unknown ownerId?: unknown
linkingKey?: StoredSecret linkingKey?: StoredSecret
bearers: EncryptedBearerRecord[] bearers: EncryptedBearerRecord[]
nextByHost: Record<string, number>
trustedMints?: TrustedMint[] trustedMints?: TrustedMint[]
settings?: WalletSettings settings?: WalletSettings
} }
type ParsedBackupFile = { type ParsedBackupFile = {
type: 'sattle-backup' type: 'sattle-backup'
version: 1 version: 2
createdAt?: unknown createdAt?: unknown
ownerId?: unknown ownerId?: unknown
linkingKey?: unknown linkingKey?: unknown
bearers: unknown[] bearers: unknown[]
nextByHost: Record<string, unknown>
trustedMints?: unknown trustedMints?: unknown
settings?: unknown settings?: unknown
} }
export const buildBackup = (ownerId?: string): BackupFile => { export const buildBackup = (ownerId?: string): BackupFile => {
// one document, one getItem: bearers and counters are snapshot-consistent
// by construction; pending journal records are never read here
const funds = readFundsDocument()
const backup: BackupFile = { const backup: BackupFile = {
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: Date.now(), createdAt: Date.now(),
bearers: readEncryptedBearers(), bearers: funds.bearers,
nextByHost: funds.nextByHost,
trustedMints: readTrustedMints(ownerId), trustedMints: readTrustedMints(ownerId),
settings: loadSettings(), settings: loadSettings(),
} }
@@ -91,8 +103,9 @@ const MAX_BACKUP_FIELD_LENGTH = 64 * 1024
const isBackupFile = (data: unknown): data is ParsedBackupFile => const isBackupFile = (data: unknown): data is ParsedBackupFile =>
isJsonObject(data) && isJsonObject(data) &&
data.type === 'sattle-backup' && data.type === 'sattle-backup' &&
data.version === 1 && data.version === 2 &&
Array.isArray(data.bearers) Array.isArray(data.bearers) &&
isJsonObject(data.nextByHost)
export const parseBackupFile = (data: unknown): ParsedBackupFile => { export const parseBackupFile = (data: unknown): ParsedBackupFile => {
if (!isBackupFile(data)) { if (!isBackupFile(data)) {
@@ -102,7 +115,10 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => {
} }
// merges a backup into localStorage: bearer records are added by id // merges a backup into localStorage: bearer records are added by id
// (already present ids are left as-is - union, never overwrite), the // (already present ids are left as-is - union, never overwrite) and counter
// entries merge upward-only (max per host - a stale backup must never rewind
// a BIP-32 counter and reopen burned indices), both inside ONE locked funds
// document write (commitFundsRestore enforces the counter bounds). The
// backup's linking key is only installed when this device has none yet - // backup's linking key is only installed when this device has none yet -
// never overwriting an existing wallet. That guard is deliberate (a // never overwriting an existing wallet. That guard is deliberate (a
// stale/wrong backup must never clobber a wallet already holding funds), // stale/wrong backup must never clobber a wallet already holding funds),
@@ -114,14 +130,12 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => {
// decrypt, in bearers.ts's mergeBearers. // decrypt, in bearers.ts's mergeBearers.
export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => { export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => {
const backup = parseBackupFile(data) const backup = parseBackupFile(data)
const existing = readEncryptedBearers()
const existingIds = new Set(existing.map((r) => r.id))
if (backup.bearers.length > MAX_BACKUP_RECORDS) { if (backup.bearers.length > MAX_BACKUP_RECORDS) {
throw new Error( throw new Error(
`Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`, `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`,
) )
} }
let added = 0 const incomingBearers: EncryptedBearerRecord[] = []
let skipped = 0 let skipped = 0
for (const record of backup.bearers) { for (const record of backup.bearers) {
if ( if (
@@ -136,21 +150,31 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise<Rest
skipped++ skipped++
continue continue
} }
if (existingIds.has(record.id)) { incomingBearers.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext})
skipped++
continue
} }
existing.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext}) // candidate counters: validity (host length, safe range, host cap) is
existingIds.add(record.id) // enforced by commitFundsRestore; here we only keep the raw shape honest
added++ const incomingCounters: Record<string, number> = {}
for (const [host, next] of Object.entries(backup.nextByHost)) {
if (typeof next === 'number') incomingCounters[host] = next
} }
let added: number
try { try {
writeEncryptedBearers(existing) const merged = await commitFundsRestore(incomingBearers, incomingCounters)
} catch { added = merged.added
skipped += merged.skipped
} catch (error) {
const name = error instanceof Error ? error.name : ''
const message = error instanceof Error ? error.message : ''
if (name === 'QuotaExceededError' || /quota|full/i.test(message)) {
throw new Error( throw new Error(
'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.',
{cause: error},
) )
} }
throw error
}
let linkingKeyRestored = false let linkingKeyRestored = false
let linkingKeySkipped = false let linkingKeySkipped = false
-10
View File
@@ -174,16 +174,6 @@ export const writeFundsDocument = (doc: StoredFundsV2): void => {
localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc)) localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc))
} }
// COMPAT shim for the pre-counter backup path (storage/backup.ts before the
// backup-projection commit): replaces the document's bearer array wholesale,
// preserving pending journal and counters. Deleted together with that path's
// last call site; new code writes through the locked commit primitives only.
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
const doc = readFundsDocument()
doc.bearers = records
writeFundsDocument(doc)
}
export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers
export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending
+68
View File
@@ -0,0 +1,68 @@
// The nostr-backup store's scheduling surface: a counter-only funds commit
// (a BIP-32 reservation that adds no bearer) must still schedule a publish,
// and the published notes payload carries bearers plus counters - never the
// device-local pending journal. The engine's publish is mocked; everything
// below it (debounce, wallet wiring, payload assembly) is real.
import { createPinia, setActivePinia } from 'pinia';
import { deriveCashRoot } from 'lnurlcash-kit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { publishBackup } from '@/lnurlcash/nostrBackup';
import type * as NostrBackupEngine from '@/lnurlcash/nostrBackup';
import { readFundsDocument } from '@/lnurlcash/storage';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import { useNostrBackupStore } from './nostrBackup';
import { useWalletStore } from './wallet';
vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => {
const actual = await importOriginal<typeof NostrBackupEngine>();
return { ...actual, publishBackup: vi.fn().mockResolvedValue({ published: [] }) };
});
const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1));
const publishCalls = (): { parts: { notes?: { nextByHost: Record<string, number> } } }[] =>
vi.mocked(publishBackup).mock.calls.map((call) => ({ parts: call[1] }));
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(publishBackup).mockResolvedValue({ published: [] });
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
});
stubLocalStorage();
setActivePinia(createPinia());
});
afterEach(() => {
vi.useRealTimers();
});
describe('counter-aware backup scheduling', () => {
it('schedules a publish for a counter-only reservation and includes the counters', async () => {
const wallet = useWalletStore();
await wallet.create();
const backup = useNostrBackupStore();
vi.useFakeTimers();
backup.setEnabled(true);
// the initial activation publish drains first
await vi.advanceTimersByTimeAsync(5000);
expect(publishCalls()).toHaveLength(1);
vi.mocked(publishBackup).mockClear();
// a reservation changes no bearer - only the counter map and the pending
// journal - yet it must still schedule a publish
await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence());
await vi.advanceTimersByTimeAsync(5000);
const calls = publishCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.parts.notes?.nextByHost).toEqual({ 'mint.example': 2 });
// the pending journal never leaves the device
expect(JSON.stringify(calls[0]?.parts)).not.toContain('cash-allocation');
expect(readFundsDocument().pending).toHaveLength(1);
});
});
+14 -4
View File
@@ -9,7 +9,7 @@ import {
publishBackup, publishBackup,
} from '@/lnurlcash/nostrBackup'; } from '@/lnurlcash/nostrBackup';
import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup'; import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup';
import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage'; import { loadSettings, persistSettings, readFundsDocument } from '@/lnurlcash/storage';
import { readTrustedMints } from '@/lnurlcash/trustedMints'; import { readTrustedMints } from '@/lnurlcash/trustedMints';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
import { useMintsStore } from './mints'; import { useMintsStore } from './mints';
@@ -62,11 +62,17 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => {
wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null, wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null,
); );
const currentPayload = (): BackupPartPayload => ({ const currentPayload = (): BackupPartPayload => {
notes: readEncryptedBearers(), // one document, one getItem: bearers and counters come from the same
// locked generation by construction; pending journal state is
// device-local and never published
const funds = readFundsDocument();
return {
notes: { bearers: funds.bearers, nextByHost: funds.nextByHost },
mints: readTrustedMints(wallet.pubkey ?? undefined), mints: readTrustedMints(wallet.pubkey ?? undefined),
settings: loadSettings(), settings: loadSettings(),
}); };
};
const publishNow = async (): Promise<void> => { const publishNow = async (): Promise<void> => {
const secretKey = deriveBackupKey(wallet.requireLinkingKey()); const secretKey = deriveBackupKey(wallet.requireLinkingKey());
@@ -97,6 +103,10 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => {
const schedule = () => publisher?.schedule(currentPayload()); const schedule = () => publisher?.schedule(currentPayload());
const stops = [ const stops = [
watch(() => wallet.bearers, schedule), watch(() => wallet.bearers, schedule),
// counter-only commits (reservations) leave the bearer list untouched,
// so watch the persisted document revision as well - a restored or
// freshly burned counter must still reach the relays
watch(() => wallet.fundsRevision, schedule),
watch(() => mints.mints, schedule), watch(() => mints.mints, schedule),
watch(() => mints.defaultMint, schedule), watch(() => mints.defaultMint, schedule),
]; ];