mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(backup): preserve BIP-32 counters
This commit is contained in:
@@ -131,7 +131,7 @@ const lookForBackup = async (): Promise<void> => {
|
||||
looked.value = true;
|
||||
if (parts.notes || parts.mints || parts.settings) {
|
||||
found.value = {
|
||||
notes: parts.notes?.length ?? 0,
|
||||
notes: parts.notes?.bearers.length ?? 0,
|
||||
mints: parts.mints?.length ?? 0,
|
||||
settings: parts.settings !== undefined,
|
||||
};
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
//
|
||||
// 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)
|
||||
// notes - the funds projection: encrypted bearer records exactly as
|
||||
// they sit in the funds document (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) 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
|
||||
// settings - plaintext wallet settings
|
||||
|
||||
@@ -46,9 +49,16 @@ export const BACKUP_D_TAGS: Record<BackupPart, string> = {
|
||||
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
|
||||
export type BackupPartPayload = {
|
||||
notes: EncryptedBearerRecord[]
|
||||
notes: NotesBackupPayload
|
||||
mints: TrustedMint[]
|
||||
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] ?? ''
|
||||
|
||||
// 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 => {
|
||||
switch (part) {
|
||||
case 'notes':
|
||||
return JSON.stringify({version: 1, bearers: payload})
|
||||
case 'notes': {
|
||||
const notes = payload as NotesBackupPayload
|
||||
return JSON.stringify({version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost})
|
||||
}
|
||||
case 'mints':
|
||||
return JSON.stringify({version: 1, trustedMints: payload})
|
||||
case 'settings':
|
||||
@@ -122,18 +143,18 @@ export const buildBackupEvents = (
|
||||
}
|
||||
|
||||
export type ParsedBackupEvent =
|
||||
| {part: 'notes'; bearers: EncryptedBearerRecord[]}
|
||||
| {part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record<string, number>}
|
||||
| {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
|
||||
// (record counts, field lengths, counter ranges and caps) are enforced by
|
||||
// applyBackup / commitFundsRestore 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
|
||||
if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null
|
||||
switch (dTag) {
|
||||
case 'notes': {
|
||||
if (!Array.isArray(envelope.bearers)) return null
|
||||
@@ -148,7 +169,14 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | 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': {
|
||||
if (!Array.isArray(envelope.trustedMints)) return null
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//
|
||||
// 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.
|
||||
// - the SAME entry point as file restore (union by record id, counters
|
||||
// merged upward-only, 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'
|
||||
@@ -94,7 +94,7 @@ export const fetchBackup = async (
|
||||
if (!parsed) continue
|
||||
switch (parsed.part) {
|
||||
case 'notes':
|
||||
parts.notes = parsed.bearers
|
||||
parts.notes = {bearers: parsed.bearers, nextByHost: parsed.nextByHost}
|
||||
break
|
||||
case 'mints':
|
||||
parts.mints = parsed.trustedMints
|
||||
@@ -134,9 +134,10 @@ export const restoreFromNostr = async (
|
||||
const result = await applyBackup(
|
||||
{
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
version: 2,
|
||||
createdAt: Date.now(),
|
||||
bearers: parts.notes ?? [],
|
||||
bearers: parts.notes?.bearers ?? [],
|
||||
nextByHost: parts.notes?.nextByHost ?? {},
|
||||
trustedMints: parts.mints,
|
||||
settings: parts.settings,
|
||||
},
|
||||
|
||||
@@ -103,11 +103,12 @@ describe('buildBackupEvent / parseBackupEvent', () => {
|
||||
// assertion below
|
||||
{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 settings = {defaultMint: 'mint.example'}
|
||||
|
||||
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.map((e) => e.kind)).toEqual([
|
||||
BACKUP_EVENT_KIND,
|
||||
@@ -124,6 +125,7 @@ describe('buildBackupEvent / parseBackupEvent', () => {
|
||||
expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({
|
||||
part: 'notes',
|
||||
bearers: records,
|
||||
nextByHost: {'mint.example': 3},
|
||||
})
|
||||
expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({
|
||||
part: 'mints',
|
||||
@@ -136,7 +138,7 @@ describe('buildBackupEvent / parseBackupEvent', () => {
|
||||
})
|
||||
|
||||
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('ciphertext')
|
||||
})
|
||||
@@ -188,4 +190,25 @@ describe('buildBackupEvent / parseBackupEvent', () => {
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
persistBearer,
|
||||
persistSettings,
|
||||
readEncryptedBearers,
|
||||
readNextByHost,
|
||||
} from './storage'
|
||||
import {readFundsDocument, writeFundsDocument} from './storage/bearers'
|
||||
import type {Bearer} from './types'
|
||||
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
@@ -108,7 +110,7 @@ describe('restoreFromNostr', () => {
|
||||
await publishBackup(
|
||||
secretKey,
|
||||
{
|
||||
notes: readEncryptedBearers(),
|
||||
notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()},
|
||||
mints: readTrustedMints(OWNER_ID),
|
||||
settings: loadSettings(),
|
||||
},
|
||||
@@ -145,7 +147,7 @@ describe('restoreFromNostr', () => {
|
||||
|
||||
// device A publishes its store holding the spendable note
|
||||
await persistBearer(aesKey, bearerFixture({id: 'rec-a'}))
|
||||
await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, {
|
||||
await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, {
|
||||
transport,
|
||||
createdAt: 1000,
|
||||
})
|
||||
@@ -155,7 +157,7 @@ describe('restoreFromNostr', () => {
|
||||
stubLocalStorage()
|
||||
await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
|
||||
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,
|
||||
createdAt: 2000,
|
||||
})
|
||||
@@ -190,7 +192,7 @@ describe('restoreFromNostr', () => {
|
||||
persistSettings({defaultMint: 'remote.example'})
|
||||
await publishBackup(
|
||||
secretKey,
|
||||
{notes: readEncryptedBearers(), settings: loadSettings()},
|
||||
{notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, settings: loadSettings()},
|
||||
RELAYS,
|
||||
{transport, createdAt: 1000},
|
||||
)
|
||||
@@ -220,4 +222,50 @@ describe('restoreFromNostr', () => {
|
||||
expect(result.found).toEqual([])
|
||||
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'})])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +100,10 @@ describe('publishBackup / fetchBackup', () => {
|
||||
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)}],
|
||||
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}],
|
||||
settings: {defaultMint: 'mint.example'},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// 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 {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 {stubLocalStorage} from './test-utils'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
|
||||
@@ -40,9 +44,10 @@ class DeferredLocks {
|
||||
|
||||
const backup = (server: string, mintPubkey: string) => ({
|
||||
type: 'sattle-backup' as const,
|
||||
version: 1 as const,
|
||||
version: 2 as const,
|
||||
createdAt: 1,
|
||||
bearers: [],
|
||||
nextByHost: {},
|
||||
trustedMints: [
|
||||
{
|
||||
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.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Backup files: everything exactly as it sits in localStorage - bearer
|
||||
// ciphertexts always, the linking-key record only when it is itself
|
||||
// password-encrypted. A plaintext linking key never leaves the device in a
|
||||
// backup; the seed phrase is the recovery path for it instead. Trusted
|
||||
// mints are plain (not secret - a mintPubkey is public), included as-is.
|
||||
// ciphertexts and the BIP-32 counter map always, the linking-key record only
|
||||
// when it is itself password-encrypted. A plaintext linking key never leaves
|
||||
// the device in a backup; the seed phrase is the recovery path for it
|
||||
// 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 {
|
||||
@@ -16,39 +22,45 @@ import type {TrustedMint} from '../trustedMints'
|
||||
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
|
||||
import {isWalletOwnerId} from './walletOwner'
|
||||
import type {EncryptedBearerRecord} from './bearers'
|
||||
import {readEncryptedBearers, writeEncryptedBearers} from './bearers'
|
||||
import {commitFundsRestore, readFundsDocument} from './bearers'
|
||||
import type {WalletSettings} from './settings'
|
||||
import {loadSettings, persistSettings} from './settings'
|
||||
import {isJsonObject} from '../jsonParsing'
|
||||
|
||||
export type BackupFile = {
|
||||
type: 'sattle-backup'
|
||||
version: 1
|
||||
version: 2
|
||||
createdAt: number
|
||||
ownerId?: unknown
|
||||
linkingKey?: StoredSecret
|
||||
bearers: EncryptedBearerRecord[]
|
||||
nextByHost: Record<string, number>
|
||||
trustedMints?: TrustedMint[]
|
||||
settings?: WalletSettings
|
||||
}
|
||||
|
||||
type ParsedBackupFile = {
|
||||
type: 'sattle-backup'
|
||||
version: 1
|
||||
version: 2
|
||||
createdAt?: unknown
|
||||
ownerId?: unknown
|
||||
linkingKey?: unknown
|
||||
bearers: unknown[]
|
||||
nextByHost: Record<string, unknown>
|
||||
trustedMints?: unknown
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
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 = {
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
version: 2,
|
||||
createdAt: Date.now(),
|
||||
bearers: readEncryptedBearers(),
|
||||
bearers: funds.bearers,
|
||||
nextByHost: funds.nextByHost,
|
||||
trustedMints: readTrustedMints(ownerId),
|
||||
settings: loadSettings(),
|
||||
}
|
||||
@@ -91,8 +103,9 @@ const MAX_BACKUP_FIELD_LENGTH = 64 * 1024
|
||||
const isBackupFile = (data: unknown): data is ParsedBackupFile =>
|
||||
isJsonObject(data) &&
|
||||
data.type === 'sattle-backup' &&
|
||||
data.version === 1 &&
|
||||
Array.isArray(data.bearers)
|
||||
data.version === 2 &&
|
||||
Array.isArray(data.bearers) &&
|
||||
isJsonObject(data.nextByHost)
|
||||
|
||||
export const parseBackupFile = (data: unknown): ParsedBackupFile => {
|
||||
if (!isBackupFile(data)) {
|
||||
@@ -102,7 +115,10 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => {
|
||||
}
|
||||
|
||||
// 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 -
|
||||
// never overwriting an existing wallet. That guard is deliberate (a
|
||||
// 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.
|
||||
export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => {
|
||||
const backup = parseBackupFile(data)
|
||||
const existing = readEncryptedBearers()
|
||||
const existingIds = new Set(existing.map((r) => r.id))
|
||||
if (backup.bearers.length > MAX_BACKUP_RECORDS) {
|
||||
throw new Error(
|
||||
`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
|
||||
for (const record of backup.bearers) {
|
||||
if (
|
||||
@@ -136,21 +150,31 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise<Rest
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if (existingIds.has(record.id)) {
|
||||
skipped++
|
||||
continue
|
||||
incomingBearers.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext})
|
||||
}
|
||||
existing.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext})
|
||||
existingIds.add(record.id)
|
||||
added++
|
||||
// candidate counters: validity (host length, safe range, host cap) is
|
||||
// enforced by commitFundsRestore; here we only keep the raw shape honest
|
||||
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 {
|
||||
writeEncryptedBearers(existing)
|
||||
} catch {
|
||||
const merged = await commitFundsRestore(incomingBearers, incomingCounters)
|
||||
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(
|
||||
'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 linkingKeySkipped = false
|
||||
|
||||
@@ -174,16 +174,6 @@ export const writeFundsDocument = (doc: StoredFundsV2): void => {
|
||||
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 readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
publishBackup,
|
||||
} 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 { useWalletStore } from './wallet';
|
||||
import { useMintsStore } from './mints';
|
||||
@@ -62,11 +62,17 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => {
|
||||
wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null,
|
||||
);
|
||||
|
||||
const currentPayload = (): BackupPartPayload => ({
|
||||
notes: readEncryptedBearers(),
|
||||
const currentPayload = (): BackupPartPayload => {
|
||||
// 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),
|
||||
settings: loadSettings(),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const publishNow = async (): Promise<void> => {
|
||||
const secretKey = deriveBackupKey(wallet.requireLinkingKey());
|
||||
@@ -97,6 +103,10 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => {
|
||||
const schedule = () => publisher?.schedule(currentPayload());
|
||||
const stops = [
|
||||
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.defaultMint, schedule),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user