From 7607e47358fe4d9736c04853b784e05b2bef20c1 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:00 +0200 Subject: [PATCH] feat: apply owner-aware wallet backups --- src/lnurlcash/storage.backup.test.ts | 155 +++++++++++++++++++++++++++ src/lnurlcash/storage/backup.ts | 80 ++++++++------ 2 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 src/lnurlcash/storage.backup.test.ts diff --git a/src/lnurlcash/storage.backup.test.ts b/src/lnurlcash/storage.backup.test.ts new file mode 100644 index 0000000..1c10fce --- /dev/null +++ b/src/lnurlcash/storage.backup.test.ts @@ -0,0 +1,155 @@ +// Backup restore waits for owner-bound trusted-mint convergence before it +// reports success, while retaining the hostile-file merge policy. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import {applyBackup, buildBackup} from './storage' +import {addTrustedMint, readTrustedMints} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) + +type LockRequest = { + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +class DeferredLocks { + readonly requests: LockRequest[] = [] + + readonly request = (_name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({callback, resolve, reject}) + }) + + async releaseNext(): Promise { + const request = this.requests.shift() + if (!request) throw new Error('Expected a queued lock request.') + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } + } +} + +const backup = (server: string, mintPubkey: string) => ({ + type: 'sattle-backup' as const, + version: 1 as const, + createdAt: 1, + bearers: [], + trustedMints: [ + { + server, + mintPubkey, + addedAt: 1, + locked: true, + pendingMintPubkey: KEY_B, + }, + ], +}) + +const installProvenOwner = (): Promise => saveLinkingKey(LINKING_KEY) + +beforeEach(() => { + vi.unstubAllGlobals() + stubLocalStorage() +}) + +describe('owner-bound backup restore', () => { + it('exports only the active owner trusted-mint registry', async () => { + await installProvenOwner() + await addTrustedMint('backup-mint.example', KEY_A, {ownerId: OWNER_ID}) + + expect(buildBackup(OWNER_ID).trustedMints).toEqual([ + expect.objectContaining({server: 'backup-mint.example', mintPubkey: KEY_A}), + ]) + expect(buildBackup(OWNER_ID).ownerId).toBe(OWNER_ID) + expect(buildBackup().trustedMints).toEqual([]) + }) + + it('drops fresh-device mint trust instead of using a valid file owner marker', async () => { + const result = await applyBackup({ + ...backup('file-mint.example', KEY_A), + ownerId: OWNER_ID, + }) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + expect(localStorage.getItem('sattle_trusted_mints')).toBeNull() + }) + + it('does not attach file mints to a malformed owner marker', async () => { + const result = await applyBackup({ + ...backup('file-mint.example', KEY_A), + ownerId: 'malformed-owner', + }) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([]) + }) + + it('does not resolve before the trusted-mint merge commits', async () => { + await installProvenOwner() + const locks = new DeferredLocks() + vi.stubGlobal('navigator', {locks}) + + let settled = false + const restoring = applyBackup(backup('backup-mint.example', KEY_A), OWNER_ID).then((result) => { + settled = true + return result + }) + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)) + + expect(settled).toBe(false) + await locks.releaseNext() + + expect((await restoring).trustedMintsAdded).toBe(1) + const restored = readTrustedMints(OWNER_ID) + expect(restored).toEqual([ + expect.objectContaining({ + server: 'backup-mint.example', + mintPubkey: KEY_A, + locked: false, + unconfirmed: true, + }), + ]) + expect(restored[0]?.pendingMintPubkey).toBeUndefined() + }) + + it('keeps a local locked pin and pending rekey authoritative', async () => { + await installProvenOwner() + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ + version: 1, + ownerId: OWNER_ID, + mints: [ + { + server: 'local.example', + mintPubkey: KEY_A, + addedAt: 1, + locked: true, + pendingMintPubkey: KEY_B, + }, + ], + }), + ) + + const result = await applyBackup(backup('local.example', KEY_B), OWNER_ID) + + expect(result.trustedMintsAdded).toBe(0) + expect(readTrustedMints(OWNER_ID)).toEqual([ + expect.objectContaining({ + mintPubkey: KEY_A, + locked: true, + pendingMintPubkey: KEY_B, + }), + ]) + }) +}) diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts index 89bf71a..c277264 100644 --- a/src/lnurlcash/storage/backup.ts +++ b/src/lnurlcash/storage/backup.ts @@ -10,34 +10,49 @@ import { savedKeyExists, savedKeyIsEncrypted, restoreLinkingKeyStored, - isValidStoredSecret + isValidStoredSecret, } from '../keys' 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 type {WalletSettings} from './settings' import {loadSettings, persistSettings} from './settings' +import {isJsonObject} from '../jsonParsing' export type BackupFile = { type: 'sattle-backup' version: 1 createdAt: number + ownerId?: unknown linkingKey?: StoredSecret bearers: EncryptedBearerRecord[] trustedMints?: TrustedMint[] settings?: WalletSettings } -export const buildBackup = (): BackupFile => { +type ParsedBackupFile = { + type: 'sattle-backup' + version: 1 + createdAt?: unknown + ownerId?: unknown + linkingKey?: unknown + bearers: unknown[] + trustedMints?: unknown + settings?: unknown +} + +export const buildBackup = (ownerId?: string): BackupFile => { const backup: BackupFile = { type: 'sattle-backup', version: 1, createdAt: Date.now(), bearers: readEncryptedBearers(), - trustedMints: readTrustedMints(), - settings: loadSettings() + trustedMints: readTrustedMints(ownerId), + settings: loadSettings(), } + if (isWalletOwnerId(ownerId)) backup.ownerId = ownerId const storedKey = getSavedLinkingKeyStored() if (savedKeyIsEncrypted() && storedKey) { backup.linkingKey = storedKey @@ -73,14 +88,17 @@ export const MAX_BACKUP_FILE_BYTES = 10 * 1024 * 1024 const MAX_BACKUP_RECORDS = 10_000 const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 -const isBackupFile = (data: unknown): data is BackupFile => { - if (typeof data !== 'object' || data === null) return false - const backup = data as Record - return ( - backup.type === 'sattle-backup' && - backup.version === 1 && - Array.isArray(backup.bearers) - ) +const isBackupFile = (data: unknown): data is ParsedBackupFile => + isJsonObject(data) && + data.type === 'sattle-backup' && + data.version === 1 && + Array.isArray(data.bearers) + +export const parseBackupFile = (data: unknown): ParsedBackupFile => { + if (!isBackupFile(data)) { + throw new Error('Not a valid sattle backup file.') + } + return data } // merges a backup into localStorage: bearer records are added by id @@ -94,25 +112,23 @@ const isBackupFile = (data: unknown): data is BackupFile => { // different key. See linkingKeySkipped above. The note-level dedupe (same // note arriving under a different record id, spent-wins) happens after // decrypt, in bearers.ts's mergeBearers. -export const applyBackup = (data: unknown): RestoreResult => { - if (!isBackupFile(data)) { - throw new Error('Not a valid sattle backup file.') - } - const backup = data +export const applyBackup = async (data: unknown, ownerId?: string): Promise => { + const backup = parseBackupFile(data) const existing = readEncryptedBearers() - const existingIds = new Set(existing.map(r => r.id)) + 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.` + `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`, ) } let added = 0 let skipped = 0 for (const record of backup.bearers) { if ( - typeof record?.id !== 'string' || - typeof record?.iv !== 'string' || - typeof record?.ciphertext !== 'string' || + !isJsonObject(record) || + typeof record.id !== 'string' || + typeof record.iv !== 'string' || + typeof record.ciphertext !== 'string' || record.id.length > MAX_BACKUP_FIELD_LENGTH || record.iv.length > MAX_BACKUP_FIELD_LENGTH || record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH @@ -132,14 +148,14 @@ export const applyBackup = (data: unknown): RestoreResult => { writeEncryptedBearers(existing) } catch { 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.', ) } let linkingKeyRestored = false let linkingKeySkipped = false // an invalid key record reads as "no key in this backup", never as skipped - if (backup.linkingKey && isValidStoredSecret(backup.linkingKey)) { + if (isValidStoredSecret(backup.linkingKey)) { if (savedKeyExists()) { linkingKeySkipped = true } else { @@ -148,16 +164,20 @@ export const applyBackup = (data: unknown): RestoreResult => { } } - const trustedMintsAdded = Array.isArray(backup.trustedMints) - ? mergeTrustedMints(backup.trustedMints) - : 0 + // A file-carried owner marker is not identity proof, so it cannot namespace + // imported trust. Fresh file restores drop pins until key proof; active-wallet + // and Nostr restores supply an owner derived from their already-proven key. + const trustedMintsAdded = + ownerId && Array.isArray(backup.trustedMints) + ? await mergeTrustedMints(backup.trustedMints, ownerId) + : 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).defaultMint + if (isJsonObject(backup.settings)) { + const incoming = backup.settings.defaultMint const local = loadSettings() if ( local.defaultMint === undefined && @@ -175,6 +195,6 @@ export const applyBackup = (data: unknown): RestoreResult => { linkingKeyRestored, linkingKeySkipped, trustedMintsAdded, - settingsRestored + settingsRestored, } }