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 { ApplyRefundResponse } from "@gnu-taler/taler-wallet-core";
|
||||||
import { PendingOperationsResponse } from "@gnu-taler/taler-wallet-core";
|
import { PendingOperationsResponse } from "@gnu-taler/taler-wallet-core";
|
||||||
import { CoinConfig } from "./denomStructures";
|
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);
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
|
||||||
@ -1474,7 +1477,9 @@ export class MerchantService implements MerchantServiceInterface {
|
|||||||
config.write(this.configFilename);
|
config.write(this.configFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addInstance(instanceConfig: PartialMerchantInstanceConfig): Promise<void> {
|
async addInstance(
|
||||||
|
instanceConfig: PartialMerchantInstanceConfig,
|
||||||
|
): Promise<void> {
|
||||||
if (!this.proc) {
|
if (!this.proc) {
|
||||||
throw Error("merchant must be running to add instance");
|
throw Error("merchant must be running to add instance");
|
||||||
}
|
}
|
||||||
@ -1881,4 +1886,12 @@ export class WalletCli {
|
|||||||
}
|
}
|
||||||
throw new OperationFailedError(resp.error);
|
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({
|
await wallet.addBackupProvider({
|
||||||
backupProviderBaseUrl: sync.baseUrl,
|
backupProviderBaseUrl: sync.baseUrl,
|
||||||
activate: false,
|
activate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
const bi = await wallet.getBackupInfo();
|
const bi = await wallet.getBackupInfo();
|
||||||
t.assertDeepEqual(bi.providers[0].active, true);
|
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,
|
timestampMin,
|
||||||
timestampMax,
|
timestampMax,
|
||||||
} from "./time";
|
} from "./time";
|
||||||
|
import { TalerErrorDetails } from "..";
|
||||||
|
|
||||||
const logger = new Logger("http.ts");
|
const logger = new Logger("http.ts");
|
||||||
|
|
||||||
@ -134,29 +135,35 @@ type ResponseOrError<T> =
|
|||||||
| { isError: false; response: T }
|
| { isError: false; response: T }
|
||||||
| { isError: true; talerErrorResponse: TalerErrorResponse };
|
| { 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>(
|
export async function readSuccessResponseJsonOrErrorCode<T>(
|
||||||
httpResponse: HttpResponse,
|
httpResponse: HttpResponse,
|
||||||
codec: Codec<T>,
|
codec: Codec<T>,
|
||||||
): Promise<ResponseOrError<T>> {
|
): Promise<ResponseOrError<T>> {
|
||||||
if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
|
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 {
|
return {
|
||||||
isError: true,
|
isError: true,
|
||||||
talerErrorResponse: errJson,
|
talerErrorResponse: await readTalerErrorResponse(httpResponse),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const respJson = await httpResponse.json();
|
const respJson = await httpResponse.json();
|
||||||
|
@ -30,7 +30,6 @@ import {
|
|||||||
BackupInfo,
|
BackupInfo,
|
||||||
BackupRecovery,
|
BackupRecovery,
|
||||||
codecForAddBackupProviderRequest,
|
codecForAddBackupProviderRequest,
|
||||||
exportBackup,
|
|
||||||
exportBackupEncrypted,
|
exportBackupEncrypted,
|
||||||
getBackupInfo,
|
getBackupInfo,
|
||||||
getBackupRecovery,
|
getBackupRecovery,
|
||||||
@ -39,6 +38,7 @@ import {
|
|||||||
loadBackupRecovery,
|
loadBackupRecovery,
|
||||||
runBackupCycle,
|
runBackupCycle,
|
||||||
} from "./operations/backup";
|
} from "./operations/backup";
|
||||||
|
import { exportBackup } from "./operations/backup/export";
|
||||||
import { getBalances } from "./operations/balance";
|
import { getBalances } from "./operations/balance";
|
||||||
import {
|
import {
|
||||||
createDepositGroup,
|
createDepositGroup,
|
||||||
|
Loading…
Reference in New Issue
Block a user