mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Fix backup export and import. Encrypt backup to the seed.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* The backup envelope.
|
||||
*
|
||||
* A backup is bearer money and the only copy of a wallet's state, so both failure
|
||||
* directions matter: a backup that cannot be restored is lost funds, and a backup
|
||||
* anyone can read is stolen funds. These pin both, plus the compatibility promise —
|
||||
* `minibitsA` plaintext backups, saved in notes and password managers long before
|
||||
* encryption existed, must keep restoring.
|
||||
*/
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
import {mnemonicToSeedSync} from '@scure/bip39'
|
||||
import {encodeBackup, decodeBackup} from '../src/services/backup/backupCodec'
|
||||
|
||||
const MNEMONIC =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
const OTHER_MNEMONIC =
|
||||
'legal winner thank year wave sausage worth useful legal winner thank yellow'
|
||||
|
||||
const seed = mnemonicToSeedSync(MNEMONIC)
|
||||
const otherSeed = mnemonicToSeedSync(OTHER_MNEMONIC)
|
||||
|
||||
// Shaped like the real thing: the proof secrets are the part worth stealing.
|
||||
const payload = {
|
||||
proofsStore: {
|
||||
proofs: [
|
||||
{
|
||||
id: '009a1f293253e41e',
|
||||
amount: 2,
|
||||
secret: '407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837',
|
||||
C: '02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea',
|
||||
unit: 'sat',
|
||||
tId: 1,
|
||||
mintUrl: 'https://mint.test',
|
||||
state: 'UNSPENT',
|
||||
},
|
||||
],
|
||||
pendingByMintSecrets: ['407915bc212be61a'],
|
||||
},
|
||||
mintsStore: {
|
||||
mints: [{id: 'mint1111', mintUrl: 'https://mint.test', keys: [], proofsCounters: [{keyset: '009a1f293253e41e', unit: 'sat', counter: 42}]}],
|
||||
blockedMintUrls: [],
|
||||
},
|
||||
contactsStore: {contacts: []},
|
||||
}
|
||||
|
||||
describe('encoding', () => {
|
||||
test('a backup round trips through the seed that made it', () => {
|
||||
const encoded = encodeBackup(payload, seed)
|
||||
|
||||
expect(encoded.startsWith('minibitsB')).toBe(true)
|
||||
expect(decodeBackup(encoded, seed)).toEqual(payload)
|
||||
})
|
||||
|
||||
test('the payload is not readable in the encoded string', () => {
|
||||
const encoded = encodeBackup(payload, seed)
|
||||
const raw = Buffer.from(encoded.slice('minibitsB'.length), 'base64').toString('latin1')
|
||||
|
||||
expect(encoded).not.toContain(payload.proofsStore.proofs[0].secret)
|
||||
expect(raw).not.toContain(payload.proofsStore.proofs[0].secret)
|
||||
expect(raw).not.toContain('mint.test')
|
||||
})
|
||||
|
||||
// Same wallet, same payload, two exports: no shared key material, and nothing
|
||||
// that says the two files belong together.
|
||||
test('two backups of the same wallet look unrelated', () => {
|
||||
const first = encodeBackup(payload, seed)
|
||||
const second = encodeBackup(payload, seed)
|
||||
|
||||
expect(first).not.toEqual(second)
|
||||
expect(decodeBackup(first, seed)).toEqual(decodeBackup(second, seed))
|
||||
})
|
||||
|
||||
// btoa() threw on anything above U+00FF, so a single emoji in a contact name
|
||||
// used to fail the export outright, with no backup produced and only a toast to
|
||||
// show for it. The envelope is UTF-8 now.
|
||||
test('non-Latin1 text survives — it used to break the export', () => {
|
||||
const withUnicode = {
|
||||
...payload,
|
||||
contactsStore: {contacts: [{name: '🥜 Ňuž', about: 'Zkoušků'}]},
|
||||
}
|
||||
|
||||
expect(decodeBackup(encodeBackup(withUnicode, seed), seed)).toEqual(withUnicode)
|
||||
})
|
||||
|
||||
test('refuses to encrypt without a seed', () => {
|
||||
expect(() => encodeBackup(payload, new Uint8Array())).toThrow(/Missing the wallet seed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decoding', () => {
|
||||
test('another wallet cannot read the backup', () => {
|
||||
const encoded = encodeBackup(payload, seed)
|
||||
|
||||
expect(() => decodeBackup(encoded, otherSeed)).toThrow(/different seed phrase/)
|
||||
})
|
||||
|
||||
test('altered ciphertext is rejected, not silently mis-decoded', () => {
|
||||
const encoded = encodeBackup(payload, seed)
|
||||
const envelope = Buffer.from(encoded.slice('minibitsB'.length), 'base64')
|
||||
|
||||
envelope[envelope.length - 1] ^= 0xff
|
||||
|
||||
expect(() => decodeBackup('minibitsB' + envelope.toString('base64'), seed)).toThrow(
|
||||
/Could not decrypt/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a truncated backup says so', () => {
|
||||
const encoded = encodeBackup(payload, seed)
|
||||
|
||||
expect(() => decodeBackup(encoded.slice(0, 20), seed)).toThrow(/incomplete|Could not decrypt/)
|
||||
})
|
||||
|
||||
test('a string that is not a backup at all says so', () => {
|
||||
expect(() => decodeBackup('not a backup', seed)).toThrow(/starts with 'minibits'/)
|
||||
expect(() => decodeBackup('', seed)).toThrow(/starts with 'minibits'/)
|
||||
})
|
||||
|
||||
test('a format from a newer app version asks the user to update', () => {
|
||||
expect(() => decodeBackup('minibitsZsomething', seed)).toThrow(/newer version/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy plaintext backups', () => {
|
||||
// Exactly what the old export produced: btoa(JSON.stringify(payload)).
|
||||
const legacy = (data: unknown) =>
|
||||
'minibitsA' + Buffer.from(JSON.stringify(data), 'latin1').toString('base64')
|
||||
|
||||
test('still restore, with no seed needed', () => {
|
||||
expect(decodeBackup(legacy(payload), new Uint8Array())).toEqual(payload)
|
||||
})
|
||||
|
||||
// btoa mapped each code unit below 0x100 to one byte, so these backups are
|
||||
// latin1, not UTF-8 — reading them as UTF-8 would corrupt every accented name in
|
||||
// them. Only characters up to U+00FF can appear: 'š' (U+0161) never made it into
|
||||
// a legacy backup, because btoa threw and no backup was produced at all.
|
||||
test('and accented names in them are not corrupted', () => {
|
||||
const accented = {...payload, contactsStore: {contacts: [{name: 'Tomás Müller'}]}}
|
||||
|
||||
expect(decodeBackup(legacy(accented), new Uint8Array())).toEqual(accented)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Backup export -> import, at the model layer.
|
||||
*
|
||||
* The screens are thin: ExportBackupScreen serializes `mintsStore.backupSnapshot`
|
||||
* plus the live proofs, and ImportBackupScreen feeds the decoded JSON back through
|
||||
* `restoreFromBackup` / `importProofs` / `importPendingByMintSecrets`. Everything
|
||||
* that can silently destroy a wallet on restore lives on this side of that line,
|
||||
* which is why the round trip is pinned here.
|
||||
*
|
||||
* Each of these covers a way import actually broke on a user's device:
|
||||
* - a backup taken while a payment was pending at the mint threw mid-import,
|
||||
* - importing over an onboarded wallet left the same mint in SQLite twice, one
|
||||
* copy stripped of its keysets.
|
||||
*/
|
||||
jest.mock('../src/services/nostrService', () => ({
|
||||
// cashuUtils -> nostrService -> minibitsService -> models is an import CYCLE.
|
||||
NostrClient: {getFirstTagValue: jest.fn()},
|
||||
}))
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
||||
}))
|
||||
|
||||
import {mnemonicToSeedSync} from '@scure/bip39'
|
||||
import {types, getSnapshot, applySnapshot} from 'mobx-state-tree'
|
||||
import {encodeBackup, decodeBackup} from '../src/services/backup/backupCodec'
|
||||
import {MintsStoreModel, MintsStoreSnapshot} from '../src/models/MintsStore'
|
||||
import {ProofsStoreModel} from '../src/models/ProofsStore'
|
||||
import {ContactsStoreModel} from '../src/models/ContactsStore'
|
||||
import {Database, CounterSeed} from '../src/services/db'
|
||||
|
||||
const TestRoot = types.model('RootStore', {
|
||||
mintsStore: types.optional(MintsStoreModel, {}),
|
||||
proofsStore: types.optional(ProofsStoreModel, {}),
|
||||
contactsStore: types.optional(ContactsStoreModel, {}),
|
||||
})
|
||||
|
||||
// The wallet's own seed: the export encrypts to it, the import types it back in.
|
||||
const SEED = mnemonicToSeedSync(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
)
|
||||
|
||||
const MINT_URL = 'https://mint.test'
|
||||
const OTHER_MINT_URL = 'https://other.test'
|
||||
|
||||
// Real-shaped keyset ids ('00' + 14 hex): a placeholder like 'k1' is not hex, so
|
||||
// isCollidingKeysetId reads it as a legacy base64 id.
|
||||
const KEYSET_1 = '009a1f293253e41e'
|
||||
const KEYSET_2 = '00ad268c4d1f5826'
|
||||
|
||||
const mintSnapshot = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'mint1111',
|
||||
mintUrl: MINT_URL,
|
||||
hostname: 'mint.test',
|
||||
shortname: 'Test Mint',
|
||||
units: ['sat'],
|
||||
keysets: [{id: KEYSET_1, unit: 'sat', active: true, input_fee_ppk: 0}],
|
||||
keys: [{id: KEYSET_1, unit: 'sat', keys: {'1': '02aa'}}],
|
||||
proofsCounters: [{keyset: KEYSET_1, unit: 'sat'}],
|
||||
color: '#abcdef',
|
||||
status: 'ONLINE',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const proofSnapshot = (secret: string, state: string = 'UNSPENT') => ({
|
||||
id: KEYSET_1,
|
||||
amount: 2,
|
||||
secret,
|
||||
C: '02bb',
|
||||
unit: 'sat',
|
||||
tId: 1,
|
||||
mintUrl: MINT_URL,
|
||||
state,
|
||||
})
|
||||
|
||||
/**
|
||||
* What ExportBackupScreen.copyBackup hands to the share sheet — through the real
|
||||
* envelope, so these tests cover the whole path a backup actually travels rather
|
||||
* than a JSON stand-in for it.
|
||||
*/
|
||||
const exportBackup = (root: Instance) =>
|
||||
encodeBackup(
|
||||
{
|
||||
proofsStore: {
|
||||
// The store snapshot is emptied in postProcessSnapshot — the live map is
|
||||
// the only place the proofs exist.
|
||||
proofs: Array.from(root.proofsStore.proofs.values()),
|
||||
pendingByMintSecrets: getSnapshot(root.proofsStore.pendingByMintSecrets),
|
||||
},
|
||||
mintsStore: root.mintsStore.backupSnapshot,
|
||||
contactsStore: getSnapshot(root.contactsStore),
|
||||
},
|
||||
SEED,
|
||||
)
|
||||
|
||||
/**
|
||||
* What ImportBackupScreen.importWallet does with the decoded payload.
|
||||
*
|
||||
* `keysByMintUrl` stands in for the screen's per-mint `getKeys()` call: the backup
|
||||
* carries no keys, so it re-fetches them and pushes them into the decoded JSON
|
||||
* before any of it is applied. A mint missing from the map is one the screen could
|
||||
* not reach — it imports without keys rather than failing the whole restore.
|
||||
*/
|
||||
const importBackup = (root: Instance, encoded: string, keysByMintUrl: Record<string, any[]> = {}) => {
|
||||
const backup: any = decodeBackup(encoded, SEED)
|
||||
|
||||
for (const mint of backup.mintsStore.mints ?? []) {
|
||||
for (const keys of keysByMintUrl[mint.mintUrl] ?? []) mint.keys.push(keys)
|
||||
}
|
||||
|
||||
root.proofsStore.importProofs(backup.proofsStore.proofs)
|
||||
root.proofsStore.importPendingByMintSecrets(backup.proofsStore.pendingByMintSecrets)
|
||||
root.mintsStore.restoreFromBackup(backup.mintsStore as MintsStoreSnapshot)
|
||||
applySnapshot(root.contactsStore, backup.contactsStore)
|
||||
|
||||
// The counters ride in the backup's raw JSON (they are volatile in the model),
|
||||
// seed the SQLite authority, and are read back from it.
|
||||
const counterSeeds: CounterSeed[] = []
|
||||
for (const mint of backup.mintsStore?.mints ?? []) {
|
||||
for (const pc of mint?.proofsCounters ?? []) {
|
||||
if (pc?.keyset && typeof pc.counter === 'number' && pc.counter > 0) {
|
||||
counterSeeds.push({keysetId: pc.keyset, unit: pc.unit, counter: pc.counter})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (counterSeeds.length > 0) Database.seedCounters(counterSeeds)
|
||||
root.mintsStore.hydrateCountersFromDatabase()
|
||||
|
||||
if (root.proofsStore.proofsCount > 0) {
|
||||
Database.addOrUpdateProofs(root.proofsStore.allProofs, 'UNSPENT')
|
||||
}
|
||||
if (root.proofsStore.pendingProofsCount > 0) {
|
||||
Database.addOrUpdateProofs(root.proofsStore.allPendingProofs, 'PENDING')
|
||||
}
|
||||
}
|
||||
|
||||
// The store instances are structurally typed here; the models' own Instance types
|
||||
// would drag the whole root store in for no gain.
|
||||
type Instance = any
|
||||
|
||||
beforeEach(() => {
|
||||
Database.getInstance().executeBatch([
|
||||
['DELETE FROM mints'],
|
||||
['DELETE FROM mint_keysets'],
|
||||
['DELETE FROM proofs'],
|
||||
['DELETE FROM mint_counters'],
|
||||
])
|
||||
})
|
||||
|
||||
describe('backup round trip', () => {
|
||||
test('mints, proofs, counters and the pending registry all come back', () => {
|
||||
const source = TestRoot.create({
|
||||
mintsStore: {mints: [mintSnapshot()]},
|
||||
proofsStore: {
|
||||
proofs: {s1: proofSnapshot('s1'), s2: proofSnapshot('s2', 'PENDING')},
|
||||
pendingByMintSecrets: ['s2'],
|
||||
} as any,
|
||||
})
|
||||
source.mintsStore.mints[0].proofsCounters[0].increaseProofsCounter(7)
|
||||
|
||||
const backup = exportBackup(source)
|
||||
|
||||
// A fresh wallet on another device.
|
||||
Database.getInstance().executeBatch([['DELETE FROM mints'], ['DELETE FROM mint_keysets'], ['DELETE FROM mint_counters']])
|
||||
const target = TestRoot.create({})
|
||||
importBackup(target, backup)
|
||||
|
||||
expect(target.mintsStore.mints.map((m: any) => m.mintUrl)).toEqual([MINT_URL])
|
||||
expect(target.proofsStore.proofs.size).toBe(2)
|
||||
expect(target.proofsStore.proofsCount).toBe(1)
|
||||
expect(target.proofsStore.pendingProofsCount).toBe(1)
|
||||
// The mint-pending registry used to throw here: the import pushed onto the
|
||||
// protected array from outside an action, aborting the whole restore.
|
||||
expect([...target.proofsStore.pendingByMintSecrets]).toEqual(['s2'])
|
||||
})
|
||||
|
||||
// NUT-13 derives from (seed, keysetId, counter). A restore that reset the
|
||||
// counter to 0 would re-derive blinded secrets the mint has already signed.
|
||||
test('the derivation counter survives, through SQLite', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot()]}})
|
||||
source.mintsStore.mints[0].proofsCounters[0].increaseProofsCounter(42)
|
||||
|
||||
const backup = exportBackup(source)
|
||||
expect((decodeBackup(backup, SEED) as any).mintsStore.mints[0].proofsCounters[0].counter).toBe(42)
|
||||
|
||||
Database.getInstance().executeBatch([['DELETE FROM mint_counters']])
|
||||
const target = TestRoot.create({})
|
||||
importBackup(target, backup)
|
||||
|
||||
expect(target.mintsStore.mints[0].proofsCounters[0].counter).toBe(42)
|
||||
expect(Database.getCounters().find(c => c.keysetId === KEYSET_1)?.counter).toBe(42)
|
||||
})
|
||||
|
||||
// What the screen's best-effort key fetch relies on. A mint that is dead,
|
||||
// moved, or merely offline at import time used to throw out of the whole
|
||||
// restore; now it comes back with its keysets and no keys, and the wallet
|
||||
// fetches those on first use.
|
||||
test('a mint whose keys could not be fetched still restores', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot()]}})
|
||||
|
||||
const target = TestRoot.create({})
|
||||
importBackup(target, exportBackup(source)) // no keys supplied for any mint
|
||||
|
||||
const restarted = TestRoot.create({})
|
||||
restarted.mintsStore.hydrateMintsFromDatabase()
|
||||
|
||||
expect(restarted.mintsStore.mints.map((m: any) => m.mintUrl)).toEqual([MINT_URL])
|
||||
expect(restarted.mintsStore.mints[0].keysets.map((k: any) => k.id)).toEqual([KEYSET_1])
|
||||
expect(restarted.mintsStore.mints[0].keys).toHaveLength(0)
|
||||
// The counter shells still exist, so a hydrate can fill the real indices in.
|
||||
expect(restarted.mintsStore.mints[0].proofsCounters.map((c: any) => c.keyset)).toEqual([KEYSET_1])
|
||||
})
|
||||
|
||||
test('the restored proofs are in the database, under the right state', () => {
|
||||
const source = TestRoot.create({
|
||||
proofsStore: {proofs: {s1: proofSnapshot('s1'), s2: proofSnapshot('s2', 'PENDING')}} as any,
|
||||
mintsStore: {mints: [mintSnapshot()]},
|
||||
})
|
||||
|
||||
const target = TestRoot.create({})
|
||||
importBackup(target, exportBackup(source))
|
||||
|
||||
const stored = Database.getInstance().execute(`SELECT secret, state FROM proofs ORDER BY secret`)
|
||||
expect(stored.rows?._array).toEqual([
|
||||
{secret: 's1', state: 'UNSPENT'},
|
||||
{secret: 's2', state: 'PENDING'},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('importing over an existing wallet', () => {
|
||||
// Onboarding adds the Minibits mint before the user can ever reach the import
|
||||
// screen, so the wallet ALWAYS has a mint here — and the backup's copy of that
|
||||
// same mint carries a different local id.
|
||||
const onboardedWallet = () => {
|
||||
const root = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'onboard1'})]}})
|
||||
root.mintsStore.persistAllMints()
|
||||
root.mintsStore.observeMints()
|
||||
return root
|
||||
}
|
||||
|
||||
test('the same mint does not end up in the database twice', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}})
|
||||
const backup = exportBackup(source)
|
||||
|
||||
const target = onboardedWallet()
|
||||
expect(Database.getMints()).toHaveLength(1)
|
||||
|
||||
importBackup(target, backup)
|
||||
|
||||
expect(Database.getMints().map(m => m.id)).toEqual(['backup01'])
|
||||
})
|
||||
|
||||
// The duplicate was not even the worst of it: mint_keysets is keyed by keysetId
|
||||
// and its upsert moves mintId, so the replaced mint's row was left with no
|
||||
// keysets and no keys — and rehydrated on the next launch as an unusable husk.
|
||||
test('and the next launch sees exactly one, intact', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}})
|
||||
|
||||
const target = onboardedWallet()
|
||||
importBackup(target, exportBackup(source), {[MINT_URL]: [{id: KEYSET_1, unit: 'sat', keys: {'1': '02aa'}}]})
|
||||
|
||||
const restarted = TestRoot.create({})
|
||||
restarted.mintsStore.hydrateMintsFromDatabase()
|
||||
|
||||
expect(restarted.mintsStore.mints).toHaveLength(1)
|
||||
expect(restarted.mintsStore.mints[0].keysets.map((k: any) => k.id)).toEqual([KEYSET_1])
|
||||
expect(restarted.mintsStore.mints[0].keys.map((k: any) => k.id)).toEqual([KEYSET_1])
|
||||
})
|
||||
|
||||
// The backup is the wallet being restored: a mint it does not carry is gone,
|
||||
// exactly as it was when the whole state lived in one MMKV snapshot.
|
||||
test('a mint the backup does not carry is removed from the database too', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}})
|
||||
|
||||
const target = TestRoot.create({
|
||||
mintsStore: {
|
||||
mints: [
|
||||
mintSnapshot({id: 'onboard1'}),
|
||||
mintSnapshot({
|
||||
id: 'straymint',
|
||||
mintUrl: OTHER_MINT_URL,
|
||||
hostname: 'other.test',
|
||||
keysets: [{id: KEYSET_2, unit: 'sat', active: true, input_fee_ppk: 0}],
|
||||
keys: [{id: KEYSET_2, unit: 'sat', keys: {'1': '02cc'}}],
|
||||
proofsCounters: [{keyset: KEYSET_2, unit: 'sat'}],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
target.mintsStore.persistAllMints()
|
||||
target.mintsStore.observeMints()
|
||||
expect(Database.getMints()).toHaveLength(2)
|
||||
|
||||
importBackup(target, exportBackup(source))
|
||||
|
||||
expect(Database.getMints().map(m => m.mintUrl)).toEqual([MINT_URL])
|
||||
})
|
||||
|
||||
// Counters are keyed by keysetId and deliberately outlive their mint, so
|
||||
// re-adding one recovers its real derivation index instead of restarting at 0.
|
||||
test('but that mint keeps its derivation counters', () => {
|
||||
const source = TestRoot.create({mintsStore: {mints: [mintSnapshot({id: 'backup01'})]}})
|
||||
|
||||
const target = onboardedWallet()
|
||||
Database.seedCounters([{keysetId: KEYSET_2, unit: 'sat', counter: 99}])
|
||||
|
||||
importBackup(target, exportBackup(source))
|
||||
|
||||
expect(Database.getCounters().find(c => c.keysetId === KEYSET_2)?.counter).toBe(99)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.4.3-beta.19",
|
||||
"version": "0.4.3-beta.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SnapshotOut,
|
||||
types,
|
||||
destroy,
|
||||
applySnapshot,
|
||||
isStateTreeNode,
|
||||
detach,
|
||||
flow,
|
||||
@@ -46,7 +47,9 @@ export type MintsByUnit = {
|
||||
// the ~20 Mint mutators — where forgetting one is SILENT staleness, the exact bug
|
||||
// class this whole effort has been about — each mint gets one onSnapshot observer
|
||||
// that persists its row whenever anything in its subtree changes. It cannot be
|
||||
// forgotten, and it gives ImportBackup persistence for free (applySnapshot fires it).
|
||||
// forgotten. Note the one thing it does NOT cover: nodes that arrive already-formed
|
||||
// rather than by mutation never fire an observer, which is why restoring a backup
|
||||
// goes through restoreFromBackup instead of a bare applySnapshot.
|
||||
//
|
||||
// Derivation counters are unaffected: `counter` is volatile, so it never appears in
|
||||
// a snapshot and a bump never fires these.
|
||||
@@ -245,6 +248,64 @@ export const MintsStoreModel = types
|
||||
}))
|
||||
.actions(self => ({
|
||||
|
||||
/**
|
||||
* Replace the wallet's mints with the ones from a backup — in BOTH engines.
|
||||
*
|
||||
* The counterpart to `backupSnapshot`, and it exists for the same reason:
|
||||
* ImportBackup used to do this inline, as applySnapshot + persistAllMints,
|
||||
* and that covers only half of it. applySnapshot REPLACES the mints in the
|
||||
* model, but a mint the wallet already had keeps its SQLite row — under its
|
||||
* own id, which the backup's copy of the same mint does not share. The
|
||||
* import screen is reached from an onboarded wallet, which always has the
|
||||
* Minibits mint (WelcomeScreen adds it), so this was not an edge case: the
|
||||
* next launch hydrated BOTH rows and the user saw the same mint twice.
|
||||
*
|
||||
* Worse than a duplicate: mint_keysets is keyed by keysetId and its upsert
|
||||
* reassigns mintId, so the imported mint takes the keysets with it and the
|
||||
* stale row rehydrates as a husk with no keysets and no keys.
|
||||
*
|
||||
* So the removals are the point. Under MMKV this came for free — the
|
||||
* snapshot WAS the state, and applying one dropped whatever it omitted.
|
||||
* With mints mastered in SQLite, dropping them has to be said out loud.
|
||||
*
|
||||
* Observers are disposed first (they point at nodes applySnapshot is about
|
||||
* to destroy) and re-attached at the end, once the array has settled.
|
||||
*/
|
||||
restoreFromBackup(snapshot: MintsStoreSnapshot) {
|
||||
const previousMintIds = self.mints.map(m => m.id as string)
|
||||
|
||||
for (const mintId of [...self.mintObservers.keys()]) self.unobserveMint(mintId)
|
||||
|
||||
applySnapshot(self, snapshot as any)
|
||||
|
||||
const restoredMintIds = new Set(self.mints.map(m => m.id as string))
|
||||
|
||||
// Rows for mints the backup does not carry. Their mint_counters rows
|
||||
// stay behind, as they do on any mint removal, so re-adding a mint
|
||||
// recovers its real derivation counter rather than restarting at 0.
|
||||
for (const mintId of previousMintIds) {
|
||||
if (restoredMintIds.has(mintId)) continue
|
||||
try {
|
||||
Database.removeMintById(mintId)
|
||||
} catch (e: any) {
|
||||
log.error('[restoreFromBackup]', 'Could not remove a replaced mint', {
|
||||
error: e?.message,
|
||||
mintId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes that arrive already-formed never fire an observer, so the write
|
||||
// through has to be explicit.
|
||||
self.persistAllMints()
|
||||
self.observeMints()
|
||||
|
||||
log.info('[restoreFromBackup]', 'Mints restored from a backup', {
|
||||
restored: self.mints.length,
|
||||
removed: previousMintIds.filter(id => !restoredMintIds.has(id)).length,
|
||||
})
|
||||
},
|
||||
|
||||
hydrateCountersFromDatabase() {
|
||||
const rows = Database.getCounters()
|
||||
if (rows.length === 0) return
|
||||
|
||||
@@ -228,7 +228,8 @@ import {
|
||||
},
|
||||
|
||||
// Called when the mint explicitly reports PENDING (lightning in-flight).
|
||||
// Only place that adds to pendingByMintSecrets.
|
||||
// The only place that adds to pendingByMintSecrets during normal operation
|
||||
// (importPendingByMintSecrets below restores it from a backup).
|
||||
registerAsPendingAtMint(proofs: Proof[]) {
|
||||
for (const p of proofs) {
|
||||
if (!self.pendingByMintSecrets.includes(p.secret)) {
|
||||
@@ -237,6 +238,25 @@ import {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore the mint-pending registry from a backup.
|
||||
*
|
||||
* Separate from registerAsPendingAtMint because the import holds bare
|
||||
* secrets decoded from JSON, not Proof instances — and it has to be an
|
||||
* ACTION: ImportBackupScreen used to push onto this array directly, which
|
||||
* MST rejects on a protected tree ("the object is protected and can only be
|
||||
* modified by using an action"). That threw in the middle of the import, so
|
||||
* a backup taken while a payment was pending at the mint could not be
|
||||
* restored at all.
|
||||
*/
|
||||
importPendingByMintSecrets(secrets: string[]) {
|
||||
for (const secret of secrets ?? []) {
|
||||
if (!self.pendingByMintSecrets.includes(secret)) {
|
||||
self.pendingByMintSecrets.push(secret)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called when the mint no longer reports PENDING (payment settled or failed).
|
||||
unregisterFromPendingAtMint(secrets: string[] | Set<string>) {
|
||||
const set = secrets instanceof Set ? secrets : new Set(secrets)
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {log} from '../services/logService'
|
||||
import AppError from '../utils/AppError'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { Proof } from '../models/Proof'
|
||||
import { useStores } from '../models'
|
||||
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
|
||||
@@ -38,6 +38,8 @@ import { ResultModalInfo } from './Wallet/ResultModalInfo'
|
||||
import { verticalScale } from '@gocodingnow/rn-size-matters'
|
||||
import { Token, getEncodedToken, normalizeProofAmounts } from '@cashu/cashu-ts'
|
||||
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
import { mnemonicToSeedSync } from '@scure/bip39'
|
||||
import { encodeBackup } from '../services/backup/backupCodec'
|
||||
|
||||
const OPTIMIZE_FROM_PROOFS_COUNT = 10
|
||||
type Props = StaticScreenProps<undefined>
|
||||
@@ -47,7 +49,8 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
const {
|
||||
mintsStore,
|
||||
contactsStore,
|
||||
proofsStore
|
||||
proofsStore,
|
||||
walletStore
|
||||
} = useStores()
|
||||
|
||||
/* useHeader({
|
||||
@@ -166,26 +169,37 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
|
||||
log.trace({exportedSnapshot})
|
||||
|
||||
const prefix = 'minibits'
|
||||
const version = 'A'
|
||||
// Encrypted to the wallet's seed. The payload is every proof's secret and
|
||||
// signature — bearer money — and it used to leave the app as plain base64,
|
||||
// readable by anything the user shared it through. See services/backup for
|
||||
// the envelope.
|
||||
//
|
||||
// The key is derived from the MNEMONIC rather than read straight from the
|
||||
// keychain seed, because the mnemonic is all the import has: it turns the
|
||||
// words the user types into the seed with this same call. The two are the
|
||||
// same value for every wallet this app has made, and deriving it the same
|
||||
// way on both sides means they cannot quietly stop being.
|
||||
const mnemonic: string = await walletStore.getCachedMnenomic()
|
||||
|
||||
// CBOR - WIP, not working
|
||||
// const encodedData = encodeCBOR(exportedSnapshot)
|
||||
// const base64Data = encodeUint8toBase64Url(encodedData)
|
||||
if(!mnemonic) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'This wallet has no seed phrase to encrypt the backup with.',
|
||||
)
|
||||
}
|
||||
|
||||
// Simple BASE64
|
||||
const base64Data = btoa(JSON.stringify(exportedSnapshot))
|
||||
|
||||
const base64Encoded = prefix + version + base64Data
|
||||
const encodedBackup = encodeBackup(exportedSnapshot, mnemonicToSeedSync(mnemonic))
|
||||
|
||||
await Share.share({
|
||||
title: 'minibits-backup.txt',
|
||||
message: base64Encoded,
|
||||
message: encodedBackup,
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
} catch (e: any) {
|
||||
setInfo(`Could not encode and export wallet backup: ${e.message}`)
|
||||
// Everything the codec raises is already written for the user; only an
|
||||
// unexpected failure (the share sheet, the keychain) needs framing.
|
||||
setInfo(e instanceof AppError ? e.message : `Could not export the wallet backup: ${e.message}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -390,7 +404,7 @@ export const ExportBackupScreen = function ExportBackup({ route }: Props) {
|
||||
style={{color: hint}}
|
||||
size='xs'
|
||||
preset='formHelper'
|
||||
text='You will still need your seed phrase when using this backup to recover your wallet.'
|
||||
text='This backup is encrypted with your seed phrase. You will need those words to restore it.'
|
||||
/>
|
||||
</View>
|
||||
<View style={$buttonContainer}>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ContactsStoreSnapshot } from '../models/ContactsStore'
|
||||
import { Mint as CashuMint, GetKeysResponse } from '@cashu/cashu-ts'
|
||||
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
import { Proof } from '../models/Proof'
|
||||
import { decodeBackup } from '../services/backup/backupCodec'
|
||||
|
||||
type Props = StaticScreenProps<undefined>
|
||||
|
||||
@@ -123,36 +124,36 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decode the pasted backup, whichever format it is in.
|
||||
*
|
||||
* The seed does the decrypting, and this is why the screen asks for the
|
||||
* mnemonic FIRST: by the time a backup can be pasted at all, seedRef holds the
|
||||
* seed those words derive — the same value the export encrypted with. Older
|
||||
* plaintext backups ignore it and still restore. See services/backup for the
|
||||
* envelope itself; every message it raises is already written for the user, so
|
||||
* they are shown as they are rather than wrapped in another prefix.
|
||||
*/
|
||||
const getWalletSnapshot = function () {
|
||||
try {
|
||||
if(!backup.startsWith('minibitsA')) {
|
||||
throw new Error('Minibits backup needs to start with minibitsA.')
|
||||
}
|
||||
|
||||
// decode
|
||||
const decoded = atob(backup.substring(9))
|
||||
|
||||
// try to load as json
|
||||
const snapshot = JSON.parse(decoded) as {
|
||||
const snapshot = decodeBackup(backup, seedRef.current!) as {
|
||||
proofsStore: {proofs: Proof[], pendingByMintSecrets: string[]},
|
||||
mintsStore: MintsStoreSnapshot,
|
||||
contactsStore: ContactsStoreSnapshot,
|
||||
}
|
||||
|
||||
if(!snapshot.proofsStore || !snapshot.mintsStore || !snapshot.contactsStore) {
|
||||
throw new Error('Wrong backup format.')
|
||||
if(!snapshot?.proofsStore || !snapshot?.mintsStore || !snapshot?.contactsStore) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'This does not look like a Minibits wallet backup.')
|
||||
}
|
||||
|
||||
return snapshot
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, `Invalid backup: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const onConfirmBackup = async function () {
|
||||
try {
|
||||
if(!backup || !seedHashRef.current) {
|
||||
// seedRef, not just seedHashRef: an encrypted backup cannot be opened
|
||||
// without the seed itself.
|
||||
if(!backup || !seedRef.current) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Missing backup or seed.')
|
||||
}
|
||||
|
||||
@@ -180,8 +181,17 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
// import wallet snapshot into the state
|
||||
// const rootStore = rootStoreInstance
|
||||
|
||||
// hydrate mint keys back to the backup as they are stripped from backup
|
||||
// Hydrate mint keys back into the backup, which carries none: they are the
|
||||
// bulk of the payload and the mint serves them.
|
||||
//
|
||||
// BEST EFFORT, per mint. A mint that has died, moved, or is simply offline
|
||||
// right now used to throw here and abort the entire import — nothing was
|
||||
// restored, and the user could never get their other mints back. Keys are
|
||||
// not needed to restore a mint: the keyset metadata comes from the backup,
|
||||
// hydrateMintsFromDatabase tolerates a keyset whose keys were never
|
||||
// fetched, and the wallet re-fetches them on first use.
|
||||
for (const mint of walletSnapshot.mintsStore.mints) {
|
||||
try {
|
||||
const cashuMint = new CashuMint(mint.mintUrl)
|
||||
const keysResult: GetKeysResponse = await cashuMint.getKeys()
|
||||
const {keysets: keys} = keysResult
|
||||
@@ -194,28 +204,26 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
log.trace('[importWallet] Hydrating keys for', {keysetId: key.id})
|
||||
mint.keys.push(key)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.warn('[importWallet]', 'Could not fetch keys, importing the mint without them', {
|
||||
mintUrl: mint.mintUrl,
|
||||
error: e?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// applySnapshot(proofsStore, walletSnapshot.proofsStore)
|
||||
proofsStore.importProofs(walletSnapshot.proofsStore.proofs)
|
||||
for(const secret of walletSnapshot.proofsStore.pendingByMintSecrets) {
|
||||
proofsStore.pendingByMintSecrets.push(secret)
|
||||
}
|
||||
applySnapshot(mintsStore, walletSnapshot.mintsStore)
|
||||
proofsStore.importPendingByMintSecrets(walletSnapshot.proofsStore.pendingByMintSecrets)
|
||||
// Mints are mastered in SQLite, so restoring them is more than an
|
||||
// applySnapshot — the mints being replaced have rows of their own that have
|
||||
// to go. See MintsStore.restoreFromBackup.
|
||||
mintsStore.restoreFromBackup(walletSnapshot.mintsStore)
|
||||
applySnapshot(contactsStore, walletSnapshot.contactsStore)
|
||||
|
||||
// Mints are mastered in SQLite, and applySnapshot only puts them in the
|
||||
// model — so write them through explicitly. The per-mint observers fire on
|
||||
// CHANGE, and these nodes arrived already-formed, so nothing else would
|
||||
// persist them; the mints would then vanish on the next launch, which
|
||||
// hydrates from the database. Re-attach the observers too: applySnapshot
|
||||
// replaced the array, so the previous ones point at destroyed nodes.
|
||||
mintsStore.persistAllMints()
|
||||
mintsStore.observeMints()
|
||||
|
||||
// The backup carries real derivation counters in its raw MST snapshot.
|
||||
// `counter` is VOLATILE in the model (mastered in SQLite), so the
|
||||
// applySnapshot above does NOT load it — read the values straight from
|
||||
// `counter` is VOLATILE in the model (mastered in SQLite), so the restore
|
||||
// above does NOT load it — read the values straight from
|
||||
// the backup snapshot, seed SQLite (monotonic, never lowers), then
|
||||
// hydrate the in-memory cache from the authority. Seeding 0 from the
|
||||
// live (just-reset) model would risk blinded-secret reuse on restore.
|
||||
@@ -294,6 +302,12 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
// The cached CashuWallet instances hold the OLD bip39 seed. Dropping the
|
||||
// keychain cache alone does not reach them, so anything already built in
|
||||
// this session would keep deriving from the seed the import just
|
||||
// replaced — against the imported counters, producing ecash the restored
|
||||
// mnemonic cannot recover.
|
||||
walletStore.resetWallets()
|
||||
|
||||
// Re-authenticate with new derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
|
||||
@@ -499,6 +499,9 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
// Same reason as in ImportBackupScreen: cached CashuWallet instances
|
||||
// carry the old bip39 seed and the keychain cache does not reach them.
|
||||
walletStore.resetWallets()
|
||||
|
||||
// Re-authenticate with new derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The wallet backup envelope: how a backup payload becomes the string the user
|
||||
* carries between devices, and how it comes back.
|
||||
*
|
||||
* A backup is BEARER MONEY — it holds every proof's secret and signature — and it
|
||||
* used to travel as plain base64. Anyone who saw the shared text, or the file it
|
||||
* landed in, could redeem all of it. So the payload is encrypted to the wallet's
|
||||
* bip39 seed, which the import screen already has: it makes the user enter their
|
||||
* mnemonic BEFORE the backup can be pasted, because the profile recovery needs it.
|
||||
* The key is therefore available exactly when it is needed, with nothing new to
|
||||
* remember and nothing extra to type.
|
||||
*
|
||||
* The format is versioned by a single character after the `minibits` prefix:
|
||||
*
|
||||
* minibitsA<base64> plaintext JSON. Read-only, and forever: people have old
|
||||
* backups saved in notes and password managers, and refusing
|
||||
* them would strand wallets.
|
||||
* minibitsB<base64> salt(16) ‖ iv(12) ‖ tag(16) ‖ AES-256-GCM ciphertext.
|
||||
*
|
||||
* Two things deliberately NOT done here:
|
||||
*
|
||||
* - The key is not derived from `seedHash`. That value identifies the wallet
|
||||
* profile to the Minibits server, which therefore knows it; a backup key must
|
||||
* be something only the holder of the mnemonic can compute.
|
||||
*
|
||||
* - The envelope carries no wallet identifier. It would let the import say "wrong
|
||||
* seed phrase" rather than "wrong seed phrase or damaged backup", at the price
|
||||
* of a stable fingerprint linking any two backup files to the same wallet. The
|
||||
* GCM tag already distinguishes the two cases well enough in practice.
|
||||
*/
|
||||
import QuickCrypto from 'react-native-quick-crypto'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
import {log} from '../logService'
|
||||
|
||||
const PREFIX = 'minibits'
|
||||
/** Plaintext JSON. Decoded, never produced. */
|
||||
const VERSION_PLAINTEXT = 'A'
|
||||
/** AES-256-GCM, keyed by the bip39 seed. */
|
||||
const VERSION_ENCRYPTED = 'B'
|
||||
|
||||
const SALT_BYTES = 16
|
||||
const IV_BYTES = 12
|
||||
const TAG_BYTES = 16
|
||||
const KEY_BYTES = 32
|
||||
|
||||
/**
|
||||
* HKDF domain separator. Changing it makes every existing backup undecryptable,
|
||||
* so it changes only alongside a new version character.
|
||||
*/
|
||||
const HKDF_INFO = 'minibits/backup/v1'
|
||||
|
||||
const CIPHER = 'aes-256-gcm'
|
||||
|
||||
/**
|
||||
* Every failure in here is shown to the user as-is, so each message is a whole
|
||||
* sentence naming what went wrong and, where there is one, what to do about it.
|
||||
* AppError also carries them to Sentry — a wallet that cannot restore its backup
|
||||
* is exactly what we want to hear about.
|
||||
*/
|
||||
const backupError = (message: string, params?: Record<string, any>) =>
|
||||
new AppError(Err.VALIDATION_ERROR, message, params)
|
||||
|
||||
/**
|
||||
* The backup key, derived from the bip39 seed with HKDF-SHA256 (RFC 5869).
|
||||
*
|
||||
* HKDF and not a password KDF: the input is a 512-bit seed, already uniformly
|
||||
* random, so there is nothing for iteration count to buy — Argon2 or a large
|
||||
* PBKDF2 would only make every export and import slower. The random per-backup
|
||||
* salt means two backups of the same wallet share no key material.
|
||||
*
|
||||
* The expand step is a single block because the output is 32 bytes, exactly one
|
||||
* SHA-256 block: T(1) = HMAC(PRK, info ‖ 0x01).
|
||||
*/
|
||||
const deriveBackupKey = function (seed: Uint8Array, salt: Uint8Array) {
|
||||
const prk = QuickCrypto.createHmac('sha256', Buffer.from(salt))
|
||||
.update(Buffer.from(seed))
|
||||
.digest()
|
||||
|
||||
const okm = QuickCrypto.createHmac('sha256', prk)
|
||||
.update(Buffer.concat([Buffer.from(HKDF_INFO, 'utf8'), Buffer.from([1])]))
|
||||
.digest()
|
||||
|
||||
return okm.subarray(0, KEY_BYTES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a backup payload as an encrypted `minibitsB` string.
|
||||
*
|
||||
* `seed` MUST be the seed the mnemonic derives — `mnemonicToSeedSync(mnemonic)` —
|
||||
* because that is what the import computes from the words the user types. The two
|
||||
* are the same value for every wallet this codebase has ever created (the keychain
|
||||
* seed is generated from the mnemonic), but the import has only the mnemonic, so
|
||||
* that is the side the contract is written from.
|
||||
*
|
||||
* The result is decrypted again before it is returned. That is not paranoia about
|
||||
* the cipher: an export that produces an unrestorable string fails SILENTLY, and
|
||||
* is discovered by the user at the worst possible moment — when the phone is gone
|
||||
* and the backup is all they have. One extra decryption makes that outcome
|
||||
* impossible rather than unlikely.
|
||||
*/
|
||||
export const encodeBackup = function (payload: unknown, seed: Uint8Array): string {
|
||||
if (!seed || seed.length === 0) {
|
||||
throw backupError('Missing the wallet seed to encrypt the backup with.', {
|
||||
caller: 'encodeBackup',
|
||||
})
|
||||
}
|
||||
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
let encoded: string
|
||||
|
||||
try {
|
||||
const salt = Buffer.from(QuickCrypto.randomBytes(SALT_BYTES))
|
||||
const iv = Buffer.from(QuickCrypto.randomBytes(IV_BYTES))
|
||||
|
||||
const cipher = QuickCrypto.createCipheriv(CIPHER, deriveBackupKey(seed, salt), iv)
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
encoded = PREFIX + VERSION_ENCRYPTED + Buffer.concat([salt, iv, tag, ciphertext]).toString('base64')
|
||||
} catch (e: any) {
|
||||
throw backupError(`Could not encrypt the wallet backup: ${e.message}`, {
|
||||
caller: 'encodeBackup',
|
||||
})
|
||||
}
|
||||
|
||||
// Read back what we are about to hand the user. See the note above.
|
||||
let verified: unknown
|
||||
try {
|
||||
verified = decodeBackup(encoded, seed)
|
||||
} catch (e: any) {
|
||||
throw backupError('The backup was encrypted but could not be read back, so it was not exported.', {
|
||||
caller: 'encodeBackup',
|
||||
error: e.message,
|
||||
})
|
||||
}
|
||||
|
||||
if (!Buffer.from(JSON.stringify(verified), 'utf8').equals(plaintext)) {
|
||||
throw backupError('The encrypted backup did not decrypt back to the wallet state, so it was not exported.', {
|
||||
caller: 'encodeBackup',
|
||||
})
|
||||
}
|
||||
|
||||
log.debug('[encodeBackup]', 'Wallet backup encrypted', {bytes: encoded.length})
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded bytes as a payload. Anything that reaches JSON.parse has already
|
||||
* passed the GCM tag (or is a legacy plaintext body), so a parse failure here
|
||||
* means the string itself was mangled on its way between the two devices —
|
||||
* truncated by whatever the user pasted it through, most likely.
|
||||
*/
|
||||
const parsePayload = function (json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (e: any) {
|
||||
throw backupError('The backup could not be read. It may have been cut short when it was copied.', {
|
||||
caller: 'decodeBackup',
|
||||
error: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Split `minibits<version><body>`, or say why it is not a backup at all. */
|
||||
const parseEnvelope = function (backup: string): {version: string; body: string} {
|
||||
const trimmed = (backup ?? '').trim()
|
||||
|
||||
if (!trimmed.startsWith(PREFIX) || trimmed.length <= PREFIX.length) {
|
||||
throw backupError(`A Minibits backup starts with '${PREFIX}'.`, {caller: 'decodeBackup'})
|
||||
}
|
||||
|
||||
return {
|
||||
version: trimmed.charAt(PREFIX.length),
|
||||
body: trimmed.slice(PREFIX.length + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a backup string back into its payload, whichever version it is.
|
||||
*
|
||||
* The legacy plaintext body is read as LATIN-1 on purpose. It was produced by
|
||||
* `btoa(JSON.stringify(...))`, which maps each UTF-16 code unit below 0x100 to one
|
||||
* byte — so an accented character in a contact name is a single byte in those
|
||||
* backups, and reading them as UTF-8 would corrupt exactly the names that motivated
|
||||
* moving off `btoa` in the first place. (`btoa` also threw outright on anything
|
||||
* above U+00FF, which is why some exports used to fail with no backup produced;
|
||||
* `minibitsB` is UTF-8 throughout and has no such limit.)
|
||||
*/
|
||||
export const decodeBackup = function (backup: string, seed: Uint8Array): unknown {
|
||||
const {version, body} = parseEnvelope(backup)
|
||||
|
||||
if (version === VERSION_PLAINTEXT) {
|
||||
return parsePayload(Buffer.from(body, 'base64').toString('latin1'))
|
||||
}
|
||||
|
||||
if (version !== VERSION_ENCRYPTED) {
|
||||
throw backupError(
|
||||
`This backup was made by a newer version of Minibits (format ${version}). Update the app to restore it.`,
|
||||
{caller: 'decodeBackup'},
|
||||
)
|
||||
}
|
||||
|
||||
if (!seed || seed.length === 0) {
|
||||
throw backupError('Missing the seed phrase this backup was encrypted with.', {
|
||||
caller: 'decodeBackup',
|
||||
})
|
||||
}
|
||||
|
||||
const envelope = Buffer.from(body, 'base64')
|
||||
|
||||
if (envelope.length <= SALT_BYTES + IV_BYTES + TAG_BYTES) {
|
||||
throw backupError('The backup is incomplete — it may have been cut short when it was copied.', {
|
||||
caller: 'decodeBackup',
|
||||
})
|
||||
}
|
||||
|
||||
const salt = envelope.subarray(0, SALT_BYTES)
|
||||
const iv = envelope.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES)
|
||||
const tag = envelope.subarray(SALT_BYTES + IV_BYTES, SALT_BYTES + IV_BYTES + TAG_BYTES)
|
||||
const ciphertext = envelope.subarray(SALT_BYTES + IV_BYTES + TAG_BYTES)
|
||||
|
||||
const decipher = QuickCrypto.createDecipheriv(CIPHER, deriveBackupKey(seed, salt), iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
let plaintext: Buffer
|
||||
try {
|
||||
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
} catch (e: any) {
|
||||
// GCM cannot tell a wrong key from altered bytes — both fail the same tag
|
||||
// check — so the message names both, in the order the user should check.
|
||||
throw backupError(
|
||||
'Could not decrypt the backup. It belongs to a different seed phrase, or it was damaged in transit.',
|
||||
{caller: 'decodeBackup'},
|
||||
)
|
||||
}
|
||||
|
||||
return parsePayload(plaintext.toString('utf8'))
|
||||
}
|
||||
Reference in New Issue
Block a user