/* 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 */ /** * Implementation of wallet backups (export/import/upload) and sync * server management. * * @author Florian Dold */ /** * 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 { const bs: ConfigRecord | 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 | 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, ): Promise { 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 { 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_start: 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] ?? [], }; 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, })), timestam_picked_up: 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_started: recoupGroup.timestampStarted, timestamp_finished: 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, 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(); 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({ clock_created: 1, 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, timestamp_last_refund_status: purch.timestampLastRefundStatus, 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_started: rg.timestampCreated, timestamp_finished: 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: {}, }; // 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 { 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; coinPrivToCompletedCoin: Record; proposalNoncePrivToPub: { [priv: string]: string }; proposalIdToContractTermsHash: { [proposalId: string]: string }; reservePrivToPub: Record; } /** * 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 { 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 { 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 = 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, exchangeBaseUrl: string, sel: BackupDenomSel, ): Promise { 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 { 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, 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_start, 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.master_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.master_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: backupPurchase.timestamp_last_refund_status, 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_finished, timestampCreated: backupRefreshGroup.timestamp_started, 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.timestam_picked_up, 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 { 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() .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() .property("backupProviderBaseUrl", codecForString()) .build("AddBackupProviderRequest"); export async function addBackupProvider( ws: InternalWalletState, req: AddBackupProviderRequest, ): Promise { 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, }); } export async function removeBackupProvider( syncProviderBaseUrl: string, ): Promise {} export async function restoreFromRecoverySecret(): Promise {} /** * 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 { // 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 { 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 { 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 | 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: [], }); } } 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 { 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 { 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 { 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 { const backupConfig = await provideBackupState(ws); const blob = await decryptBackup(backupConfig, data); const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob); await importBackup(ws, blob, cryptoData); }