summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-03-10 12:00:30 +0100
committerFlorian Dold <florian@dold.me>2021-03-10 12:00:30 +0100
commitac89c3d277134e49e44d8b0afd4930fd4df934aa (patch)
tree2d2682630e108067d4f5f00946da681e978aa41c /packages/taler-wallet-core/src
parent49b5d006db6639082eea10158e2da7cc13473c21 (diff)
downloadwallet-core-ac89c3d277134e49e44d8b0afd4930fd4df934aa.tar.gz
wallet-core-ac89c3d277134e49e44d8b0afd4930fd4df934aa.tar.bz2
wallet-core-ac89c3d277134e49e44d8b0afd4930fd4df934aa.zip
restructure sync, store errors
Diffstat (limited to 'packages/taler-wallet-core/src')
-rw-r--r--packages/taler-wallet-core/src/operations/backup.ts1907
-rw-r--r--packages/taler-wallet-core/src/operations/backup/export.ts447
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts825
-rw-r--r--packages/taler-wallet-core/src/operations/backup/index.ts650
-rw-r--r--packages/taler-wallet-core/src/operations/backup/state.ts101
-rw-r--r--packages/taler-wallet-core/src/util/http.ts39
-rw-r--r--packages/taler-wallet-core/src/wallet.ts2
7 files changed, 2047 insertions, 1924 deletions
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,