Fix backup export and import. Encrypt backup to the seed.

This commit is contained in:
minibits-cash
2026-09-07 17:31:16 +02:00
parent 03e532e7c9
commit 4401ec3c84
9 changed files with 875 additions and 68 deletions
+145
View File
@@ -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)
})
})
+311
View File
@@ -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
View File
@@ -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",
+62 -1
View File
@@ -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
+21 -1
View File
@@ -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)
+28 -14
View File
@@ -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}>
+62 -48
View File
@@ -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 {
proofsStore: {proofs: Proof[], pendingByMintSecrets: string[]},
mintsStore: MintsStoreSnapshot,
contactsStore: ContactsStoreSnapshot,
}
if(!snapshot.proofsStore || !snapshot.mintsStore || !snapshot.contactsStore) {
throw new Error('Wrong backup format.')
}
return snapshot
} catch (e: any) {
throw new AppError(Err.VALIDATION_ERROR, `Invalid backup: ${e.message}`)
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 AppError(Err.VALIDATION_ERROR, 'This does not look like a Minibits wallet backup.')
}
return snapshot
}
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,42 +181,49 @@ 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) {
const cashuMint = new CashuMint(mint.mintUrl)
const keysResult: GetKeysResponse = await cashuMint.getKeys()
const {keysets: keys} = keysResult
try {
const cashuMint = new CashuMint(mint.mintUrl)
const keysResult: GetKeysResponse = await cashuMint.getKeys()
const {keysets: keys} = keysResult
for(const key of keys) {
if(!key.unit) {
key.unit = 'sat'
for(const key of keys) {
if(!key.unit) {
key.unit = 'sat'
}
log.trace('[importWallet] Hydrating keys for', {keysetId: key.id})
mint.keys.push(key)
}
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()
+3
View File
@@ -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()
+239
View File
@@ -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'))
}