mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: apply owner-aware wallet backups
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user