mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|