feat: apply owner-aware wallet backups

This commit is contained in:
2026-08-22 16:55:00 +02:00
parent 0862a10b01
commit 7607e47358
2 changed files with 205 additions and 30 deletions
+155
View File
@@ -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<unknown> =>
new Promise((resolve, reject) => {
this.requests.push({callback, resolve, reject})
})
async releaseNext(): Promise<void> {
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<void> => 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,
}),
])
})
})
+49 -29
View File
@@ -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<string, unknown>
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<RestoreResult> => {
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)
// 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<string, unknown>).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,
}
}