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,
|
savedKeyExists,
|
||||||
savedKeyIsEncrypted,
|
savedKeyIsEncrypted,
|
||||||
restoreLinkingKeyStored,
|
restoreLinkingKeyStored,
|
||||||
isValidStoredSecret
|
isValidStoredSecret,
|
||||||
} from '../keys'
|
} from '../keys'
|
||||||
import type {TrustedMint} from '../trustedMints'
|
import type {TrustedMint} from '../trustedMints'
|
||||||
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
|
import {readTrustedMints, mergeTrustedMints} from '../trustedMints'
|
||||||
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
import type {EncryptedBearerRecord} from './bearers'
|
import type {EncryptedBearerRecord} from './bearers'
|
||||||
import {readEncryptedBearers, writeEncryptedBearers} from './bearers'
|
import {readEncryptedBearers, writeEncryptedBearers} 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'
|
||||||
|
|
||||||
export type BackupFile = {
|
export type BackupFile = {
|
||||||
type: 'sattle-backup'
|
type: 'sattle-backup'
|
||||||
version: 1
|
version: 1
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
ownerId?: unknown
|
||||||
linkingKey?: StoredSecret
|
linkingKey?: StoredSecret
|
||||||
bearers: EncryptedBearerRecord[]
|
bearers: EncryptedBearerRecord[]
|
||||||
trustedMints?: TrustedMint[]
|
trustedMints?: TrustedMint[]
|
||||||
settings?: WalletSettings
|
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 = {
|
const backup: BackupFile = {
|
||||||
type: 'sattle-backup',
|
type: 'sattle-backup',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
bearers: readEncryptedBearers(),
|
bearers: readEncryptedBearers(),
|
||||||
trustedMints: readTrustedMints(),
|
trustedMints: readTrustedMints(ownerId),
|
||||||
settings: loadSettings()
|
settings: loadSettings(),
|
||||||
}
|
}
|
||||||
|
if (isWalletOwnerId(ownerId)) backup.ownerId = ownerId
|
||||||
const storedKey = getSavedLinkingKeyStored()
|
const storedKey = getSavedLinkingKeyStored()
|
||||||
if (savedKeyIsEncrypted() && storedKey) {
|
if (savedKeyIsEncrypted() && storedKey) {
|
||||||
backup.linkingKey = 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_RECORDS = 10_000
|
||||||
const MAX_BACKUP_FIELD_LENGTH = 64 * 1024
|
const MAX_BACKUP_FIELD_LENGTH = 64 * 1024
|
||||||
|
|
||||||
const isBackupFile = (data: unknown): data is BackupFile => {
|
const isBackupFile = (data: unknown): data is ParsedBackupFile =>
|
||||||
if (typeof data !== 'object' || data === null) return false
|
isJsonObject(data) &&
|
||||||
const backup = data as Record<string, unknown>
|
data.type === 'sattle-backup' &&
|
||||||
return (
|
data.version === 1 &&
|
||||||
backup.type === 'sattle-backup' &&
|
Array.isArray(data.bearers)
|
||||||
backup.version === 1 &&
|
|
||||||
Array.isArray(backup.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
|
// 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
|
// different key. See linkingKeySkipped above. The note-level dedupe (same
|
||||||
// note arriving under a different record id, spent-wins) happens after
|
// note arriving under a different record id, spent-wins) happens after
|
||||||
// decrypt, in bearers.ts's mergeBearers.
|
// decrypt, in bearers.ts's mergeBearers.
|
||||||
export const applyBackup = (data: unknown): RestoreResult => {
|
export const applyBackup = async (data: unknown, ownerId?: string): Promise<RestoreResult> => {
|
||||||
if (!isBackupFile(data)) {
|
const backup = parseBackupFile(data)
|
||||||
throw new Error('Not a valid sattle backup file.')
|
|
||||||
}
|
|
||||||
const backup = data
|
|
||||||
const existing = readEncryptedBearers()
|
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) {
|
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
|
let added = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
for (const record of backup.bearers) {
|
for (const record of backup.bearers) {
|
||||||
if (
|
if (
|
||||||
typeof record?.id !== 'string' ||
|
!isJsonObject(record) ||
|
||||||
typeof record?.iv !== 'string' ||
|
typeof record.id !== 'string' ||
|
||||||
typeof record?.ciphertext !== 'string' ||
|
typeof record.iv !== 'string' ||
|
||||||
|
typeof record.ciphertext !== 'string' ||
|
||||||
record.id.length > MAX_BACKUP_FIELD_LENGTH ||
|
record.id.length > MAX_BACKUP_FIELD_LENGTH ||
|
||||||
record.iv.length > MAX_BACKUP_FIELD_LENGTH ||
|
record.iv.length > MAX_BACKUP_FIELD_LENGTH ||
|
||||||
record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH
|
record.ciphertext.length > MAX_BACKUP_FIELD_LENGTH
|
||||||
@@ -132,14 +148,14 @@ export const applyBackup = (data: unknown): RestoreResult => {
|
|||||||
writeEncryptedBearers(existing)
|
writeEncryptedBearers(existing)
|
||||||
} catch {
|
} catch {
|
||||||
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.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let linkingKeyRestored = false
|
let linkingKeyRestored = false
|
||||||
let linkingKeySkipped = false
|
let linkingKeySkipped = false
|
||||||
// an invalid key record reads as "no key in this backup", never as skipped
|
// 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()) {
|
if (savedKeyExists()) {
|
||||||
linkingKeySkipped = true
|
linkingKeySkipped = true
|
||||||
} else {
|
} else {
|
||||||
@@ -148,16 +164,20 @@ export const applyBackup = (data: unknown): RestoreResult => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustedMintsAdded = Array.isArray(backup.trustedMints)
|
// A file-carried owner marker is not identity proof, so it cannot namespace
|
||||||
? mergeTrustedMints(backup.trustedMints)
|
// 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
|
: 0
|
||||||
|
|
||||||
// settings merge: fill only fields this device has never set. Flat
|
// settings merge: fill only fields this device has never set. Flat
|
||||||
// optional fields (see settings.ts), so the merge is field by field -
|
// optional fields (see settings.ts), so the merge is field by field -
|
||||||
// today that is just defaultMint
|
// today that is just defaultMint
|
||||||
let settingsRestored = false
|
let settingsRestored = false
|
||||||
if (typeof backup.settings === 'object' && backup.settings !== null) {
|
if (isJsonObject(backup.settings)) {
|
||||||
const incoming = (backup.settings as Record<string, unknown>).defaultMint
|
const incoming = backup.settings.defaultMint
|
||||||
const local = loadSettings()
|
const local = loadSettings()
|
||||||
if (
|
if (
|
||||||
local.defaultMint === undefined &&
|
local.defaultMint === undefined &&
|
||||||
@@ -175,6 +195,6 @@ export const applyBackup = (data: unknown): RestoreResult => {
|
|||||||
linkingKeyRestored,
|
linkingKeyRestored,
|
||||||
linkingKeySkipped,
|
linkingKeySkipped,
|
||||||
trustedMintsAdded,
|
trustedMintsAdded,
|
||||||
settingsRestored
|
settingsRestored,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user