diff options
author | Florian Dold <florian@dold.me> | 2021-03-10 12:00:30 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2021-03-10 12:00:30 +0100 |
commit | ac89c3d277134e49e44d8b0afd4930fd4df934aa (patch) | |
tree | 2d2682630e108067d4f5f00946da681e978aa41c | |
parent | 49b5d006db6639082eea10158e2da7cc13473c21 (diff) |
restructure sync, store errors
9 files changed, 2070 insertions, 1927 deletions
diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts b/packages/taler-wallet-cli/src/integrationtests/harness.ts index 2b26ef7fc..835eb7a08 100644 --- a/packages/taler-wallet-cli/src/integrationtests/harness.ts +++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts @@ -99,7 +99,10 @@ import { import { ApplyRefundResponse } from "@gnu-taler/taler-wallet-core"; import { PendingOperationsResponse } from "@gnu-taler/taler-wallet-core"; import { CoinConfig } from "./denomStructures"; -import { AddBackupProviderRequest, BackupInfo } from "@gnu-taler/taler-wallet-core/src/operations/backup"; +import { + AddBackupProviderRequest, + BackupInfo, +} from "@gnu-taler/taler-wallet-core/src/operations/backup"; const exec = util.promisify(require("child_process").exec); @@ -1474,7 +1477,9 @@ export class MerchantService implements MerchantServiceInterface { config.write(this.configFilename); } - async addInstance(instanceConfig: PartialMerchantInstanceConfig): Promise<void> { + async addInstance( + instanceConfig: PartialMerchantInstanceConfig, + ): Promise<void> { if (!this.proc) { throw Error("merchant must be running to add instance"); } @@ -1881,4 +1886,12 @@ export class WalletCli { } throw new OperationFailedError(resp.error); } + + async runBackupCycle(): Promise<void> { + const resp = await this.apiRequest("runBackupCycle", {}); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } } diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts index 9201c558c..9804f7ab2 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts @@ -56,11 +56,18 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) { await wallet.addBackupProvider({ backupProviderBaseUrl: sync.baseUrl, - activate: false, + activate: true, }); { const bi = await wallet.getBackupInfo(); t.assertDeepEqual(bi.providers[0].active, true); } + + await wallet.runBackupCycle(); + + { + const bi = await wallet.getBackupInfo(); + console.log(bi); + } } diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts deleted file mode 100644 index 77f1581ae..000000000 --- a/packages/taler-wallet-core/src/operations/backup.ts +++ /dev/null @@ -1,1907 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2020 Taler Systems SA - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Implementation of wallet backups (export/import/upload) and sync - * server management. - * - * @author Florian Dold <dold@taler.net> - */ - -/** - * Imports. - */ -import { InternalWalletState } from "./state"; -import { - BackupBackupProvider, - BackupBackupProviderTerms, - BackupCoin, - BackupCoinSource, - BackupCoinSourceType, - BackupDenomination, - BackupDenomSel, - BackupExchange, - BackupExchangeWireFee, - BackupProposal, - BackupProposalStatus, - BackupPurchase, - BackupRecoupGroup, - BackupRefreshGroup, - BackupRefreshOldCoin, - BackupRefreshReason, - BackupRefreshSession, - BackupRefundItem, - BackupRefundState, - BackupReserve, - BackupTip, - BackupWithdrawalGroup, - WalletBackupContentV1, -} from "../types/backupTypes"; -import { TransactionHandle } from "../util/query"; -import { - AbortStatus, - BackupProviderStatus, - CoinSource, - CoinSourceType, - CoinStatus, - ConfigRecord, - DenominationStatus, - DenomSelectionState, - ExchangeUpdateStatus, - ExchangeWireInfo, - PayCoinSelection, - ProposalDownload, - ProposalStatus, - RefreshSessionRecord, - RefundState, - ReserveBankInfo, - ReserveRecordStatus, - Stores, - WalletContractData, - WalletRefundItem, -} from "../types/dbTypes"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants"; -import { AmountJson, Amounts, codecForAmountString } from "../util/amounts"; -import { - bytesToString, - decodeCrock, - eddsaGetPublic, - EddsaKeyPair, - encodeCrock, - getRandomBytes, - hash, - rsaBlind, - stringToBytes, -} from "../crypto/talerCrypto"; -import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers"; -import { getTimestampNow, Timestamp } from "../util/time"; -import { URL } from "../util/url"; -import { - AmountString, - codecForContractTerms, - ContractTerms, -} from "../types/talerTypes"; -import { - buildCodecForObject, - Codec, - codecForNumber, - codecForString, -} from "../util/codec"; -import { - HttpResponseStatus, - readSuccessResponseJsonOrThrow, - throwUnexpectedRequestError, -} from "../util/http"; -import { Logger } from "../util/logging"; -import { gunzipSync, gzipSync } from "fflate"; -import { kdf } from "../crypto/primitives/kdf"; -import { initRetryInfo } from "../util/retries"; -import { - ConfirmPayResultType, - PreparePayResultType, - RecoveryLoadRequest, - RecoveryMergeStrategy, - RefreshReason, -} from "../types/walletTypes"; -import { CryptoApi } from "../crypto/workers/cryptoApi"; -import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast"; -import { str } from "../i18n"; -import { confirmPay, preparePayForUri } from "./pay"; - -interface WalletBackupConfState { - deviceId: string; - walletRootPub: string; - walletRootPriv: string; - clocks: { [device_id: string]: number }; - - /** - * Last hash of the canonicalized plain-text backup. - * - * Used to determine whether the wallet's content changed - * and we need to bump the clock. - */ - lastBackupPlainHash?: string; - - /** - * Timestamp stored in the last backup. - */ - lastBackupTimestamp?: Timestamp; - - /** - * Last time we tried to do a backup. - */ - lastBackupCheckTimestamp?: Timestamp; - lastBackupNonce?: string; -} - -const WALLET_BACKUP_STATE_KEY = "walletBackupState"; - -const logger = new Logger("operations/backup.ts"); - -async function provideBackupState( - ws: InternalWalletState, -): Promise<WalletBackupConfState> { - const bs: ConfigRecord<WalletBackupConfState> | undefined = await ws.db.get( - Stores.config, - WALLET_BACKUP_STATE_KEY, - ); - if (bs) { - return bs.value; - } - // We need to generate the key outside of the transaction - // due to how IndexedDB works. - const k = await ws.cryptoApi.createEddsaKeypair(); - const d = getRandomBytes(5); - // FIXME: device ID should be configured when wallet is initialized - // and be based on hostname - const deviceId = `wallet-core-${encodeCrock(d)}`; - return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => { - let backupStateEntry: - | ConfigRecord<WalletBackupConfState> - | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); - if (!backupStateEntry) { - backupStateEntry = { - key: WALLET_BACKUP_STATE_KEY, - value: { - deviceId, - clocks: { [deviceId]: 1 }, - walletRootPub: k.pub, - walletRootPriv: k.priv, - lastBackupPlainHash: undefined, - }, - }; - await tx.put(Stores.config, backupStateEntry); - } - return backupStateEntry.value; - }); -} - -async function getWalletBackupState( - ws: InternalWalletState, - tx: TransactionHandle<typeof Stores.config>, -): Promise<WalletBackupConfState> { - let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); - checkDbInvariant(!!bs, "wallet backup state should be in DB"); - return bs.value; -} - -export async function exportBackup( - ws: InternalWalletState, -): Promise<WalletBackupContentV1> { - await provideBackupState(ws); - return ws.db.runWithWriteTransaction( - [ - Stores.config, - Stores.exchanges, - Stores.coins, - Stores.denominations, - Stores.purchases, - Stores.proposals, - Stores.refreshGroups, - Stores.backupProviders, - Stores.tips, - Stores.recoupGroups, - Stores.reserves, - Stores.withdrawalGroups, - ], - async (tx) => { - const bs = await getWalletBackupState(ws, tx); - - const backupExchanges: BackupExchange[] = []; - const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {}; - const backupDenominationsByExchange: { - [url: string]: BackupDenomination[]; - } = {}; - const backupReservesByExchange: { [url: string]: BackupReserve[] } = {}; - const backupPurchases: BackupPurchase[] = []; - const backupProposals: BackupProposal[] = []; - const backupRefreshGroups: BackupRefreshGroup[] = []; - const backupBackupProviders: BackupBackupProvider[] = []; - const backupTips: BackupTip[] = []; - const backupRecoupGroups: BackupRecoupGroup[] = []; - const withdrawalGroupsByReserve: { - [reservePub: string]: BackupWithdrawalGroup[]; - } = {}; - - await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wg) => { - const withdrawalGroups = (withdrawalGroupsByReserve[ - wg.reservePub - ] ??= []); - withdrawalGroups.push({ - raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount), - selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - })), - timestamp_created: wg.timestampStart, - timestamp_finish: wg.timestampFinish, - withdrawal_group_id: wg.withdrawalGroupId, - secret_seed: wg.secretSeed, - }); - }); - - await tx.iter(Stores.reserves).forEach((reserve) => { - const backupReserve: BackupReserve = { - initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map( - (x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - }), - ), - initial_withdrawal_group_id: reserve.initialWithdrawalGroupId, - instructed_amount: Amounts.stringify(reserve.instructedAmount), - reserve_priv: reserve.reservePriv, - timestamp_created: reserve.timestampCreated, - withdrawal_groups: - withdrawalGroupsByReserve[reserve.reservePub] ?? [], - // FIXME! - timestamp_last_activity: reserve.timestampCreated, - }; - const backupReserves = (backupReservesByExchange[ - reserve.exchangeBaseUrl - ] ??= []); - backupReserves.push(backupReserve); - }); - - await tx.iter(Stores.tips).forEach((tip) => { - backupTips.push({ - exchange_base_url: tip.exchangeBaseUrl, - merchant_base_url: tip.merchantBaseUrl, - merchant_tip_id: tip.merchantTipId, - wallet_tip_id: tip.walletTipId, - secret_seed: tip.secretSeed, - selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - })), - timestamp_finished: tip.pickedUpTimestamp, - timestamp_accepted: tip.acceptedTimestamp, - timestamp_created: tip.createdTimestamp, - timestamp_expiration: tip.tipExpiration, - tip_amount_raw: Amounts.stringify(tip.tipAmountRaw), - }); - }); - - await tx.iter(Stores.recoupGroups).forEach((recoupGroup) => { - backupRecoupGroups.push({ - recoup_group_id: recoupGroup.recoupGroupId, - timestamp_created: recoupGroup.timestampStarted, - timestamp_finish: recoupGroup.timestampFinished, - coins: recoupGroup.coinPubs.map((x, i) => ({ - coin_pub: x, - recoup_finished: recoupGroup.recoupFinishedPerCoin[i], - old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]), - })), - }); - }); - - await tx.iter(Stores.backupProviders).forEach((bp) => { - let terms: BackupBackupProviderTerms | undefined; - if (bp.terms) { - terms = { - annual_fee: Amounts.stringify(bp.terms.annualFee), - storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes, - supported_protocol_version: bp.terms.supportedProtocolVersion, - }; - } - backupBackupProviders.push({ - terms, - base_url: canonicalizeBaseUrl(bp.baseUrl), - pay_proposal_ids: bp.paymentProposalIds, - }); - }); - - await tx.iter(Stores.coins).forEach((coin) => { - let bcs: BackupCoinSource; - switch (coin.coinSource.type) { - case CoinSourceType.Refresh: - bcs = { - type: BackupCoinSourceType.Refresh, - old_coin_pub: coin.coinSource.oldCoinPub, - }; - break; - case CoinSourceType.Tip: - bcs = { - type: BackupCoinSourceType.Tip, - coin_index: coin.coinSource.coinIndex, - wallet_tip_id: coin.coinSource.walletTipId, - }; - break; - case CoinSourceType.Withdraw: - bcs = { - type: BackupCoinSourceType.Withdraw, - coin_index: coin.coinSource.coinIndex, - reserve_pub: coin.coinSource.reservePub, - withdrawal_group_id: coin.coinSource.withdrawalGroupId, - }; - break; - } - - const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []); - coins.push({ - blinding_key: coin.blindingKey, - coin_priv: coin.coinPriv, - coin_source: bcs, - current_amount: Amounts.stringify(coin.currentAmount), - fresh: coin.status === CoinStatus.Fresh, - denom_sig: coin.denomSig, - }); - }); - - await tx.iter(Stores.denominations).forEach((denom) => { - const backupDenoms = (backupDenominationsByExchange[ - denom.exchangeBaseUrl - ] ??= []); - backupDenoms.push({ - coins: backupCoinsByDenom[denom.denomPubHash] ?? [], - denom_pub: denom.denomPub, - fee_deposit: Amounts.stringify(denom.feeDeposit), - fee_refresh: Amounts.stringify(denom.feeRefresh), - fee_refund: Amounts.stringify(denom.feeRefund), - fee_withdraw: Amounts.stringify(denom.feeWithdraw), - is_offered: denom.isOffered, - is_revoked: denom.isRevoked, - master_sig: denom.masterSig, - stamp_expire_deposit: denom.stampExpireDeposit, - stamp_expire_legal: denom.stampExpireLegal, - stamp_expire_withdraw: denom.stampExpireWithdraw, - stamp_start: denom.stampStart, - value: Amounts.stringify(denom.value), - }); - }); - - await tx.iter(Stores.exchanges).forEach((ex) => { - // Only back up permanently added exchanges. - - if (!ex.details) { - return; - } - if (!ex.wireInfo) { - return; - } - if (!ex.addComplete) { - return; - } - if (!ex.permanent) { - return; - } - const wi = ex.wireInfo; - const wireFees: BackupExchangeWireFee[] = []; - - Object.keys(wi.feesForType).forEach((x) => { - for (const f of wi.feesForType[x]) { - wireFees.push({ - wire_type: x, - closing_fee: Amounts.stringify(f.closingFee), - end_stamp: f.endStamp, - sig: f.sig, - start_stamp: f.startStamp, - wire_fee: Amounts.stringify(f.wireFee), - }); - } - }); - - backupExchanges.push({ - base_url: ex.baseUrl, - reserve_closing_delay: ex.details.reserveClosingDelay, - accounts: ex.wireInfo.accounts.map((x) => ({ - payto_uri: x.payto_uri, - master_sig: x.master_sig, - })), - auditors: ex.details.auditors.map((x) => ({ - auditor_pub: x.auditor_pub, - auditor_url: x.auditor_url, - denomination_keys: x.denomination_keys, - })), - master_public_key: ex.details.masterPublicKey, - currency: ex.details.currency, - protocol_version: ex.details.protocolVersion, - wire_fees: wireFees, - signing_keys: ex.details.signingKeys.map((x) => ({ - key: x.key, - master_sig: x.master_sig, - stamp_end: x.stamp_end, - stamp_expire: x.stamp_expire, - stamp_start: x.stamp_start, - })), - tos_etag_accepted: ex.termsOfServiceAcceptedEtag, - tos_etag_last: ex.termsOfServiceLastEtag, - denominations: backupDenominationsByExchange[ex.baseUrl] ?? [], - reserves: backupReservesByExchange[ex.baseUrl] ?? [], - }); - }); - - const purchaseProposalIdSet = new Set<string>(); - - await tx.iter(Stores.purchases).forEach((purch) => { - const refunds: BackupRefundItem[] = []; - purchaseProposalIdSet.add(purch.proposalId); - for (const refundKey of Object.keys(purch.refunds)) { - const ri = purch.refunds[refundKey]; - const common = { - coin_pub: ri.coinPub, - execution_time: ri.executionTime, - obtained_time: ri.obtainedTime, - refund_amount: Amounts.stringify(ri.refundAmount), - rtransaction_id: ri.rtransactionId, - total_refresh_cost_bound: Amounts.stringify( - ri.totalRefreshCostBound, - ), - }; - switch (ri.type) { - case RefundState.Applied: - refunds.push({ type: BackupRefundState.Applied, ...common }); - break; - case RefundState.Failed: - refunds.push({ type: BackupRefundState.Failed, ...common }); - break; - case RefundState.Pending: - refunds.push({ type: BackupRefundState.Pending, ...common }); - break; - } - } - - backupPurchases.push({ - contract_terms_raw: purch.download.contractTermsRaw, - auto_refund_deadline: purch.autoRefundDeadline, - merchant_pay_sig: purch.merchantPaySig, - pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({ - coin_pub: x, - contribution: Amounts.stringify( - purch.payCoinSelection.coinContributions[i], - ), - })), - proposal_id: purch.proposalId, - refunds, - timestamp_accept: purch.timestampAccept, - timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, - abort_status: - purch.abortStatus === AbortStatus.None - ? undefined - : purch.abortStatus, - nonce_priv: purch.noncePriv, - merchant_sig: purch.download.contractData.merchantSig, - total_pay_cost: Amounts.stringify(purch.totalPayCost), - }); - }); - - await tx.iter(Stores.proposals).forEach((prop) => { - if (purchaseProposalIdSet.has(prop.proposalId)) { - return; - } - let propStatus: BackupProposalStatus; - switch (prop.proposalStatus) { - case ProposalStatus.ACCEPTED: - return; - case ProposalStatus.DOWNLOADING: - case ProposalStatus.PROPOSED: - propStatus = BackupProposalStatus.Proposed; - break; - case ProposalStatus.PERMANENTLY_FAILED: - propStatus = BackupProposalStatus.PermanentlyFailed; - break; - case ProposalStatus.REFUSED: - propStatus = BackupProposalStatus.Refused; - break; - case ProposalStatus.REPURCHASE: - propStatus = BackupProposalStatus.Repurchase; - break; - } - backupProposals.push({ - claim_token: prop.claimToken, - nonce_priv: prop.noncePriv, - proposal_id: prop.noncePriv, - proposal_status: propStatus, - repurchase_proposal_id: prop.repurchaseProposalId, - timestamp: prop.timestamp, - contract_terms_raw: prop.download?.contractTermsRaw, - download_session_id: prop.downloadSessionId, - merchant_base_url: prop.merchantBaseUrl, - order_id: prop.orderId, - merchant_sig: prop.download?.contractData.merchantSig, - }); - }); - - await tx.iter(Stores.refreshGroups).forEach((rg) => { - const oldCoins: BackupRefreshOldCoin[] = []; - - for (let i = 0; i < rg.oldCoinPubs.length; i++) { - let refreshSession: BackupRefreshSession | undefined; - const s = rg.refreshSessionPerCoin[i]; - if (s) { - refreshSession = { - new_denoms: s.newDenoms.map((x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - })), - session_secret_seed: s.sessionSecretSeed, - noreveal_index: s.norevealIndex, - }; - } - oldCoins.push({ - coin_pub: rg.oldCoinPubs[i], - estimated_output_amount: Amounts.stringify( - rg.estimatedOutputPerCoin[i], - ), - finished: rg.finishedPerCoin[i], - input_amount: Amounts.stringify(rg.inputPerCoin[i]), - refresh_session: refreshSession, - }); - } - - backupRefreshGroups.push({ - reason: rg.reason as any, - refresh_group_id: rg.refreshGroupId, - timestamp_created: rg.timestampCreated, - timestamp_finish: rg.timestampFinished, - old_coins: oldCoins, - }); - }); - - if (!bs.lastBackupTimestamp) { - bs.lastBackupTimestamp = getTimestampNow(); - } - - const backupBlob: WalletBackupContentV1 = { - schema_id: "gnu-taler-wallet-backup-content", - schema_version: 1, - clocks: bs.clocks, - exchanges: backupExchanges, - wallet_root_pub: bs.walletRootPub, - backup_providers: backupBackupProviders, - current_device_id: bs.deviceId, - proposals: backupProposals, - purchase_tombstones: [], - purchases: backupPurchases, - recoup_groups: backupRecoupGroups, - refresh_groups: backupRefreshGroups, - tips: backupTips, - timestamp: bs.lastBackupTimestamp, - trusted_auditors: {}, - trusted_exchanges: {}, - intern_table: {}, - error_reports: [], - }; - - // If the backup changed, we increment our clock. - - let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob)))); - if (h != bs.lastBackupPlainHash) { - backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId]; - bs.lastBackupPlainHash = encodeCrock( - hash(stringToBytes(canonicalJson(backupBlob))), - ); - bs.lastBackupNonce = encodeCrock(getRandomBytes(32)); - await tx.put(Stores.config, { - key: WALLET_BACKUP_STATE_KEY, - value: bs, - }); - } - - return backupBlob; - }, - ); -} - -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); - return concatArrays(chunks); -} - -interface CompletedCoin { - coinPub: string; - coinEvHash: string; -} - -/** - * Precomputed cryptographic material for a backup import. - * - * We separate this data from the backup blob as we want the backup - * blob to be small, and we can't compute it during the database transaction, - * as the async crypto worker communication would auto-close the database transaction. - */ -interface BackupCryptoPrecomputedData { - denomPubToHash: Record<string, string>; - coinPrivToCompletedCoin: Record<string, CompletedCoin>; - proposalNoncePrivToPub: { [priv: string]: string }; - proposalIdToContractTermsHash: { [proposalId: string]: string }; - reservePrivToPub: Record<string, string>; -} - -/** - * Compute cryptographic values for a backup blob. - * - * FIXME: Take data that we already know from the DB. - * FIXME: Move computations into crypto worker. - */ -async function computeBackupCryptoData( - cryptoApi: CryptoApi, - backupContent: WalletBackupContentV1, -): Promise<BackupCryptoPrecomputedData> { - const cryptoData: BackupCryptoPrecomputedData = { - coinPrivToCompletedCoin: {}, - denomPubToHash: {}, - proposalIdToContractTermsHash: {}, - proposalNoncePrivToPub: {}, - reservePrivToPub: {}, - }; - for (const backupExchange of backupContent.exchanges) { - for (const backupDenom of backupExchange.denominations) { - for (const backupCoin of backupDenom.coins) { - const coinPub = encodeCrock( - eddsaGetPublic(decodeCrock(backupCoin.coin_priv)), - ); - const blindedCoin = rsaBlind( - hash(decodeCrock(backupCoin.coin_priv)), - decodeCrock(backupCoin.blinding_key), - decodeCrock(backupDenom.denom_pub), - ); - cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = { - coinEvHash: encodeCrock(hash(blindedCoin)), - coinPub, - }; - } - cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock( - hash(decodeCrock(backupDenom.denom_pub)), - ); - } - for (const backupReserve of backupExchange.reserves) { - cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( - eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), - ); - } - } - for (const prop of backupContent.proposals) { - const contractTermsHash = await cryptoApi.hashString( - canonicalJson(prop.contract_terms_raw), - ); - const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv))); - cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub; - cryptoData.proposalIdToContractTermsHash[ - prop.proposal_id - ] = contractTermsHash; - } - for (const purch of backupContent.purchases) { - const contractTermsHash = await cryptoApi.hashString( - canonicalJson(purch.contract_terms_raw), - ); - const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv))); - cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub; - cryptoData.proposalIdToContractTermsHash[ - purch.proposal_id - ] = contractTermsHash; - } - return cryptoData; -} - -function checkBackupInvariant(b: boolean, m?: string): asserts b { - if (!b) { - if (m) { - throw Error(`BUG: backup invariant failed (${m})`); - } else { - throw Error("BUG: backup invariant failed"); - } - } -} - -/** - * Re-compute information about the coin selection for a payment. - */ -async function recoverPayCoinSelection( - tx: TransactionHandle< - typeof Stores.exchanges | typeof Stores.coins | typeof Stores.denominations - >, - contractData: WalletContractData, - backupPurchase: BackupPurchase, -): Promise<PayCoinSelection> { - const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub); - const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ); - - const coveredExchanges: Set<string> = new Set(); - - let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency); - let totalDepositFees: AmountJson = Amounts.getZero( - contractData.amount.currency, - ); - - for (const coinPub of coinPubs) { - const coinRecord = await tx.get(Stores.coins, coinPub); - checkBackupInvariant(!!coinRecord); - const denom = await tx.get(Stores.denominations, [ - coinRecord.exchangeBaseUrl, - coinRecord.denomPubHash, - ]); - checkBackupInvariant(!!denom); - totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount; - - if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) { - const exchange = await tx.get( - Stores.exchanges, - coinRecord.exchangeBaseUrl, - ); - checkBackupInvariant(!!exchange); - let wireFee: AmountJson | undefined; - const feesForType = exchange.wireInfo?.feesForType; - checkBackupInvariant(!!feesForType); - for (const fee of feesForType[contractData.wireMethod] || []) { - if ( - fee.startStamp <= contractData.timestamp && - fee.endStamp >= contractData.timestamp - ) { - wireFee = fee.wireFee; - break; - } - } - if (wireFee) { - totalWireFee = Amounts.add(totalWireFee, wireFee).amount; - } - } - } - - let customerWireFee: AmountJson; - - const amortizedWireFee = Amounts.divide( - totalWireFee, - contractData.wireFeeAmortization, - ); - if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) { - customerWireFee = amortizedWireFee; - } else { - customerWireFee = Amounts.getZero(contractData.amount.currency); - } - - const customerDepositFees = Amounts.sub( - totalDepositFees, - contractData.maxDepositFee, - ).amount; - - return { - coinPubs, - coinContributions, - paymentAmount: contractData.amount, - customerWireFees: customerWireFee, - customerDepositFees, - }; -} - -async function getDenomSelStateFromBackup( - tx: TransactionHandle<typeof Stores.denominations>, - exchangeBaseUrl: string, - sel: BackupDenomSel, -): Promise<DenomSelectionState> { - const d0 = await tx.get(Stores.denominations, [ - exchangeBaseUrl, - sel[0].denom_pub_hash, - ]); - checkBackupInvariant(!!d0); - const selectedDenoms: { - denomPubHash: string; - count: number; - }[] = []; - 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, - ]); - checkBackupInvariant(!!d); - totalCoinValue = Amounts.add(totalCoinValue, d.value).amount; - totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw) - .amount; - } - return { - selectedDenoms, - totalCoinValue, - totalWithdrawCost, - }; -} - -export async function importBackup( - ws: InternalWalletState, - backupBlobArg: any, - cryptoComp: BackupCryptoPrecomputedData, -): Promise<void> { - await provideBackupState(ws); - return ws.db.runWithWriteTransaction( - [ - Stores.config, - Stores.exchanges, - Stores.coins, - Stores.denominations, - Stores.purchases, - Stores.proposals, - Stores.refreshGroups, - Stores.backupProviders, - Stores.tips, - Stores.recoupGroups, - Stores.reserves, - Stores.withdrawalGroups, - ], - async (tx) => { - // FIXME: validate schema! - const backupBlob = backupBlobArg as WalletBackupContentV1; - - // FIXME: validate version - - for (const backupExchange of backupBlob.exchanges) { - const existingExchange = await tx.get( - Stores.exchanges, - backupExchange.base_url, - ); - - if (!existingExchange) { - const wireInfo: ExchangeWireInfo = { - accounts: backupExchange.accounts.map((x) => ({ - master_sig: x.master_sig, - payto_uri: x.payto_uri, - })), - feesForType: {}, - }; - for (const fee of backupExchange.wire_fees) { - const w = (wireInfo.feesForType[fee.wire_type] ??= []); - w.push({ - closingFee: Amounts.parseOrThrow(fee.closing_fee), - endStamp: fee.end_stamp, - sig: fee.sig, - startStamp: fee.start_stamp, - wireFee: Amounts.parseOrThrow(fee.wire_fee), - }); - } - await tx.put(Stores.exchanges, { - addComplete: true, - baseUrl: backupExchange.base_url, - builtIn: false, - updateReason: undefined, - permanent: true, - retryInfo: initRetryInfo(), - termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted, - termsOfServiceText: undefined, - termsOfServiceLastEtag: backupExchange.tos_etag_last, - updateStarted: getTimestampNow(), - updateStatus: ExchangeUpdateStatus.FetchKeys, - wireInfo, - details: { - currency: backupExchange.currency, - reserveClosingDelay: backupExchange.reserve_closing_delay, - auditors: backupExchange.auditors.map((x) => ({ - auditor_pub: x.auditor_pub, - auditor_url: x.auditor_url, - denomination_keys: x.denomination_keys, - })), - lastUpdateTime: { t_ms: "never" }, - masterPublicKey: backupExchange.master_public_key, - nextUpdateTime: { t_ms: "never" }, - protocolVersion: backupExchange.protocol_version, - signingKeys: backupExchange.signing_keys.map((x) => ({ - key: x.key, - master_sig: x.master_sig, - stamp_end: x.stamp_end, - stamp_expire: x.stamp_expire, - stamp_start: x.stamp_start, - })), - }, - }); - } - - for (const backupDenomination of backupExchange.denominations) { - const denomPubHash = - cryptoComp.denomPubToHash[backupDenomination.denom_pub]; - checkLogicInvariant(!!denomPubHash); - const existingDenom = await tx.get(Stores.denominations, [ - backupExchange.base_url, - denomPubHash, - ]); - if (!existingDenom) { - await tx.put(Stores.denominations, { - denomPub: backupDenomination.denom_pub, - denomPubHash: denomPubHash, - exchangeBaseUrl: backupExchange.base_url, - feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit), - feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh), - feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund), - feeWithdraw: Amounts.parseOrThrow( - backupDenomination.fee_withdraw, - ), - isOffered: backupDenomination.is_offered, - isRevoked: backupDenomination.is_revoked, - masterSig: backupDenomination.master_sig, - stampExpireDeposit: backupDenomination.stamp_expire_deposit, - stampExpireLegal: backupDenomination.stamp_expire_legal, - stampExpireWithdraw: backupDenomination.stamp_expire_withdraw, - stampStart: backupDenomination.stamp_start, - status: DenominationStatus.VerifiedGood, - value: Amounts.parseOrThrow(backupDenomination.value), - }); - } - for (const backupCoin of backupDenomination.coins) { - const compCoin = - cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv]; - checkLogicInvariant(!!compCoin); - const existingCoin = await tx.get(Stores.coins, compCoin.coinPub); - if (!existingCoin) { - let coinSource: CoinSource; - switch (backupCoin.coin_source.type) { - case BackupCoinSourceType.Refresh: - coinSource = { - type: CoinSourceType.Refresh, - oldCoinPub: backupCoin.coin_source.old_coin_pub, - }; - break; - case BackupCoinSourceType.Tip: - coinSource = { - type: CoinSourceType.Tip, - coinIndex: backupCoin.coin_source.coin_index, - walletTipId: backupCoin.coin_source.wallet_tip_id, - }; - break; - case BackupCoinSourceType.Withdraw: - coinSource = { - type: CoinSourceType.Withdraw, - coinIndex: backupCoin.coin_source.coin_index, - reservePub: backupCoin.coin_source.reserve_pub, - withdrawalGroupId: - backupCoin.coin_source.withdrawal_group_id, - }; - break; - } - await tx.put(Stores.coins, { - blindingKey: backupCoin.blinding_key, - coinEvHash: compCoin.coinEvHash, - coinPriv: backupCoin.coin_priv, - currentAmount: Amounts.parseOrThrow(backupCoin.current_amount), - denomSig: backupCoin.denom_sig, - coinPub: compCoin.coinPub, - suspended: false, - exchangeBaseUrl: backupExchange.base_url, - denomPub: backupDenomination.denom_pub, - denomPubHash, - status: backupCoin.fresh - ? CoinStatus.Fresh - : CoinStatus.Dormant, - coinSource, - }); - } - } - } - - for (const backupReserve of backupExchange.reserves) { - const reservePub = - cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; - checkLogicInvariant(!!reservePub); - const existingReserve = await tx.get(Stores.reserves, reservePub); - const instructedAmount = Amounts.parseOrThrow( - backupReserve.instructed_amount, - ); - if (!existingReserve) { - let bankInfo: ReserveBankInfo | undefined; - if (backupReserve.bank_info) { - bankInfo = { - exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri, - statusUrl: backupReserve.bank_info.status_url, - confirmUrl: backupReserve.bank_info.confirm_url, - }; - } - await tx.put(Stores.reserves, { - currency: instructedAmount.currency, - instructedAmount, - exchangeBaseUrl: backupExchange.base_url, - reservePub, - reservePriv: backupReserve.reserve_priv, - requestedQuery: false, - bankInfo, - timestampCreated: backupReserve.timestamp_created, - timestampBankConfirmed: - backupReserve.bank_info?.timestamp_bank_confirmed, - timestampReserveInfoPosted: - backupReserve.bank_info?.timestamp_reserve_info_posted, - senderWire: backupReserve.sender_wire, - retryInfo: initRetryInfo(false), - lastError: undefined, - lastSuccessfulStatusQuery: { t_ms: "never" }, - initialWithdrawalGroupId: - backupReserve.initial_withdrawal_group_id, - initialWithdrawalStarted: - backupReserve.withdrawal_groups.length > 0, - // FIXME! - reserveStatus: ReserveRecordStatus.QUERYING_STATUS, - initialDenomSel: await getDenomSelStateFromBackup( - tx, - backupExchange.base_url, - backupReserve.initial_selected_denoms, - ), - }); - } - for (const backupWg of backupReserve.withdrawal_groups) { - const existingWg = await tx.get( - Stores.withdrawalGroups, - backupWg.withdrawal_group_id, - ); - if (!existingWg) { - await tx.put(Stores.withdrawalGroups, { - denomsSel: await getDenomSelStateFromBackup( - tx, - backupExchange.base_url, - backupWg.selected_denoms, - ), - exchangeBaseUrl: backupExchange.base_url, - lastError: undefined, - rawWithdrawalAmount: Amounts.parseOrThrow( - backupWg.raw_withdrawal_amount, - ), - reservePub, - retryInfo: initRetryInfo(false), - secretSeed: backupWg.secret_seed, - timestampStart: backupWg.timestamp_created, - timestampFinish: backupWg.timestamp_finish, - withdrawalGroupId: backupWg.withdrawal_group_id, - }); - } - } - } - } - - for (const backupProposal of backupBlob.proposals) { - const existingProposal = await tx.get( - Stores.proposals, - backupProposal.proposal_id, - ); - if (!existingProposal) { - let download: ProposalDownload | undefined; - let proposalStatus: ProposalStatus; - switch (backupProposal.proposal_status) { - case BackupProposalStatus.Proposed: - if (backupProposal.contract_terms_raw) { - proposalStatus = ProposalStatus.PROPOSED; - } else { - proposalStatus = ProposalStatus.DOWNLOADING; - } - break; - case BackupProposalStatus.Refused: - proposalStatus = ProposalStatus.REFUSED; - break; - case BackupProposalStatus.Repurchase: - proposalStatus = ProposalStatus.REPURCHASE; - break; - case BackupProposalStatus.PermanentlyFailed: - proposalStatus = ProposalStatus.PERMANENTLY_FAILED; - break; - } - if (backupProposal.contract_terms_raw) { - checkDbInvariant(!!backupProposal.merchant_sig); - const parsedContractTerms = codecForContractTerms().decode( - backupProposal.contract_terms_raw, - ); - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - const contractTermsHash = - cryptoComp.proposalIdToContractTermsHash[ - backupProposal.proposal_id - ]; - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow( - parsedContractTerms.max_wire_fee, - ); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } - download = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupProposal.merchant_sig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: - parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow( - parsedContractTerms.max_fee, - ), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - }, - contractTermsRaw: backupProposal.contract_terms_raw, - }; - } - await tx.put(Stores.proposals, { - claimToken: backupProposal.claim_token, - lastError: undefined, - merchantBaseUrl: backupProposal.merchant_base_url, - timestamp: backupProposal.timestamp, - orderId: backupProposal.order_id, - noncePriv: backupProposal.nonce_priv, - noncePub: - cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv], - proposalId: backupProposal.proposal_id, - repurchaseProposalId: backupProposal.repurchase_proposal_id, - retryInfo: initRetryInfo(false), - download, - proposalStatus, - }); - } - } - - for (const backupPurchase of backupBlob.purchases) { - const existingPurchase = await tx.get( - Stores.purchases, - backupPurchase.proposal_id, - ); - if (!existingPurchase) { - const refunds: { [refundKey: string]: WalletRefundItem } = {}; - for (const backupRefund of backupPurchase.refunds) { - const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; - const coin = await tx.get(Stores.coins, backupRefund.coin_pub); - checkBackupInvariant(!!coin); - const denom = await tx.get(Stores.denominations, [ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - checkBackupInvariant(!!denom); - const common = { - coinPub: backupRefund.coin_pub, - executionTime: backupRefund.execution_time, - obtainedTime: backupRefund.obtained_time, - refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount), - refundFee: denom.feeRefund, - rtransactionId: backupRefund.rtransaction_id, - totalRefreshCostBound: Amounts.parseOrThrow( - backupRefund.total_refresh_cost_bound, - ), - }; - switch (backupRefund.type) { - case BackupRefundState.Applied: - refunds[key] = { - type: RefundState.Applied, - ...common, - }; - break; - case BackupRefundState.Failed: - refunds[key] = { - type: RefundState.Failed, - ...common, - }; - break; - case BackupRefundState.Pending: - refunds[key] = { - type: RefundState.Pending, - ...common, - }; - break; - } - } - let abortStatus: AbortStatus; - switch (backupPurchase.abort_status) { - case "abort-finished": - abortStatus = AbortStatus.AbortFinished; - break; - case "abort-refund": - abortStatus = AbortStatus.AbortRefund; - break; - case undefined: - abortStatus = AbortStatus.None; - break; - default: - logger.warn( - `got backup purchase abort_status ${j2s( - backupPurchase.abort_status, - )}`, - ); - throw Error("not reachable"); - } - const parsedContractTerms = codecForContractTerms().decode( - backupPurchase.contract_terms_raw, - ); - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - const contractTermsHash = - cryptoComp.proposalIdToContractTermsHash[ - backupPurchase.proposal_id - ]; - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } - const download: ProposalDownload = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupPurchase.merchant_sig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: - parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - }, - contractTermsRaw: backupPurchase.contract_terms_raw, - }; - await tx.put(Stores.purchases, { - proposalId: backupPurchase.proposal_id, - noncePriv: backupPurchase.nonce_priv, - noncePub: - cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], - lastPayError: undefined, - autoRefundDeadline: { t_ms: "never" }, - refundStatusRetryInfo: initRetryInfo(false), - lastRefundStatusError: undefined, - timestampAccept: backupPurchase.timestamp_accept, - timestampFirstSuccessfulPay: - backupPurchase.timestamp_first_successful_pay, - timestampLastRefundStatus: undefined, - merchantPaySig: backupPurchase.merchant_pay_sig, - lastSessionId: undefined, - abortStatus, - // FIXME! - payRetryInfo: initRetryInfo(false), - download, - paymentSubmitPending: !backupPurchase.timestamp_first_successful_pay, - refundQueryRequested: false, - payCoinSelection: await recoverPayCoinSelection( - tx, - download.contractData, - backupPurchase, - ), - coinDepositPermissions: undefined, - totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost), - refunds, - }); - } - } - - for (const backupRefreshGroup of backupBlob.refresh_groups) { - const existingRg = await tx.get( - Stores.refreshGroups, - backupRefreshGroup.refresh_group_id, - ); - if (!existingRg) { - let reason: RefreshReason; - switch (backupRefreshGroup.reason) { - case BackupRefreshReason.AbortPay: - reason = RefreshReason.AbortPay; - break; - case BackupRefreshReason.BackupRestored: - reason = RefreshReason.BackupRestored; - break; - case BackupRefreshReason.Manual: - reason = RefreshReason.Manual; - break; - case BackupRefreshReason.Pay: - reason = RefreshReason.Pay; - break; - case BackupRefreshReason.Recoup: - reason = RefreshReason.Recoup; - break; - case BackupRefreshReason.Refund: - reason = RefreshReason.Refund; - break; - case BackupRefreshReason.Scheduled: - reason = RefreshReason.Scheduled; - break; - } - const refreshSessionPerCoin: ( - | RefreshSessionRecord - | undefined - )[] = []; - for (const oldCoin of backupRefreshGroup.old_coins) { - const c = await tx.get(Stores.coins, oldCoin.coin_pub); - checkBackupInvariant(!!c); - if (oldCoin.refresh_session) { - const denomSel = await getDenomSelStateFromBackup( - tx, - c.exchangeBaseUrl, - oldCoin.refresh_session.new_denoms, - ); - refreshSessionPerCoin.push({ - sessionSecretSeed: oldCoin.refresh_session.session_secret_seed, - norevealIndex: oldCoin.refresh_session.noreveal_index, - newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({ - count: x.count, - denomPubHash: x.denom_pub_hash, - })), - amountRefreshOutput: denomSel.totalCoinValue, - }); - } else { - refreshSessionPerCoin.push(undefined); - } - } - await tx.put(Stores.refreshGroups, { - timestampFinished: backupRefreshGroup.timestamp_finish, - timestampCreated: backupRefreshGroup.timestamp_created, - refreshGroupId: backupRefreshGroup.refresh_group_id, - reason, - lastError: undefined, - lastErrorPerCoin: {}, - oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub), - finishedPerCoin: backupRefreshGroup.old_coins.map( - (x) => x.finished, - ), - inputPerCoin: backupRefreshGroup.old_coins.map((x) => - Amounts.parseOrThrow(x.input_amount), - ), - estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) => - Amounts.parseOrThrow(x.estimated_output_amount), - ), - refreshSessionPerCoin, - retryInfo: initRetryInfo(false), - }); - } - } - - for (const backupTip of backupBlob.tips) { - const existingTip = await tx.get(Stores.tips, backupTip.wallet_tip_id); - if (!existingTip) { - const denomsSel = await getDenomSelStateFromBackup( - tx, - backupTip.exchange_base_url, - backupTip.selected_denoms, - ); - await tx.put(Stores.tips, { - acceptedTimestamp: backupTip.timestamp_accepted, - createdTimestamp: backupTip.timestamp_created, - denomsSel, - exchangeBaseUrl: backupTip.exchange_base_url, - lastError: undefined, - merchantBaseUrl: backupTip.exchange_base_url, - merchantTipId: backupTip.merchant_tip_id, - pickedUpTimestamp: backupTip.timestamp_finished, - retryInfo: initRetryInfo(false), - secretSeed: backupTip.secret_seed, - tipAmountEffective: denomsSel.totalCoinValue, - tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw), - tipExpiration: backupTip.timestamp_expiration, - walletTipId: backupTip.wallet_tip_id, - }); - } - } - }, - ); -} - -function deriveAccountKeyPair( - bc: WalletBackupConfState, - providerUrl: string, -): EddsaKeyPair { - const privateKey = kdf( - 32, - decodeCrock(bc.walletRootPriv), - stringToBytes("taler-sync-account-key-salt"), - stringToBytes(providerUrl), - ); - return { - eddsaPriv: privateKey, - eddsaPub: eddsaGetPublic(privateKey), - }; -} - -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. - * Stop if this step succeeds. - * 2. Download, verify and import backups from connected sync accounts. - * 3. Upload the updated backup blob. - */ -export async function runBackupCycle(ws: InternalWalletState): Promise<void> { - const providers = await ws.db.iter(Stores.backupProviders).toArray(); - logger.trace("got backup providers", providers); - const backupJson = await exportBackup(ws); - const backupConfig = await provideBackupState(ws); - const encBackup = await encryptBackup(backupConfig, backupJson); - - const currentBackupHash = hash(encBackup); - - for (const provider of providers) { - const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl); - logger.trace(`trying to upload backup to ${provider.baseUrl}`); - - const syncSig = await ws.cryptoApi.makeSyncSignature({ - newHash: encodeCrock(currentBackupHash), - oldHash: provider.lastBackupHash, - accountPriv: encodeCrock(accountKeyPair.eddsaPriv), - }); - - logger.trace(`sync signature is ${syncSig}`); - - const accountBackupUrl = new URL( - `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`, - provider.baseUrl, - ); - - const resp = await ws.http.fetch(accountBackupUrl.href, { - method: "POST", - body: encBackup, - headers: { - "content-type": "application/octet-stream", - "sync-signature": syncSig, - "if-none-match": encodeCrock(currentBackupHash), - ...(provider.lastBackupHash - ? { - "if-match": provider.lastBackupHash, - } - : {}), - }, - }); - - logger.trace(`response status: ${resp.status}`); - - if (resp.status === HttpResponseStatus.PaymentRequired) { - logger.trace("payment required for backup"); - logger.trace(`headers: ${j2s(resp.headers)}`); - const talerUri = resp.headers.get("taler"); - if (!talerUri) { - throw Error("no taler URI available to pay provider"); - } - const res = await preparePayForUri(ws, talerUri); - let proposalId: string | undefined; - switch (res.status) { - case PreparePayResultType.InsufficientBalance: - // FIXME: record in provider state! - logger.warn("insufficient balance to pay for backup provider"); - break; - case PreparePayResultType.PaymentPossible: - case PreparePayResultType.AlreadyConfirmed: - proposalId = res.proposalId; - break; - } - if (!proposalId) { - continue; - } - const p = proposalId; - await ws.db.runWithWriteTransaction( - [Stores.backupProviders], - async (tx) => { - const provRec = await tx.get( - Stores.backupProviders, - provider.baseUrl, - ); - checkDbInvariant(!!provRec); - const ids = new Set(provRec.paymentProposalIds); - ids.add(p); - provRec.paymentProposalIds = Array.from(ids); - await tx.put(Stores.backupProviders, provRec); - }, - ); - const confirmRes = await confirmPay(ws, proposalId); - switch (confirmRes.type) { - case ConfirmPayResultType.Pending: - logger.warn("payment not yet finished yet"); - break; - } - } - if (resp.status === HttpResponseStatus.NoContent) { - await ws.db.runWithWriteTransaction( - [Stores.backupProviders], - async (tx) => { - const prov = await tx.get(Stores.backupProviders, provider.baseUrl); - if (!prov) { - return; - } - prov.lastBackupHash = encodeCrock(currentBackupHash); - prov.lastBackupTimestamp = getTimestampNow(); - prov.lastBackupClock = - backupJson.clocks[backupJson.current_device_id]; - await tx.put(Stores.backupProviders, prov); - }, - ); - continue; - } - if (resp.status === HttpResponseStatus.Conflict) { - logger.info("conflicting backup found"); - const backupEnc = new Uint8Array(await resp.bytes()); - const backupConfig = await provideBackupState(ws); - const blob = await decryptBackup(backupConfig, backupEnc); - const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); - await importBackup(ws, blob, cryptoData); - await ws.db.runWithWriteTransaction( - [Stores.backupProviders], - async (tx) => { - const prov = await tx.get(Stores.backupProviders, provider.baseUrl); - if (!prov) { - return; - } - prov.lastBackupHash = encodeCrock(hash(backupEnc)); - prov.lastBackupClock = blob.clocks[blob.current_device_id]; - prov.lastBackupTimestamp = getTimestampNow(); - await tx.put(Stores.backupProviders, prov); - }, - ); - logger.info("processed existing backup"); - } - } -} - -interface SyncTermsOfServiceResponse { - // maximum backup size supported - storage_limit_in_megabytes: number; - - // Fee for an account, per year. - annual_fee: AmountString; - - // protocol version supported by the server, - // for now always "0.0". - version: string; -} - -const codecForSyncTermsOfServiceResponse = (): Codec< - SyncTermsOfServiceResponse -> => - buildCodecForObject<SyncTermsOfServiceResponse>() - .property("storage_limit_in_megabytes", codecForNumber()) - .property("annual_fee", codecForAmountString()) - .property("version", codecForString()) - .build("SyncTermsOfServiceResponse"); - -export interface AddBackupProviderRequest { - backupProviderBaseUrl: string; - /** - * Activate the provider. Should only be done after - * the user has reviewed the provider. - */ - activate?: boolean; -} - -export const codecForAddBackupProviderRequest = (): Codec< - AddBackupProviderRequest -> => - buildCodecForObject<AddBackupProviderRequest>() - .property("backupProviderBaseUrl", codecForString()) - .build("AddBackupProviderRequest"); - -export async function addBackupProvider( - ws: InternalWalletState, - req: AddBackupProviderRequest, -): Promise<void> { - await provideBackupState(ws); - const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); - const oldProv = await ws.db.get(Stores.backupProviders, canonUrl); - if (oldProv) { - if (req.activate) { - oldProv.active = true; - await ws.db.put(Stores.backupProviders, oldProv); - } - return; - } - const termsUrl = new URL("terms", canonUrl); - const resp = await ws.http.get(termsUrl.href); - const terms = await readSuccessResponseJsonOrThrow( - resp, - codecForSyncTermsOfServiceResponse(), - ); - await ws.db.put(Stores.backupProviders, { - active: !!req.activate, - terms: { - annualFee: terms.annual_fee, - storageLimitInMegabytes: terms.storage_limit_in_megabytes, - supportedProtocolVersion: terms.version, - }, - paymentProposalIds: [], - baseUrl: canonUrl, - lastError: undefined, - retryInfo: initRetryInfo(false), - }); -} - -export async function removeBackupProvider( - syncProviderBaseUrl: string, -): Promise<void> {} - -export async function restoreFromRecoverySecret(): Promise<void> {} - -/** - * Information about one provider. - * - * We don't store the account key here, - * as that's derived from the wallet root key. - */ -export interface ProviderInfo { - active: boolean; - syncProviderBaseUrl: string; - lastRemoteClock?: number; - lastBackupTimestamp?: Timestamp; - paymentProposalIds: string[]; -} - -export interface BackupInfo { - walletRootPub: string; - deviceId: string; - lastLocalClock: number; - providers: ProviderInfo[]; -} - -export async function importBackupPlain( - ws: InternalWalletState, - blob: any, -): Promise<void> { - // FIXME: parse - const backup: WalletBackupContentV1 = blob; - - const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup); - - await importBackup(ws, blob, cryptoData); -} - -/** - * Get information about the current state of wallet backups. - */ -export async function getBackupInfo( - ws: InternalWalletState, -): Promise<BackupInfo> { - const backupConfig = await provideBackupState(ws); - const providers = await ws.db.iter(Stores.backupProviders).toArray(); - return { - deviceId: backupConfig.deviceId, - lastLocalClock: backupConfig.clocks[backupConfig.deviceId], - walletRootPub: backupConfig.walletRootPub, - providers: providers.map((x) => ({ - active: x.active, - lastRemoteClock: x.lastBackupClock, - syncProviderBaseUrl: x.baseUrl, - lastBackupTimestamp: x.lastBackupTimestamp, - paymentProposalIds: x.paymentProposalIds, - })), - }; -} - -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, - }; -} - -async function backupRecoveryTheirs( - ws: InternalWalletState, - br: BackupRecovery, -) { - await ws.db.runWithWriteTransaction( - [Stores.config, Stores.backupProviders], - async (tx) => { - let backupStateEntry: - | ConfigRecord<WalletBackupConfState> - | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); - checkDbInvariant(!!backupStateEntry); - backupStateEntry.value.lastBackupNonce = undefined; - backupStateEntry.value.lastBackupTimestamp = undefined; - backupStateEntry.value.lastBackupCheckTimestamp = undefined; - backupStateEntry.value.lastBackupPlainHash = undefined; - backupStateEntry.value.walletRootPriv = br.walletRootPriv; - backupStateEntry.value.walletRootPub = encodeCrock( - eddsaGetPublic(decodeCrock(br.walletRootPriv)), - ); - await tx.put(Stores.config, backupStateEntry); - for (const prov of br.providers) { - const existingProv = await tx.get(Stores.backupProviders, prov.url); - if (!existingProv) { - await tx.put(Stores.backupProviders, { - active: true, - baseUrl: prov.url, - paymentProposalIds: [], - retryInfo: initRetryInfo(false), - lastError: undefined, - }); - } - } - const providers = await tx.iter(Stores.backupProviders).toArray(); - for (const prov of providers) { - prov.lastBackupTimestamp = undefined; - prov.lastBackupHash = undefined; - prov.lastBackupClock = undefined; - await tx.put(Stores.backupProviders, prov); - } - }, - ); -} - -async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) { - throw Error("not implemented"); -} - -export async function loadBackupRecovery( - ws: InternalWalletState, - br: RecoveryLoadRequest, -): Promise<void> { - const bs = await provideBackupState(ws); - const providers = await ws.db.iter(Stores.backupProviders).toArray(); - let strategy = br.strategy; - if ( - br.recovery.walletRootPriv != bs.walletRootPriv && - providers.length > 0 && - !strategy - ) { - throw Error( - "recovery load strategy must be specified for wallet with existing providers", - ); - } else if (!strategy) { - // Default to using the new key if we don't have providers yet. - strategy = RecoveryMergeStrategy.Theirs; - } - if (strategy === RecoveryMergeStrategy.Theirs) { - return backupRecoveryTheirs(ws, br.recovery); - } else { - return backupRecoveryOurs(ws, br.recovery); - } -} - -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 decryptBackup( - backupConfig: WalletBackupConfState, - data: Uint8Array, -): Promise<WalletBackupContentV1> { - 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"); - } - return JSON.parse(bytesToString(gunzipSync(dataCompressed))); -} - -export async function importBackupEncrypted( - ws: InternalWalletState, - data: Uint8Array, -): Promise<void> { - const backupConfig = await provideBackupState(ws); - const blob = await decryptBackup(backupConfig, data); - const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); - await importBackup(ws, blob, cryptoData); -} diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts new file mode 100644 index 000000000..a32aec39d --- /dev/null +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -0,0 +1,447 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Stores, Amounts, CoinSourceType, CoinStatus, RefundState, AbortStatus, ProposalStatus, getTimestampNow, encodeCrock, stringToBytes, getRandomBytes } from "../.."; +import { hash } from "../../crypto/primitives/nacl-fast"; +import { WalletBackupContentV1, BackupExchange, BackupCoin, BackupDenomination, BackupReserve, BackupPurchase, BackupProposal, BackupRefreshGroup, BackupBackupProvider, BackupTip, BackupRecoupGroup, BackupWithdrawalGroup, BackupBackupProviderTerms, BackupCoinSource, BackupCoinSourceType, BackupExchangeWireFee, BackupRefundItem, BackupRefundState, BackupProposalStatus, BackupRefreshOldCoin, BackupRefreshSession } from "../../types/backupTypes"; +import { canonicalizeBaseUrl, canonicalJson } from "../../util/helpers"; +import { InternalWalletState } from "../state"; +import { provideBackupState, getWalletBackupState, WALLET_BACKUP_STATE_KEY } from "./state"; + +/** + * Implementation of wallet backups (export/import/upload) and sync + * server management. + * + * @author Florian Dold <dold@taler.net> + */ + +export async function exportBackup( + ws: InternalWalletState, +): Promise<WalletBackupContentV1> { + await provideBackupState(ws); + return ws.db.runWithWriteTransaction( + [ + Stores.config, + Stores.exchanges, + Stores.coins, + Stores.denominations, + Stores.purchases, + Stores.proposals, + Stores.refreshGroups, + Stores.backupProviders, + Stores.tips, + Stores.recoupGroups, + Stores.reserves, + Stores.withdrawalGroups, + ], + async (tx) => { + const bs = await getWalletBackupState(ws, tx); + + const backupExchanges: BackupExchange[] = []; + const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {}; + const backupDenominationsByExchange: { + [url: string]: BackupDenomination[]; + } = {}; + const backupReservesByExchange: { [url: string]: BackupReserve[] } = {}; + const backupPurchases: BackupPurchase[] = []; + const backupProposals: BackupProposal[] = []; + const backupRefreshGroups: BackupRefreshGroup[] = []; + const backupBackupProviders: BackupBackupProvider[] = []; + const backupTips: BackupTip[] = []; + const backupRecoupGroups: BackupRecoupGroup[] = []; + const withdrawalGroupsByReserve: { + [reservePub: string]: BackupWithdrawalGroup[]; + } = {}; + + await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wg) => { + const withdrawalGroups = (withdrawalGroupsByReserve[ + wg.reservePub + ] ??= []); + withdrawalGroups.push({ + raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount), + selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({ + count: x.count, + denom_pub_hash: x.denomPubHash, + })), + timestamp_created: wg.timestampStart, + timestamp_finish: wg.timestampFinish, + withdrawal_group_id: wg.withdrawalGroupId, + secret_seed: wg.secretSeed, + }); + }); + + await tx.iter(Stores.reserves).forEach((reserve) => { + const backupReserve: BackupReserve = { + initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map( + (x) => ({ + count: x.count, + denom_pub_hash: x.denomPubHash, + }), + ), + initial_withdrawal_group_id: reserve.initialWithdrawalGroupId, + instructed_amount: Amounts.stringify(reserve.instructedAmount), + reserve_priv: reserve.reservePriv, + timestamp_created: reserve.timestampCreated, + withdrawal_groups: + withdrawalGroupsByReserve[reserve.reservePub] ?? [], + // FIXME! + timestamp_last_activity: reserve.timestampCreated, + }; + const backupReserves = (backupReservesByExchange[ + reserve.exchangeBaseUrl + ] ??= []); + backupReserves.push(backupReserve); + }); + + await tx.iter(Stores.tips).forEach((tip) => { + backupTips.push({ + exchange_base_url: tip.exchangeBaseUrl, + merchant_base_url: tip.merchantBaseUrl, + merchant_tip_id: tip.merchantTipId, + wallet_tip_id: tip.walletTipId, + secret_seed: tip.secretSeed, + selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({ + count: x.count, + denom_pub_hash: x.denomPubHash, + })), + timestamp_finished: tip.pickedUpTimestamp, + timestamp_accepted: tip.acceptedTimestamp, + timestamp_created: tip.createdTimestamp, + timestamp_expiration: tip.tipExpiration, + tip_amount_raw: Amounts.stringify(tip.tipAmountRaw), + }); + }); + + await tx.iter(Stores.recoupGroups).forEach((recoupGroup) => { + backupRecoupGroups.push({ + recoup_group_id: recoupGroup.recoupGroupId, + timestamp_created: recoupGroup.timestampStarted, + timestamp_finish: recoupGroup.timestampFinished, + coins: recoupGroup.coinPubs.map((x, i) => ({ + coin_pub: x, + recoup_finished: recoupGroup.recoupFinishedPerCoin[i], + old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]), + })), + }); + }); + + await tx.iter(Stores.backupProviders).forEach((bp) => { + let terms: BackupBackupProviderTerms | undefined; + if (bp.terms) { + terms = { + annual_fee: Amounts.stringify(bp.terms.annualFee), + storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes, + supported_protocol_version: bp.terms.supportedProtocolVersion, + }; + } + backupBackupProviders.push({ + terms, + base_url: canonicalizeBaseUrl(bp.baseUrl), + pay_proposal_ids: bp.paymentProposalIds, + }); + }); + + await tx.iter(Stores.coins).forEach((coin) => { + let bcs: BackupCoinSource; + switch (coin.coinSource.type) { + case CoinSourceType.Refresh: + bcs = { + type: BackupCoinSourceType.Refresh, + old_coin_pub: coin.coinSource.oldCoinPub, + }; + break; + case CoinSourceType.Tip: + bcs = { + type: BackupCoinSourceType.Tip, + coin_index: coin.coinSource.coinIndex, + wallet_tip_id: coin.coinSource.walletTipId, + }; + break; + case CoinSourceType.Withdraw: + bcs = { + type: BackupCoinSourceType.Withdraw, + coin_index: coin.coinSource.coinIndex, + reserve_pub: coin.coinSource.reservePub, + withdrawal_group_id: coin.coinSource.withdrawalGroupId, + }; + break; + } + + const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []); + coins.push({ + blinding_key: coin.blindingKey, + coin_priv: coin.coinPriv, + coin_source: bcs, + current_amount: Amounts.stringify(coin.currentAmount), + fresh: coin.status === CoinStatus.Fresh, + denom_sig: coin.denomSig, + }); + }); + + await tx.iter(Stores.denominations).forEach((denom) => { + const backupDenoms = (backupDenominationsByExchange[ + denom.exchangeBaseUrl + ] ??= []); + backupDenoms.push({ + coins: backupCoinsByDenom[denom.denomPubHash] ?? [], + denom_pub: denom.denomPub, + fee_deposit: Amounts.stringify(denom.feeDeposit), + fee_refresh: Amounts.stringify(denom.feeRefresh), + fee_refund: Amounts.stringify(denom.feeRefund), + fee_withdraw: Amounts.stringify(denom.feeWithdraw), + is_offered: denom.isOffered, + is_revoked: denom.isRevoked, + master_sig: denom.masterSig, + stamp_expire_deposit: denom.stampExpireDeposit, + stamp_expire_legal: denom.stampExpireLegal, + stamp_expire_withdraw: denom.stampExpireWithdraw, + stamp_start: denom.stampStart, + value: Amounts.stringify(denom.value), + }); + }); + + await tx.iter(Stores.exchanges).forEach((ex) => { + // Only back up permanently added exchanges. + + if (!ex.details) { + return; + } + if (!ex.wireInfo) { + return; + } + if (!ex.addComplete) { + return; + } + if (!ex.permanent) { + return; + } + const wi = ex.wireInfo; + const wireFees: BackupExchangeWireFee[] = []; + + Object.keys(wi.feesForType).forEach((x) => { + for (const f of wi.feesForType[x]) { + wireFees.push({ + wire_type: x, + closing_fee: Amounts.stringify(f.closingFee), + end_stamp: f.endStamp, + sig: f.sig, + start_stamp: f.startStamp, + wire_fee: Amounts.stringify(f.wireFee), + }); + } + }); + + backupExchanges.push({ + base_url: ex.baseUrl, + reserve_closing_delay: ex.details.reserveClosingDelay, + accounts: ex.wireInfo.accounts.map((x) => ({ + payto_uri: x.payto_uri, + master_sig: x.master_sig, + })), + auditors: ex.details.auditors.map((x) => ({ + auditor_pub: x.auditor_pub, + auditor_url: x.auditor_url, + denomination_keys: x.denomination_keys, + })), + master_public_key: ex.details.masterPublicKey, + currency: ex.details.currency, + protocol_version: ex.details.protocolVersion, + wire_fees: wireFees, + signing_keys: ex.details.signingKeys.map((x) => ({ + key: x.key, + master_sig: x.master_sig, + stamp_end: x.stamp_end, + stamp_expire: x.stamp_expire, + stamp_start: x.stamp_start, + })), + tos_etag_accepted: ex.termsOfServiceAcceptedEtag, + tos_etag_last: ex.termsOfServiceLastEtag, + denominations: backupDenominationsByExchange[ex.baseUrl] ?? [], + reserves: backupReservesByExchange[ex.baseUrl] ?? [], + }); + }); + + const purchaseProposalIdSet = new Set<string>(); + + await tx.iter(Stores.purchases).forEach((purch) => { + const refunds: BackupRefundItem[] = []; + purchaseProposalIdSet.add(purch.proposalId); + for (const refundKey of Object.keys(purch.refunds)) { + const ri = purch.refunds[refundKey]; + const common = { + coin_pub: ri.coinPub, + execution_time: ri.executionTime, + obtained_time: ri.obtainedTime, + refund_amount: Amounts.stringify(ri.refundAmount), + rtransaction_id: ri.rtransactionId, + total_refresh_cost_bound: Amounts.stringify( + ri.totalRefreshCostBound, + ), + }; + switch (ri.type) { + case RefundState.Applied: + refunds.push({ type: BackupRefundState.Applied, ...common }); + break; + case RefundState.Failed: + refunds.push({ type: BackupRefundState.Failed, ...common }); + break; + case RefundState.Pending: + refunds.push({ type: BackupRefundState.Pending, ...common }); + break; + } + } + + backupPurchases.push({ + contract_terms_raw: purch.download.contractTermsRaw, + auto_refund_deadline: purch.autoRefundDeadline, + merchant_pay_sig: purch.merchantPaySig, + pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({ + coin_pub: x, + contribution: Amounts.stringify( + purch.payCoinSelection.coinContributions[i], + ), + })), + proposal_id: purch.proposalId, + refunds, + timestamp_accept: purch.timestampAccept, + timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, + abort_status: + purch.abortStatus === AbortStatus.None + ? undefined + : purch.abortStatus, + nonce_priv: purch.noncePriv, + merchant_sig: purch.download.contractData.merchantSig, + total_pay_cost: Amounts.stringify(purch.totalPayCost), + }); + }); + + await tx.iter(Stores.proposals).forEach((prop) => { + if (purchaseProposalIdSet.has(prop.proposalId)) { + return; + } + let propStatus: BackupProposalStatus; + switch (prop.proposalStatus) { + case ProposalStatus.ACCEPTED: + return; + case ProposalStatus.DOWNLOADING: + case ProposalStatus.PROPOSED: + propStatus = BackupProposalStatus.Proposed; + break; + case ProposalStatus.PERMANENTLY_FAILED: + propStatus = BackupProposalStatus.PermanentlyFailed; + break; + case ProposalStatus.REFUSED: + propStatus = BackupProposalStatus.Refused; + break; + case ProposalStatus.REPURCHASE: + propStatus = BackupProposalStatus.Repurchase; + break; + } + backupProposals.push({ + claim_token: prop.claimToken, + nonce_priv: prop.noncePriv, + proposal_id: prop.noncePriv, + proposal_status: propStatus, + repurchase_proposal_id: prop.repurchaseProposalId, + timestamp: prop.timestamp, + contract_terms_raw: prop.download?.contractTermsRaw, + download_session_id: prop.downloadSessionId, + merchant_base_url: prop.merchantBaseUrl, + order_id: prop.orderId, + merchant_sig: prop.download?.contractData.merchantSig, + }); + }); + + await tx.iter(Stores.refreshGroups).forEach((rg) => { + const oldCoins: BackupRefreshOldCoin[] = []; + + for (let i = 0; i < rg.oldCoinPubs.length; i++) { + let refreshSession: BackupRefreshSession | undefined; + const s = rg.refreshSessionPerCoin[i]; + if (s) { + refreshSession = { + new_denoms: s.newDenoms.map((x) => ({ + count: x.count, + denom_pub_hash: x.denomPubHash, + })), + session_secret_seed: s.sessionSecretSeed, + noreveal_index: s.norevealIndex, + }; + } + oldCoins.push({ + coin_pub: rg.oldCoinPubs[i], + estimated_output_amount: Amounts.stringify( + rg.estimatedOutputPerCoin[i], + ), + finished: rg.finishedPerCoin[i], + input_amount: Amounts.stringify(rg.inputPerCoin[i]), + refresh_session: refreshSession, + }); + } + + backupRefreshGroups.push({ + reason: rg.reason as any, + refresh_group_id: rg.refreshGroupId, + timestamp_created: rg.timestampCreated, + timestamp_finish: rg.timestampFinished, + old_coins: oldCoins, + }); + }); + + if (!bs.lastBackupTimestamp) { + bs.lastBackupTimestamp = getTimestampNow(); + } + + const backupBlob: WalletBackupContentV1 = { + schema_id: "gnu-taler-wallet-backup-content", + schema_version: 1, + clocks: bs.clocks, + exchanges: backupExchanges, + wallet_root_pub: bs.walletRootPub, + backup_providers: backupBackupProviders, + current_device_id: bs.deviceId, + proposals: backupProposals, + purchase_tombstones: [], + purchases: backupPurchases, + recoup_groups: backupRecoupGroups, + refresh_groups: backupRefreshGroups, + tips: backupTips, + timestamp: bs.lastBackupTimestamp, + trusted_auditors: {}, + trusted_exchanges: {}, + intern_table: {}, + error_reports: [], + }; + + // If the backup changed, we increment our clock. + + let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob)))); + if (h != bs.lastBackupPlainHash) { + backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId]; + bs.lastBackupPlainHash = encodeCrock( + hash(stringToBytes(canonicalJson(backupBlob))), + ); + bs.lastBackupNonce = encodeCrock(getRandomBytes(32)); + await tx.put(Stores.config, { + key: WALLET_BACKUP_STATE_KEY, + value: bs, + }); + } + + return backupBlob; + }, + ); +} diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts new file mode 100644 index 000000000..fa0819745 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -0,0 +1,825 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Stores, + Amounts, + CoinSourceType, + CoinStatus, + RefundState, + AbortStatus, + ProposalStatus, + getTimestampNow, + encodeCrock, + stringToBytes, + getRandomBytes, + AmountJson, + codecForContractTerms, + CoinSource, + DenominationStatus, + DenomSelectionState, + ExchangeUpdateStatus, + ExchangeWireInfo, + PayCoinSelection, + ProposalDownload, + RefreshReason, + RefreshSessionRecord, + ReserveBankInfo, + ReserveRecordStatus, + TransactionHandle, + WalletContractData, + WalletRefundItem, +} from "../.."; +import { hash } from "../../crypto/primitives/nacl-fast"; +import { + WalletBackupContentV1, + BackupExchange, + BackupCoin, + BackupDenomination, + BackupReserve, + BackupPurchase, + BackupProposal, + BackupRefreshGroup, + BackupBackupProvider, + BackupTip, + BackupRecoupGroup, + BackupWithdrawalGroup, + BackupBackupProviderTerms, + BackupCoinSource, + BackupCoinSourceType, + BackupExchangeWireFee, + BackupRefundItem, + BackupRefundState, + BackupProposalStatus, + BackupRefreshOldCoin, + BackupRefreshSession, + BackupDenomSel, + BackupRefreshReason, +} from "../../types/backupTypes"; +import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers"; +import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants"; +import { Logger } from "../../util/logging"; +import { initRetryInfo } from "../../util/retries"; +import { InternalWalletState } from "../state"; +import { provideBackupState } from "./state"; + + +const logger = new Logger("operations/backup/import.ts"); + +function checkBackupInvariant(b: boolean, m?: string): asserts b { + if (!b) { + if (m) { + throw Error(`BUG: backup invariant failed (${m})`); + } else { + throw Error("BUG: backup invariant failed"); + } + } +} + +/** + * Re-compute information about the coin selection for a payment. + */ +async function recoverPayCoinSelection( + tx: TransactionHandle< + typeof Stores.exchanges | typeof Stores.coins | typeof Stores.denominations + >, + contractData: WalletContractData, + backupPurchase: BackupPurchase, +): Promise<PayCoinSelection> { + const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub); + const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ); + + const coveredExchanges: Set<string> = new Set(); + + let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency); + let totalDepositFees: AmountJson = Amounts.getZero( + contractData.amount.currency, + ); + + for (const coinPub of coinPubs) { + const coinRecord = await tx.get(Stores.coins, coinPub); + checkBackupInvariant(!!coinRecord); + const denom = await tx.get(Stores.denominations, [ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ]); + checkBackupInvariant(!!denom); + totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount; + + if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) { + const exchange = await tx.get( + Stores.exchanges, + coinRecord.exchangeBaseUrl, + ); + checkBackupInvariant(!!exchange); + let wireFee: AmountJson | undefined; + const feesForType = exchange.wireInfo?.feesForType; + checkBackupInvariant(!!feesForType); + for (const fee of feesForType[contractData.wireMethod] || []) { + if ( + fee.startStamp <= contractData.timestamp && + fee.endStamp >= contractData.timestamp + ) { + wireFee = fee.wireFee; + break; + } + } + if (wireFee) { + totalWireFee = Amounts.add(totalWireFee, wireFee).amount; + } + } + } + + let customerWireFee: AmountJson; + + const amortizedWireFee = Amounts.divide( + totalWireFee, + contractData.wireFeeAmortization, + ); + if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) { + customerWireFee = amortizedWireFee; + } else { + customerWireFee = Amounts.getZero(contractData.amount.currency); + } + + const customerDepositFees = Amounts.sub( + totalDepositFees, + contractData.maxDepositFee, + ).amount; + + return { + coinPubs, + coinContributions, + paymentAmount: contractData.amount, + customerWireFees: customerWireFee, + customerDepositFees, + }; +} + +async function getDenomSelStateFromBackup( + tx: TransactionHandle<typeof Stores.denominations>, + exchangeBaseUrl: string, + sel: BackupDenomSel, +): Promise<DenomSelectionState> { + const d0 = await tx.get(Stores.denominations, [ + exchangeBaseUrl, + sel[0].denom_pub_hash, + ]); + checkBackupInvariant(!!d0); + const selectedDenoms: { + denomPubHash: string; + count: number; + }[] = []; + 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, + ]); + checkBackupInvariant(!!d); + totalCoinValue = Amounts.add(totalCoinValue, d.value).amount; + totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw) + .amount; + } + return { + selectedDenoms, + totalCoinValue, + totalWithdrawCost, + }; +} + +export interface CompletedCoin { + coinPub: string; + coinEvHash: string; +} + +/** + * Precomputed cryptographic material for a backup import. + * + * We separate this data from the backup blob as we want the backup + * blob to be small, and we can't compute it during the database transaction, + * as the async crypto worker communication would auto-close the database transaction. + */ +export interface BackupCryptoPrecomputedData { + denomPubToHash: Record<string, string>; + coinPrivToCompletedCoin: Record<string, CompletedCoin>; + proposalNoncePrivToPub: { [priv: string]: string }; + proposalIdToContractTermsHash: { [proposalId: string]: string }; + reservePrivToPub: Record<string, string>; +} + +export async function importBackup( + ws: InternalWalletState, + backupBlobArg: any, + cryptoComp: BackupCryptoPrecomputedData, +): Promise<void> { + await provideBackupState(ws); + return ws.db.runWithWriteTransaction( + [ + Stores.config, + Stores.exchanges, + Stores.coins, + Stores.denominations, + Stores.purchases, + Stores.proposals, + Stores.refreshGroups, + Stores.backupProviders, + Stores.tips, + Stores.recoupGroups, + Stores.reserves, + Stores.withdrawalGroups, + ], + async (tx) => { + // FIXME: validate schema! + const backupBlob = backupBlobArg as WalletBackupContentV1; + + // FIXME: validate version + + for (const backupExchange of backupBlob.exchanges) { + const existingExchange = await tx.get( + Stores.exchanges, + backupExchange.base_url, + ); + + if (!existingExchange) { + const wireInfo: ExchangeWireInfo = { + accounts: backupExchange.accounts.map((x) => ({ + master_sig: x.master_sig, + payto_uri: x.payto_uri, + })), + feesForType: {}, + }; + for (const fee of backupExchange.wire_fees) { + const w = (wireInfo.feesForType[fee.wire_type] ??= []); + w.push({ + closingFee: Amounts.parseOrThrow(fee.closing_fee), + endStamp: fee.end_stamp, + sig: fee.sig, + startStamp: fee.start_stamp, + wireFee: Amounts.parseOrThrow(fee.wire_fee), + }); + } + await tx.put(Stores.exchanges, { + addComplete: true, + baseUrl: backupExchange.base_url, + builtIn: false, + updateReason: undefined, + permanent: true, + retryInfo: initRetryInfo(), + termsOfServiceAcceptedEtag: backupExchange.tos_etag_accepted, + termsOfServiceText: undefined, + termsOfServiceLastEtag: backupExchange.tos_etag_last, + updateStarted: getTimestampNow(), + updateStatus: ExchangeUpdateStatus.FetchKeys, + wireInfo, + details: { + currency: backupExchange.currency, + reserveClosingDelay: backupExchange.reserve_closing_delay, + auditors: backupExchange.auditors.map((x) => ({ + auditor_pub: x.auditor_pub, + auditor_url: x.auditor_url, + denomination_keys: x.denomination_keys, + })), + lastUpdateTime: { t_ms: "never" }, + masterPublicKey: backupExchange.master_public_key, + nextUpdateTime: { t_ms: "never" }, + protocolVersion: backupExchange.protocol_version, + signingKeys: backupExchange.signing_keys.map((x) => ({ + key: x.key, + master_sig: x.master_sig, + stamp_end: x.stamp_end, + stamp_expire: x.stamp_expire, + stamp_start: x.stamp_start, + })), + }, + }); + } + + for (const backupDenomination of backupExchange.denominations) { + const denomPubHash = + cryptoComp.denomPubToHash[backupDenomination.denom_pub]; + checkLogicInvariant(!!denomPubHash); + const existingDenom = await tx.get(Stores.denominations, [ + backupExchange.base_url, + denomPubHash, + ]); + if (!existingDenom) { + await tx.put(Stores.denominations, { + denomPub: backupDenomination.denom_pub, + denomPubHash: denomPubHash, + exchangeBaseUrl: backupExchange.base_url, + feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit), + feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh), + feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund), + feeWithdraw: Amounts.parseOrThrow( + backupDenomination.fee_withdraw, + ), + isOffered: backupDenomination.is_offered, + isRevoked: backupDenomination.is_revoked, + masterSig: backupDenomination.master_sig, + stampExpireDeposit: backupDenomination.stamp_expire_deposit, + stampExpireLegal: backupDenomination.stamp_expire_legal, + stampExpireWithdraw: backupDenomination.stamp_expire_withdraw, + stampStart: backupDenomination.stamp_start, + status: DenominationStatus.VerifiedGood, + value: Amounts.parseOrThrow(backupDenomination.value), + }); + } + for (const backupCoin of backupDenomination.coins) { + const compCoin = + cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv]; + checkLogicInvariant(!!compCoin); + const existingCoin = await tx.get(Stores.coins, compCoin.coinPub); + if (!existingCoin) { + let coinSource: CoinSource; + switch (backupCoin.coin_source.type) { + case BackupCoinSourceType.Refresh: + coinSource = { + type: CoinSourceType.Refresh, + oldCoinPub: backupCoin.coin_source.old_coin_pub, + }; + break; + case BackupCoinSourceType.Tip: + coinSource = { + type: CoinSourceType.Tip, + coinIndex: backupCoin.coin_source.coin_index, + walletTipId: backupCoin.coin_source.wallet_tip_id, + }; + break; + case BackupCoinSourceType.Withdraw: + coinSource = { + type: CoinSourceType.Withdraw, + coinIndex: backupCoin.coin_source.coin_index, + reservePub: backupCoin.coin_source.reserve_pub, + withdrawalGroupId: + backupCoin.coin_source.withdrawal_group_id, + }; + break; + } + await tx.put(Stores.coins, { + blindingKey: backupCoin.blinding_key, + coinEvHash: compCoin.coinEvHash, + coinPriv: backupCoin.coin_priv, + currentAmount: Amounts.parseOrThrow(backupCoin.current_amount), + denomSig: backupCoin.denom_sig, + coinPub: compCoin.coinPub, + suspended: false, + exchangeBaseUrl: backupExchange.base_url, + denomPub: backupDenomination.denom_pub, + denomPubHash, + status: backupCoin.fresh + ? CoinStatus.Fresh + : CoinStatus.Dormant, + coinSource, + }); + } + } + } + + for (const backupReserve of backupExchange.reserves) { + const reservePub = + cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; + checkLogicInvariant(!!reservePub); + const existingReserve = await tx.get(Stores.reserves, reservePub); + const instructedAmount = Amounts.parseOrThrow( + backupReserve.instructed_amount, + ); + if (!existingReserve) { + let bankInfo: ReserveBankInfo | undefined; + if (backupReserve.bank_info) { + bankInfo = { + exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri, + statusUrl: backupReserve.bank_info.status_url, + confirmUrl: backupReserve.bank_info.confirm_url, + }; + } + await tx.put(Stores.reserves, { + currency: instructedAmount.currency, + instructedAmount, + exchangeBaseUrl: backupExchange.base_url, + reservePub, + reservePriv: backupReserve.reserve_priv, + requestedQuery: false, + bankInfo, + timestampCreated: backupReserve.timestamp_created, + timestampBankConfirmed: + backupReserve.bank_info?.timestamp_bank_confirmed, + timestampReserveInfoPosted: + backupReserve.bank_info?.timestamp_reserve_info_posted, + senderWire: backupReserve.sender_wire, + retryInfo: initRetryInfo(false), + lastError: undefined, + lastSuccessfulStatusQuery: { t_ms: "never" }, + initialWithdrawalGroupId: + backupReserve.initial_withdrawal_group_id, + initialWithdrawalStarted: + backupReserve.withdrawal_groups.length > 0, + // FIXME! + reserveStatus: ReserveRecordStatus.QUERYING_STATUS, + initialDenomSel: await getDenomSelStateFromBackup( + tx, + backupExchange.base_url, + backupReserve.initial_selected_denoms, + ), + }); + } + for (const backupWg of backupReserve.withdrawal_groups) { + const existingWg = await tx.get( + Stores.withdrawalGroups, + backupWg.withdrawal_group_id, + ); + if (!existingWg) { + await tx.put(Stores.withdrawalGroups, { + denomsSel: await getDenomSelStateFromBackup( + tx, + backupExchange.base_url, + backupWg.selected_denoms, + ), + exchangeBaseUrl: backupExchange.base_url, + lastError: undefined, + rawWithdrawalAmount: Amounts.parseOrThrow( + backupWg.raw_withdrawal_amount, + ), + reservePub, + retryInfo: initRetryInfo(false), + secretSeed: backupWg.secret_seed, + timestampStart: backupWg.timestamp_created, + timestampFinish: backupWg.timestamp_finish, + withdrawalGroupId: backupWg.withdrawal_group_id, + }); + } + } + } + } + + for (const backupProposal of backupBlob.proposals) { + const existingProposal = await tx.get( + Stores.proposals, + backupProposal.proposal_id, + ); + if (!existingProposal) { + let download: ProposalDownload | undefined; + let proposalStatus: ProposalStatus; + switch (backupProposal.proposal_status) { + case BackupProposalStatus.Proposed: + if (backupProposal.contract_terms_raw) { + proposalStatus = ProposalStatus.PROPOSED; + } else { + proposalStatus = ProposalStatus.DOWNLOADING; + } + break; + case BackupProposalStatus.Refused: + proposalStatus = ProposalStatus.REFUSED; + break; + case BackupProposalStatus.Repurchase: + proposalStatus = ProposalStatus.REPURCHASE; + break; + case BackupProposalStatus.PermanentlyFailed: + proposalStatus = ProposalStatus.PERMANENTLY_FAILED; + break; + } + if (backupProposal.contract_terms_raw) { + checkDbInvariant(!!backupProposal.merchant_sig); + const parsedContractTerms = codecForContractTerms().decode( + backupProposal.contract_terms_raw, + ); + const amount = Amounts.parseOrThrow(parsedContractTerms.amount); + const contractTermsHash = + cryptoComp.proposalIdToContractTermsHash[ + backupProposal.proposal_id + ]; + let maxWireFee: AmountJson; + if (parsedContractTerms.max_wire_fee) { + maxWireFee = Amounts.parseOrThrow( + parsedContractTerms.max_wire_fee, + ); + } else { + maxWireFee = Amounts.getZero(amount.currency); + } + download = { + contractData: { + amount, + contractTermsHash: contractTermsHash, + fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", + merchantBaseUrl: parsedContractTerms.merchant_base_url, + merchantPub: parsedContractTerms.merchant_pub, + merchantSig: backupProposal.merchant_sig, + orderId: parsedContractTerms.order_id, + summary: parsedContractTerms.summary, + autoRefund: parsedContractTerms.auto_refund, + maxWireFee, + payDeadline: parsedContractTerms.pay_deadline, + refundDeadline: parsedContractTerms.refund_deadline, + wireFeeAmortization: + parsedContractTerms.wire_fee_amortization || 1, + allowedAuditors: parsedContractTerms.auditors.map((x) => ({ + auditorBaseUrl: x.url, + auditorPub: x.auditor_pub, + })), + allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + })), + timestamp: parsedContractTerms.timestamp, + wireMethod: parsedContractTerms.wire_method, + wireInfoHash: parsedContractTerms.h_wire, + maxDepositFee: Amounts.parseOrThrow( + parsedContractTerms.max_fee, + ), + merchant: parsedContractTerms.merchant, + products: parsedContractTerms.products, + summaryI18n: parsedContractTerms.summary_i18n, + }, + contractTermsRaw: backupProposal.contract_terms_raw, + }; + } + await tx.put(Stores.proposals, { + claimToken: backupProposal.claim_token, + lastError: undefined, + merchantBaseUrl: backupProposal.merchant_base_url, + timestamp: backupProposal.timestamp, + orderId: backupProposal.order_id, + noncePriv: backupProposal.nonce_priv, + noncePub: + cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv], + proposalId: backupProposal.proposal_id, + repurchaseProposalId: backupProposal.repurchase_proposal_id, + retryInfo: initRetryInfo(false), + download, + proposalStatus, + }); + } + } + + for (const backupPurchase of backupBlob.purchases) { + const existingPurchase = await tx.get( + Stores.purchases, + backupPurchase.proposal_id, + ); + if (!existingPurchase) { + const refunds: { [refundKey: string]: WalletRefundItem } = {}; + for (const backupRefund of backupPurchase.refunds) { + const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; + const coin = await tx.get(Stores.coins, backupRefund.coin_pub); + checkBackupInvariant(!!coin); + const denom = await tx.get(Stores.denominations, [ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + checkBackupInvariant(!!denom); + const common = { + coinPub: backupRefund.coin_pub, + executionTime: backupRefund.execution_time, + obtainedTime: backupRefund.obtained_time, + refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount), + refundFee: denom.feeRefund, + rtransactionId: backupRefund.rtransaction_id, + totalRefreshCostBound: Amounts.parseOrThrow( + backupRefund.total_refresh_cost_bound, + ), + }; + switch (backupRefund.type) { + case BackupRefundState.Applied: + refunds[key] = { + type: RefundState.Applied, + ...common, + }; + break; + case BackupRefundState.Failed: + refunds[key] = { + type: RefundState.Failed, + ...common, + }; + break; + case BackupRefundState.Pending: + refunds[key] = { + type: RefundState.Pending, + ...common, + }; + break; + } + } + let abortStatus: AbortStatus; + switch (backupPurchase.abort_status) { + case "abort-finished": + abortStatus = AbortStatus.AbortFinished; + break; + case "abort-refund": + abortStatus = AbortStatus.AbortRefund; + break; + case undefined: + abortStatus = AbortStatus.None; + break; + default: + logger.warn( + `got backup purchase abort_status ${j2s( + backupPurchase.abort_status, + )}`, + ); + throw Error("not reachable"); + } + const parsedContractTerms = codecForContractTerms().decode( + backupPurchase.contract_terms_raw, + ); + const amount = Amounts.parseOrThrow(parsedContractTerms.amount); + const contractTermsHash = + cryptoComp.proposalIdToContractTermsHash[ + backupPurchase.proposal_id + ]; + let maxWireFee: AmountJson; + if (parsedContractTerms.max_wire_fee) { + maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); + } else { + maxWireFee = Amounts.getZero(amount.currency); + } + const download: ProposalDownload = { + contractData: { + amount, + contractTermsHash: contractTermsHash, + fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", + merchantBaseUrl: parsedContractTerms.merchant_base_url, + merchantPub: parsedContractTerms.merchant_pub, + merchantSig: backupPurchase.merchant_sig, + orderId: parsedContractTerms.order_id, + summary: parsedContractTerms.summary, + autoRefund: parsedContractTerms.auto_refund, + maxWireFee, + payDeadline: parsedContractTerms.pay_deadline, + refundDeadline: parsedContractTerms.refund_deadline, + wireFeeAmortization: + parsedContractTerms.wire_fee_amortization || 1, + allowedAuditors: parsedContractTerms.auditors.map((x) => ({ + auditorBaseUrl: x.url, + auditorPub: x.auditor_pub, + })), + allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + })), + timestamp: parsedContractTerms.timestamp, + wireMethod: parsedContractTerms.wire_method, + wireInfoHash: parsedContractTerms.h_wire, + maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), + merchant: parsedContractTerms.merchant, + products: parsedContractTerms.products, + summaryI18n: parsedContractTerms.summary_i18n, + }, + contractTermsRaw: backupPurchase.contract_terms_raw, + }; + await tx.put(Stores.purchases, { + proposalId: backupPurchase.proposal_id, + noncePriv: backupPurchase.nonce_priv, + noncePub: + cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], + lastPayError: undefined, + autoRefundDeadline: { t_ms: "never" }, + refundStatusRetryInfo: initRetryInfo(false), + lastRefundStatusError: undefined, + timestampAccept: backupPurchase.timestamp_accept, + timestampFirstSuccessfulPay: + backupPurchase.timestamp_first_successful_pay, + timestampLastRefundStatus: undefined, + merchantPaySig: backupPurchase.merchant_pay_sig, + lastSessionId: undefined, + abortStatus, + // FIXME! + payRetryInfo: initRetryInfo(false), + download, + paymentSubmitPending: !backupPurchase.timestamp_first_successful_pay, + refundQueryRequested: false, + payCoinSelection: await recoverPayCoinSelection( + tx, + download.contractData, + backupPurchase, + ), + coinDepositPermissions: undefined, + totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost), + refunds, + }); + } + } + + for (const backupRefreshGroup of backupBlob.refresh_groups) { + const existingRg = await tx.get( + Stores.refreshGroups, + backupRefreshGroup.refresh_group_id, + ); + if (!existingRg) { + let reason: RefreshReason; + switch (backupRefreshGroup.reason) { + case BackupRefreshReason.AbortPay: + reason = RefreshReason.AbortPay; + break; + case BackupRefreshReason.BackupRestored: + reason = RefreshReason.BackupRestored; + break; + case BackupRefreshReason.Manual: + reason = RefreshReason.Manual; + break; + case BackupRefreshReason.Pay: + reason = RefreshReason.Pay; + break; + case BackupRefreshReason.Recoup: + reason = RefreshReason.Recoup; + break; + case BackupRefreshReason.Refund: + reason = RefreshReason.Refund; + break; + case BackupRefreshReason.Scheduled: + reason = RefreshReason.Scheduled; + break; + } + const refreshSessionPerCoin: ( + | RefreshSessionRecord + | undefined + )[] = []; + for (const oldCoin of backupRefreshGroup.old_coins) { + const c = await tx.get(Stores.coins, oldCoin.coin_pub); + checkBackupInvariant(!!c); + if (oldCoin.refresh_session) { + const denomSel = await getDenomSelStateFromBackup( + tx, + c.exchangeBaseUrl, + oldCoin.refresh_session.new_denoms, + ); + refreshSessionPerCoin.push({ + sessionSecretSeed: oldCoin.refresh_session.session_secret_seed, + norevealIndex: oldCoin.refresh_session.noreveal_index, + newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({ + count: x.count, + denomPubHash: x.denom_pub_hash, + })), + amountRefreshOutput: denomSel.totalCoinValue, + }); + } else { + refreshSessionPerCoin.push(undefined); + } + } + await tx.put(Stores.refreshGroups, { + timestampFinished: backupRefreshGroup.timestamp_finish, + timestampCreated: backupRefreshGroup.timestamp_created, + refreshGroupId: backupRefreshGroup.refresh_group_id, + reason, + lastError: undefined, + lastErrorPerCoin: {}, + oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub), + finishedPerCoin: backupRefreshGroup.old_coins.map( + (x) => x.finished, + ), + inputPerCoin: backupRefreshGroup.old_coins.map((x) => + Amounts.parseOrThrow(x.input_amount), + ), + estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) => + Amounts.parseOrThrow(x.estimated_output_amount), + ), + refreshSessionPerCoin, + retryInfo: initRetryInfo(false), + }); + } + } + + for (const backupTip of backupBlob.tips) { + const existingTip = await tx.get(Stores.tips, backupTip.wallet_tip_id); + if (!existingTip) { + const denomsSel = await getDenomSelStateFromBackup( + tx, + backupTip.exchange_base_url, + backupTip.selected_denoms, + ); + await tx.put(Stores.tips, { + acceptedTimestamp: backupTip.timestamp_accepted, + createdTimestamp: backupTip.timestamp_created, + denomsSel, + exchangeBaseUrl: backupTip.exchange_base_url, + lastError: undefined, + merchantBaseUrl: backupTip.exchange_base_url, + merchantTipId: backupTip.merchant_tip_id, + pickedUpTimestamp: backupTip.timestamp_finished, + retryInfo: initRetryInfo(false), + secretSeed: backupTip.secret_seed, + tipAmountEffective: denomsSel.totalCoinValue, + tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw), + tipExpiration: backupTip.timestamp_expiration, + walletTipId: backupTip.wallet_tip_id, + }); + } + } + }, + ); +} diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts new file mode 100644 index 000000000..fd0274219 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -0,0 +1,650 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Implementation of wallet backups (export/import/upload) and sync + * server management. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * Imports. + */ +import { InternalWalletState } from "../state"; +import { WalletBackupContentV1 } from "../../types/backupTypes"; +import { TransactionHandle } from "../../util/query"; +import { ConfigRecord, Stores } from "../../types/dbTypes"; +import { checkDbInvariant, checkLogicInvariant } from "../../util/invariants"; +import { codecForAmountString } from "../../util/amounts"; +import { + bytesToString, + decodeCrock, + eddsaGetPublic, + EddsaKeyPair, + encodeCrock, + hash, + rsaBlind, + stringToBytes, +} from "../../crypto/talerCrypto"; +import { canonicalizeBaseUrl, canonicalJson, j2s } from "../../util/helpers"; +import { getTimestampNow, Timestamp } from "../../util/time"; +import { URL } from "../../util/url"; +import { AmountString } from "../../types/talerTypes"; +import { + buildCodecForObject, + Codec, + codecForBoolean, + codecForNumber, + codecForString, + codecOptional, +} from "../../util/codec"; +import { + HttpResponseStatus, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "../../util/http"; +import { Logger } from "../../util/logging"; +import { gunzipSync, gzipSync } from "fflate"; +import { kdf } from "../../crypto/primitives/kdf"; +import { initRetryInfo } from "../../util/retries"; +import { + ConfirmPayResultType, + PreparePayResultType, + RecoveryLoadRequest, + RecoveryMergeStrategy, + TalerErrorDetails, +} from "../../types/walletTypes"; +import { CryptoApi } from "../../crypto/workers/cryptoApi"; +import { secretbox, secretbox_open } from "../../crypto/primitives/nacl-fast"; +import { confirmPay, preparePayForUri } from "../pay"; +import { exportBackup } from "./export"; +import { BackupCryptoPrecomputedData, importBackup } from "./import"; +import { + provideBackupState, + WALLET_BACKUP_STATE_KEY, + getWalletBackupState, + WalletBackupConfState, +} from "./state"; + +const logger = new Logger("operations/backup.ts"); + +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); + return concatArrays(chunks); +} + +/** + * Compute cryptographic values for a backup blob. + * + * FIXME: Take data that we already know from the DB. + * FIXME: Move computations into crypto worker. + */ +async function computeBackupCryptoData( + cryptoApi: CryptoApi, + backupContent: WalletBackupContentV1, +): Promise<BackupCryptoPrecomputedData> { + const cryptoData: BackupCryptoPrecomputedData = { + coinPrivToCompletedCoin: {}, + denomPubToHash: {}, + proposalIdToContractTermsHash: {}, + proposalNoncePrivToPub: {}, + reservePrivToPub: {}, + }; + for (const backupExchange of backupContent.exchanges) { + for (const backupDenom of backupExchange.denominations) { + for (const backupCoin of backupDenom.coins) { + const coinPub = encodeCrock( + eddsaGetPublic(decodeCrock(backupCoin.coin_priv)), + ); + const blindedCoin = rsaBlind( + hash(decodeCrock(backupCoin.coin_priv)), + decodeCrock(backupCoin.blinding_key), + decodeCrock(backupDenom.denom_pub), + ); + cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = { + coinEvHash: encodeCrock(hash(blindedCoin)), + coinPub, + }; + } + cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock( + hash(decodeCrock(backupDenom.denom_pub)), + ); + } + for (const backupReserve of backupExchange.reserves) { + cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( + eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)), + ); + } + } + for (const prop of backupContent.proposals) { + const contractTermsHash = await cryptoApi.hashString( + canonicalJson(prop.contract_terms_raw), + ); + const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv))); + cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub; + cryptoData.proposalIdToContractTermsHash[ + prop.proposal_id + ] = contractTermsHash; + } + for (const purch of backupContent.purchases) { + const contractTermsHash = await cryptoApi.hashString( + canonicalJson(purch.contract_terms_raw), + ); + const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv))); + cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub; + cryptoData.proposalIdToContractTermsHash[ + purch.proposal_id + ] = contractTermsHash; + } + return cryptoData; +} + +function deriveAccountKeyPair( + bc: WalletBackupConfState, + providerUrl: string, +): EddsaKeyPair { + const privateKey = kdf( + 32, + decodeCrock(bc.walletRootPriv), + stringToBytes("taler-sync-account-key-salt"), + stringToBytes(providerUrl), + ); + return { + eddsaPriv: privateKey, + eddsaPub: eddsaGetPublic(privateKey), + }; +} + +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. + * Stop if this step succeeds. + * 2. Download, verify and import backups from connected sync accounts. + * 3. Upload the updated backup blob. + */ +export async function runBackupCycle(ws: InternalWalletState): Promise<void> { + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + logger.trace("got backup providers", providers); + const backupJson = await exportBackup(ws); + const backupConfig = await provideBackupState(ws); + const encBackup = await encryptBackup(backupConfig, backupJson); + + const currentBackupHash = hash(encBackup); + + for (const provider of providers) { + const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl); + logger.trace(`trying to upload backup to ${provider.baseUrl}`); + + const syncSig = await ws.cryptoApi.makeSyncSignature({ + newHash: encodeCrock(currentBackupHash), + oldHash: provider.lastBackupHash, + accountPriv: encodeCrock(accountKeyPair.eddsaPriv), + }); + + logger.trace(`sync signature is ${syncSig}`); + + const accountBackupUrl = new URL( + `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`, + provider.baseUrl, + ); + + const resp = await ws.http.fetch(accountBackupUrl.href, { + method: "POST", + body: encBackup, + headers: { + "content-type": "application/octet-stream", + "sync-signature": syncSig, + "if-none-match": encodeCrock(currentBackupHash), + ...(provider.lastBackupHash + ? { + "if-match": provider.lastBackupHash, + } + : {}), + }, + }); + + logger.trace(`sync response status: ${resp.status}`); + + if (resp.status === HttpResponseStatus.PaymentRequired) { + logger.trace("payment required for backup"); + logger.trace(`headers: ${j2s(resp.headers)}`); + const talerUri = resp.headers.get("taler"); + if (!talerUri) { + throw Error("no taler URI available to pay provider"); + } + const res = await preparePayForUri(ws, talerUri); + let proposalId: string | undefined; + switch (res.status) { + case PreparePayResultType.InsufficientBalance: + // FIXME: record in provider state! + logger.warn("insufficient balance to pay for backup provider"); + break; + case PreparePayResultType.PaymentPossible: + case PreparePayResultType.AlreadyConfirmed: + proposalId = res.proposalId; + break; + } + if (!proposalId) { + continue; + } + const p = proposalId; + await ws.db.runWithWriteTransaction( + [Stores.backupProviders], + async (tx) => { + const provRec = await tx.get( + Stores.backupProviders, + provider.baseUrl, + ); + checkDbInvariant(!!provRec); + const ids = new Set(provRec.paymentProposalIds); + ids.add(p); + provRec.paymentProposalIds = Array.from(ids); + await tx.put(Stores.backupProviders, provRec); + }, + ); + const confirmRes = await confirmPay(ws, proposalId); + switch (confirmRes.type) { + case ConfirmPayResultType.Pending: + logger.warn("payment not yet finished yet"); + break; + } + } + if (resp.status === HttpResponseStatus.NoContent) { + await ws.db.runWithWriteTransaction( + [Stores.backupProviders], + async (tx) => { + const prov = await tx.get(Stores.backupProviders, provider.baseUrl); + if (!prov) { + return; + } + prov.lastBackupHash = encodeCrock(currentBackupHash); + prov.lastBackupTimestamp = getTimestampNow(); + prov.lastBackupClock = + backupJson.clocks[backupJson.current_device_id]; + prov.lastError = undefined; + await tx.put(Stores.backupProviders, prov); + }, + ); + continue; + } + if (resp.status === HttpResponseStatus.Conflict) { + logger.info("conflicting backup found"); + const backupEnc = new Uint8Array(await resp.bytes()); + const backupConfig = await provideBackupState(ws); + const blob = await decryptBackup(backupConfig, backupEnc); + const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); + await importBackup(ws, blob, cryptoData); + await ws.db.runWithWriteTransaction( + [Stores.backupProviders], + async (tx) => { + const prov = await tx.get(Stores.backupProviders, provider.baseUrl); + if (!prov) { + return; + } + prov.lastBackupHash = encodeCrock(hash(backupEnc)); + prov.lastBackupClock = blob.clocks[blob.current_device_id]; + prov.lastBackupTimestamp = getTimestampNow(); + prov.lastError = undefined; + await tx.put(Stores.backupProviders, prov); + }, + ); + logger.info("processed existing backup"); + continue; + } + + // Some other response that we did not expect! + + logger.error("parsing error response"); + + const err = await readTalerErrorResponse(resp); + logger.error(`got error response from backup provider: ${j2s(err)}`); + await ws.db.runWithWriteTransaction( + [Stores.backupProviders], + async (tx) => { + const prov = await tx.get(Stores.backupProviders, provider.baseUrl); + if (!prov) { + return; + } + prov.lastError = err; + }, + ); + } +} + +interface SyncTermsOfServiceResponse { + // maximum backup size supported + storage_limit_in_megabytes: number; + + // Fee for an account, per year. + annual_fee: AmountString; + + // protocol version supported by the server, + // for now always "0.0". + version: string; +} + +const codecForSyncTermsOfServiceResponse = (): Codec<SyncTermsOfServiceResponse> => + buildCodecForObject<SyncTermsOfServiceResponse>() + .property("storage_limit_in_megabytes", codecForNumber()) + .property("annual_fee", codecForAmountString()) + .property("version", codecForString()) + .build("SyncTermsOfServiceResponse"); + +export interface AddBackupProviderRequest { + backupProviderBaseUrl: string; + /** + * Activate the provider. Should only be done after + * the user has reviewed the provider. + */ + activate?: boolean; +} + +export const codecForAddBackupProviderRequest = (): Codec<AddBackupProviderRequest> => + buildCodecForObject<AddBackupProviderRequest>() + .property("backupProviderBaseUrl", codecForString()) + .property("activate", codecOptional(codecForBoolean())) + .build("AddBackupProviderRequest"); + +export async function addBackupProvider( + ws: InternalWalletState, + req: AddBackupProviderRequest, +): Promise<void> { + logger.info(`adding backup provider ${j2s(req)}`); + await provideBackupState(ws); + const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); + const oldProv = await ws.db.get(Stores.backupProviders, canonUrl); + if (oldProv) { + logger.info("old backup provider found"); + if (req.activate) { + oldProv.active = true; + logger.info("setting existing backup provider to active"); + await ws.db.put(Stores.backupProviders, oldProv); + } + return; + } + const termsUrl = new URL("terms", canonUrl); + const resp = await ws.http.get(termsUrl.href); + const terms = await readSuccessResponseJsonOrThrow( + resp, + codecForSyncTermsOfServiceResponse(), + ); + await ws.db.put(Stores.backupProviders, { + active: !!req.activate, + terms: { + annualFee: terms.annual_fee, + storageLimitInMegabytes: terms.storage_limit_in_megabytes, + supportedProtocolVersion: terms.version, + }, + paymentProposalIds: [], + baseUrl: canonUrl, + lastError: undefined, + retryInfo: initRetryInfo(false), + }); +} + +export async function removeBackupProvider( + syncProviderBaseUrl: string, +): Promise<void> {} + +export async function restoreFromRecoverySecret(): Promise<void> {} + +/** + * Information about one provider. + * + * We don't store the account key here, + * as that's derived from the wallet root key. + */ +export interface ProviderInfo { + active: boolean; + syncProviderBaseUrl: string; + lastError?: TalerErrorDetails; + lastRemoteClock?: number; + lastBackupTimestamp?: Timestamp; + paymentProposalIds: string[]; +} + +export interface BackupInfo { + walletRootPub: string; + deviceId: string; + lastLocalClock: number; + providers: ProviderInfo[]; +} + +export async function importBackupPlain( + ws: InternalWalletState, + blob: any, +): Promise<void> { + // FIXME: parse + const backup: WalletBackupContentV1 = blob; + + const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup); + + await importBackup(ws, blob, cryptoData); +} + +/** + * Get information about the current state of wallet backups. + */ +export async function getBackupInfo( + ws: InternalWalletState, +): Promise<BackupInfo> { + const backupConfig = await provideBackupState(ws); + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + return { + deviceId: backupConfig.deviceId, + lastLocalClock: backupConfig.clocks[backupConfig.deviceId], + walletRootPub: backupConfig.walletRootPub, + providers: providers.map((x) => ({ + active: x.active, + lastRemoteClock: x.lastBackupClock, + syncProviderBaseUrl: x.baseUrl, + lastBackupTimestamp: x.lastBackupTimestamp, + paymentProposalIds: x.paymentProposalIds, + lastError: x.lastError, + })), + }; +} + +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, + }; +} + +async function backupRecoveryTheirs( + ws: InternalWalletState, + br: BackupRecovery, +) { + await ws.db.runWithWriteTransaction( + [Stores.config, Stores.backupProviders], + async (tx) => { + let backupStateEntry: + | ConfigRecord<WalletBackupConfState> + | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + checkDbInvariant(!!backupStateEntry); + backupStateEntry.value.lastBackupNonce = undefined; + backupStateEntry.value.lastBackupTimestamp = undefined; + backupStateEntry.value.lastBackupCheckTimestamp = undefined; + backupStateEntry.value.lastBackupPlainHash = undefined; + backupStateEntry.value.walletRootPriv = br.walletRootPriv; + backupStateEntry.value.walletRootPub = encodeCrock( + eddsaGetPublic(decodeCrock(br.walletRootPriv)), + ); + await tx.put(Stores.config, backupStateEntry); + for (const prov of br.providers) { + const existingProv = await tx.get(Stores.backupProviders, prov.url); + if (!existingProv) { + await tx.put(Stores.backupProviders, { + active: true, + baseUrl: prov.url, + paymentProposalIds: [], + retryInfo: initRetryInfo(false), + lastError: undefined, + }); + } + } + const providers = await tx.iter(Stores.backupProviders).toArray(); + for (const prov of providers) { + prov.lastBackupTimestamp = undefined; + prov.lastBackupHash = undefined; + prov.lastBackupClock = undefined; + await tx.put(Stores.backupProviders, prov); + } + }, + ); +} + +async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) { + throw Error("not implemented"); +} + +export async function loadBackupRecovery( + ws: InternalWalletState, + br: RecoveryLoadRequest, +): Promise<void> { + const bs = await provideBackupState(ws); + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + let strategy = br.strategy; + if ( + br.recovery.walletRootPriv != bs.walletRootPriv && + providers.length > 0 && + !strategy + ) { + throw Error( + "recovery load strategy must be specified for wallet with existing providers", + ); + } else if (!strategy) { + // Default to using the new key if we don't have providers yet. + strategy = RecoveryMergeStrategy.Theirs; + } + if (strategy === RecoveryMergeStrategy.Theirs) { + return backupRecoveryTheirs(ws, br.recovery); + } else { + return backupRecoveryOurs(ws, br.recovery); + } +} + +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 decryptBackup( + backupConfig: WalletBackupConfState, + data: Uint8Array, +): Promise<WalletBackupContentV1> { + 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"); + } + return JSON.parse(bytesToString(gunzipSync(dataCompressed))); +} + +export async function importBackupEncrypted( + ws: InternalWalletState, + data: Uint8Array, +): Promise<void> { + const backupConfig = await provideBackupState(ws); + const blob = await decryptBackup(backupConfig, data); + const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); + await importBackup(ws, blob, cryptoData); +} diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/operations/backup/state.ts new file mode 100644 index 000000000..29c9402c7 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/backup/state.ts @@ -0,0 +1,101 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + ConfigRecord, + encodeCrock, + getRandomBytes, + Stores, + Timestamp, + TransactionHandle, +} from "../.."; +import { checkDbInvariant } from "../../util/invariants"; +import { InternalWalletState } from "../state"; + +export interface WalletBackupConfState { + deviceId: string; + walletRootPub: string; + walletRootPriv: string; + clocks: { [device_id: string]: number }; + + /** + * Last hash of the canonicalized plain-text backup. + * + * Used to determine whether the wallet's content changed + * and we need to bump the clock. + */ + lastBackupPlainHash?: string; + + /** + * Timestamp stored in the last backup. + */ + lastBackupTimestamp?: Timestamp; + + /** + * Last time we tried to do a backup. + */ + lastBackupCheckTimestamp?: Timestamp; + lastBackupNonce?: string; +} + +export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; + +export async function provideBackupState( + ws: InternalWalletState, +): Promise<WalletBackupConfState> { + const bs: ConfigRecord<WalletBackupConfState> | undefined = await ws.db.get( + Stores.config, + WALLET_BACKUP_STATE_KEY, + ); + if (bs) { + return bs.value; + } + // We need to generate the key outside of the transaction + // due to how IndexedDB works. + const k = await ws.cryptoApi.createEddsaKeypair(); + const d = getRandomBytes(5); + // FIXME: device ID should be configured when wallet is initialized + // and be based on hostname + const deviceId = `wallet-core-${encodeCrock(d)}`; + return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => { + let backupStateEntry: + | ConfigRecord<WalletBackupConfState> + | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + if (!backupStateEntry) { + backupStateEntry = { + key: WALLET_BACKUP_STATE_KEY, + value: { + deviceId, + clocks: { [deviceId]: 1 }, + walletRootPub: k.pub, + walletRootPriv: k.priv, + lastBackupPlainHash: undefined, + }, + }; + await tx.put(Stores.config, backupStateEntry); + } + return backupStateEntry.value; + }); +} + +export async function getWalletBackupState( + ws: InternalWalletState, + tx: TransactionHandle<typeof Stores.config>, +): Promise<WalletBackupConfState> { + let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + checkDbInvariant(!!bs, "wallet backup state should be in DB"); + return bs.value; +} diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index 43a0ab16c..73f08d407 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -36,6 +36,7 @@ import { timestampMin, timestampMax, } from "./time"; +import { TalerErrorDetails } from ".."; const logger = new Logger("http.ts"); @@ -134,29 +135,35 @@ type ResponseOrError<T> = | { isError: false; response: T } | { isError: true; talerErrorResponse: TalerErrorResponse }; +export async function readTalerErrorResponse( + httpResponse: HttpResponse, +): Promise<TalerErrorDetails> { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw new OperationFailedError( + makeErrorDetails( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Error response did not contain error code", + { + requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, + }, + ), + ); + } + return errJson; +} + export async function readSuccessResponseJsonOrErrorCode<T>( httpResponse: HttpResponse, codec: Codec<T>, ): Promise<ResponseOrError<T>> { if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { - const errJson = await httpResponse.json(); - const talerErrorCode = errJson.code; - if (typeof talerErrorCode !== "number") { - throw new OperationFailedError( - makeErrorDetails( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - "Error response did not contain error code", - { - requestUrl: httpResponse.requestUrl, - requestMethod: httpResponse.requestMethod, - httpStatusCode: httpResponse.status, - }, - ), - ); - } return { isError: true, - talerErrorResponse: errJson, + talerErrorResponse: await readTalerErrorResponse(httpResponse), }; } const respJson = await httpResponse.json(); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 8f9999cc1..dc320b178 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -30,7 +30,6 @@ import { BackupInfo, BackupRecovery, codecForAddBackupProviderRequest, - exportBackup, exportBackupEncrypted, getBackupInfo, getBackupRecovery, @@ -39,6 +38,7 @@ import { loadBackupRecovery, runBackupCycle, } from "./operations/backup"; +import { exportBackup } from "./operations/backup/export"; import { getBalances } from "./operations/balance"; import { createDepositGroup, |