restructure sync, store errors
This commit is contained in:
parent
49b5d006db
commit
ac89c3d277
@ -99,7 +99,10 @@ import {
|
||||
import { ApplyRefundResponse } from "@gnu-taler/taler-wallet-core";
|
||||
import { PendingOperationsResponse } from "@gnu-taler/taler-wallet-core";
|
||||
import { CoinConfig } from "./denomStructures";
|
||||
import { AddBackupProviderRequest, BackupInfo } from "@gnu-taler/taler-wallet-core/src/operations/backup";
|
||||
import {
|
||||
AddBackupProviderRequest,
|
||||
BackupInfo,
|
||||
} from "@gnu-taler/taler-wallet-core/src/operations/backup";
|
||||
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
@ -1474,7 +1477,9 @@ export class MerchantService implements MerchantServiceInterface {
|
||||
config.write(this.configFilename);
|
||||
}
|
||||
|
||||
async addInstance(instanceConfig: PartialMerchantInstanceConfig): Promise<void> {
|
||||
async addInstance(
|
||||
instanceConfig: PartialMerchantInstanceConfig,
|
||||
): Promise<void> {
|
||||
if (!this.proc) {
|
||||
throw Error("merchant must be running to add instance");
|
||||
}
|
||||
@ -1881,4 +1886,12 @@ export class WalletCli {
|
||||
}
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async runBackupCycle(): Promise<void> {
|
||||
const resp = await this.apiRequest("runBackupCycle", {});
|
||||
if (resp.type === "response") {
|
||||
return;
|
||||
}
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
}
|
||||
|
@ -56,11 +56,18 @@ export async function runWalletBackupBasicTest(t: GlobalTestState) {
|
||||
|
||||
await wallet.addBackupProvider({
|
||||
backupProviderBaseUrl: sync.baseUrl,
|
||||
activate: false,
|
||||
activate: true,
|
||||
});
|
||||
|
||||
{
|
||||
const bi = await wallet.getBackupInfo();
|
||||
t.assertDeepEqual(bi.providers[0].active, true);
|
||||
}
|
||||
|
||||
await wallet.runBackupCycle();
|
||||
|
||||
{
|
||||
const bi = await wallet.getBackupInfo();
|
||||
console.log(bi);
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
447
packages/taler-wallet-core/src/operations/backup/export.ts
Normal file
447
packages/taler-wallet-core/src/operations/backup/export.ts
Normal file
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
825
packages/taler-wallet-core/src/operations/backup/import.ts
Normal file
825
packages/taler-wallet-core/src/operations/backup/import.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
650
packages/taler-wallet-core/src/operations/backup/index.ts
Normal file
650
packages/taler-wallet-core/src/operations/backup/index.ts
Normal file
@ -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);
|
||||
}
|
101
packages/taler-wallet-core/src/operations/backup/state.ts
Normal file
101
packages/taler-wallet-core/src/operations/backup/state.ts
Normal file
@ -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;
|
||||
}
|
@ -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();
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user