diff options
-rw-r--r-- | packages/taler-wallet-cli/src/index.ts | 53 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/crypto/primitives/nacl-fast.ts | 12 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup.ts | 140 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/types/backupTypes.ts | 1 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 25 |
5 files changed, 221 insertions, 10 deletions
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index f4970e73d..87e0e00d1 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -409,6 +409,29 @@ backupCli.subcommand("exportPlain", "export-plain").action(async (args) => { }); }); +backupCli + .subcommand("export", "export") + .requiredArgument("filename", clk.STRING, { + help: "backup filename", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + const backup = await wallet.exportBackupEncrypted(); + fs.writeFileSync(args.export.filename, backup); + }); + }); + +backupCli + .subcommand("import", "import") + .requiredArgument("filename", clk.STRING, { + help: "backup filename", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + const backupEncBlob = fs.readFileSync(args.import.filename); + await wallet.importBackupEncrypted(backupEncBlob); + }); + }); backupCli.subcommand("importPlain", "import-plain").action(async (args) => { await withWallet(args, async (wallet) => { @@ -417,6 +440,36 @@ backupCli.subcommand("importPlain", "import-plain").action(async (args) => { }); }); +backupCli.subcommand("recoverySave", "save-recovery").action(async (args) => { + await withWallet(args, async (wallet) => { + const recoveryJson = await wallet.getBackupRecovery(); + console.log(JSON.stringify(recoveryJson, undefined, 2)); + }); +}); + +backupCli.subcommand("run", "run").action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.runBackupCycle(); + }); +}); + +backupCli + .subcommand("recoveryLoad", "load-recovery") + .action(async (args) => {}); + +backupCli.subcommand("status", "status").action(async (args) => {}); + +backupCli + .subcommand("addProvider", "add-provider") + .requiredArgument("url", clk.STRING) + .action(async (args) => { + await withWallet(args, async (wallet) => { + wallet.addBackupProvider({ + backupProviderBaseUrl: args.addProvider.url, + }); + }); + }); + const advancedCli = walletCli.subcommand("advancedArgs", "advanced", { help: "Subcommands for advanced operations (only use if you know what you're doing!).", diff --git a/packages/taler-wallet-core/src/crypto/primitives/nacl-fast.ts b/packages/taler-wallet-core/src/crypto/primitives/nacl-fast.ts index ceb601468..acaebf546 100644 --- a/packages/taler-wallet-core/src/crypto/primitives/nacl-fast.ts +++ b/packages/taler-wallet-core/src/crypto/primitives/nacl-fast.ts @@ -2990,7 +2990,11 @@ export function sign_ed25519_pk_to_curve25519( return x25519_pk; } -export function secretbox(msg: Uint8Array, nonce: Uint8Array, key: Uint8Array) { +export function secretbox( + msg: Uint8Array, + nonce: Uint8Array, + key: Uint8Array, +): Uint8Array { checkArrayTypes(msg, nonce, key); checkLengths(key, nonce); var m = new Uint8Array(crypto_secretbox_ZEROBYTES + msg.length); @@ -3005,15 +3009,15 @@ export function secretbox_open( box: Uint8Array, nonce: Uint8Array, key: Uint8Array, -) { +): Uint8Array | undefined { checkArrayTypes(box, nonce, key); checkLengths(key, nonce); var c = new Uint8Array(crypto_secretbox_BOXZEROBYTES + box.length); var m = new Uint8Array(c.length); for (var i = 0; i < box.length; i++) c[i + crypto_secretbox_BOXZEROBYTES] = box[i]; - if (c.length < 32) return null; - if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return null; + if (c.length < 32) return undefined; + if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return undefined; return m.subarray(crypto_secretbox_ZEROBYTES); } diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts index 4f736c3df..5108dccfd 100644 --- a/packages/taler-wallet-core/src/operations/backup.ts +++ b/packages/taler-wallet-core/src/operations/backup.ts @@ -74,6 +74,7 @@ import { import { checkDbInvariant, checkLogicInvariant } from "../util/invariants"; import { AmountJson, Amounts, codecForAmountString } from "../util/amounts"; import { + bytesToString, decodeCrock, eddsaGetPublic, EddsaKeyPair, @@ -102,11 +103,13 @@ import { readSuccessResponseJsonOrThrow, } from "../util/http"; import { Logger } from "../util/logging"; -import { gzipSync } from "fflate"; +import { gunzipSync, gzipSync } from "fflate"; import { kdf } from "../crypto/primitives/kdf"; import { initRetryInfo } from "../util/retries"; import { RefreshReason } from "../types/walletTypes"; import { CryptoApi } from "../crypto/workers/cryptoApi"; +import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast"; +import { str } from "../i18n"; interface WalletBackupConfState { deviceId: string; @@ -588,10 +591,54 @@ export async function exportBackup( ); } +function concatArrays(xs: Uint8Array[]): Uint8Array { + let len = 0; + for (const x of xs) { + len += x.byteLength; + } + const out = new Uint8Array(len); + let offset = 0; + for (const x of xs) { + out.set(x, offset); + offset += x.length; + } + return out; +} + +const magic = "TLRWBK01"; + +/** + * Encrypt the backup. + * + * Blob format: + * Magic "TLRWBK01" (8 bytes) + * Nonce (24 bytes) + * Compressed JSON blob (rest) + */ export async function encryptBackup( config: WalletBackupConfState, blob: WalletBackupContentV1, ): Promise<Uint8Array> { + const chunks: Uint8Array[] = []; + chunks.push(stringToBytes(magic)); + const nonceStr = config.lastBackupNonce; + checkLogicInvariant(!!nonceStr); + const nonce = decodeCrock(nonceStr).slice(0, 24); + chunks.push(nonce); + const backupJsonContent = canonicalJson(blob); + logger.trace("backup JSON size", backupJsonContent.length); + const compressedContent = gzipSync(stringToBytes(backupJsonContent)); + const secret = deriveBlobSecret(config); + const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret); + chunks.push(encrypted); + logger.trace(`enc: ${encodeCrock(encrypted)}`); + return concatArrays(chunks); +} + +export async function decryptBackup( + config: WalletBackupConfState, + box: Uint8Array, +): Promise<WalletBackupContentV1> { throw Error("not implemented"); } @@ -778,7 +825,10 @@ async function getDenomSelStateFromBackup( exchangeBaseUrl: string, sel: BackupDenomSel, ): Promise<DenomSelectionState> { - const d0 = await tx.get(Stores.denominations, [exchangeBaseUrl, sel[0].denom_pub_hash]); + const d0 = await tx.get(Stores.denominations, [ + exchangeBaseUrl, + sel[0].denom_pub_hash, + ]); checkBackupInvariant(!!d0); const selectedDenoms: { denomPubHash: string; @@ -787,16 +837,20 @@ async function getDenomSelStateFromBackup( let totalCoinValue = Amounts.getZero(d0.value.currency); let totalWithdrawCost = Amounts.getZero(d0.value.currency); for (const s of sel) { - const d = await tx.get(Stores.denominations, [exchangeBaseUrl, s.denom_pub_hash]); + const d = await tx.get(Stores.denominations, [ + exchangeBaseUrl, + s.denom_pub_hash, + ]); checkBackupInvariant(!!d); totalCoinValue = Amounts.add(totalCoinValue, d.value).amount; - totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw).amount; + totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw) + .amount; } return { selectedDenoms, totalCoinValue, totalWithdrawCost, - } + }; } export async function importBackup( @@ -1407,6 +1461,15 @@ function deriveAccountKeyPair( }; } +function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array { + return kdf( + 32, + decodeCrock(bc.walletRootPriv), + stringToBytes("taler-sync-blob-secret-salt"), + stringToBytes("taler-sync-blob-secret-info"), + ); +} + /** * Do one backup cycle that consists of: * 1. Exporting a backup and try to upload it. @@ -1566,6 +1629,71 @@ export async function importBackupPlain( /** * Get information about the current state of wallet backups. */ -export function getBackupInfo(ws: InternalWalletState): Promise<BackupInfo> { +export async function getBackupInfo( + ws: InternalWalletState, +): Promise<BackupInfo> { throw Error("not implemented"); } + +export interface BackupRecovery { + walletRootPriv: string; + providers: { + url: string; + }[]; +} + +/** + * Get information about the current state of wallet backups. + */ +export async function getBackupRecovery( + ws: InternalWalletState, +): Promise<BackupRecovery> { + const bs = await provideBackupState(ws); + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + return { + providers: providers + .filter((x) => x.active) + .map((x) => { + return { + url: x.baseUrl, + }; + }), + walletRootPriv: bs.walletRootPriv, + }; +} + +export async function exportBackupEncrypted( + ws: InternalWalletState, +): Promise<Uint8Array> { + await provideBackupState(ws); + const blob = await exportBackup(ws); + const bs = await ws.db.runWithWriteTransaction( + [Stores.config], + async (tx) => { + return await getWalletBackupState(ws, tx); + }, + ); + return encryptBackup(bs, blob); +} + +export async function importBackupEncrypted( + ws: InternalWalletState, + data: Uint8Array, +): Promise<void> { + const backupConfig = await provideBackupState(ws); + const rMagic = bytesToString(data.slice(0, 8)); + if (rMagic != magic) { + throw Error("invalid backup file (magic tag mismatch)"); + } + + const nonce = data.slice(8, 8 + 24); + const box = data.slice(8 + 24); + const secret = deriveBlobSecret(backupConfig); + const dataCompressed = secretbox_open(box, nonce, secret); + if (!dataCompressed) { + throw Error("decryption failed"); + } + const blob = JSON.parse(bytesToString(gunzipSync(dataCompressed))); + const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); + await importBackup(ws, blob, cryptoData); +} diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts index f7bd8784d..32ff8c52d 100644 --- a/packages/taler-wallet-core/src/types/backupTypes.ts +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -40,6 +40,7 @@ * payment cost. * 11. Failed refunds do not have any information about why they failed. * => This should go into the general "error reports" + * 12. Tombstones for removed backup providers * * Questions: * 1. What happens when two backups are merged that have diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index b917246fc..0b2b4d639 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -162,6 +162,11 @@ import { runBackupCycle, exportBackup, importBackupPlain, + exportBackupEncrypted, + importBackupEncrypted, + BackupRecovery, + getBackupRecovery, + AddBackupProviderRequest, } from "./operations/backup"; const builtinCurrencies: CurrencyRecord[] = [ @@ -942,6 +947,26 @@ export class Wallet { return importBackupPlain(this.ws, backup); } + async exportBackupEncrypted() { + return exportBackupEncrypted(this.ws); + } + + async importBackupEncrypted(backup: Uint8Array) { + return importBackupEncrypted(this.ws, backup); + } + + async getBackupRecovery(): Promise<BackupRecovery> { + return getBackupRecovery(this.ws); + } + + async addBackupProvider(req: AddBackupProviderRequest): Promise<void> { + return addBackupProvider(this.ws, req); + } + + async runBackupCycle(): Promise<void> { + return runBackupCycle(this.ws); + } + /** * Implementation of the "wallet-core" API. */ |