implement import of backup recovery document

This commit is contained in:
Florian Dold 2021-01-08 13:30:29 +01:00
parent 324f44ae69
commit 8921a5e8f2
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 262 additions and 53 deletions

View File

@ -36,6 +36,8 @@ import {
NodeThreadCryptoWorkerFactory, NodeThreadCryptoWorkerFactory,
CryptoApi, CryptoApi,
rsaBlind, rsaBlind,
RecoveryMergeStrategy,
stringToBytes,
} from "taler-wallet-core"; } from "taler-wallet-core";
import * as clk from "./clk"; import * as clk from "./clk";
import { deepStrictEqual } from "assert"; import { deepStrictEqual } from "assert";
@ -453,19 +455,49 @@ backupCli.subcommand("run", "run").action(async (args) => {
}); });
}); });
backupCli.subcommand("status", "status").action(async (args) => {
await withWallet(args, async (wallet) => {
const status = await wallet.getBackupStatus();
console.log(JSON.stringify(status, undefined, 2));
});
});
backupCli backupCli
.subcommand("recoveryLoad", "load-recovery") .subcommand("recoveryLoad", "load-recovery")
.action(async (args) => {}); .maybeOption("strategy", ["--strategy"], clk.STRING, {
help:
backupCli.subcommand("status", "status").action(async (args) => {}); "Strategy for resolving a conflict with the existing wallet key ('theirs' or 'ours')",
})
.action(async (args) => {
await withWallet(args, async (wallet) => {
const data = JSON.parse(await read(process.stdin));
let strategy: RecoveryMergeStrategy | undefined;
const stratStr = args.recoveryLoad.strategy;
if (stratStr) {
if (stratStr === "theirs") {
strategy = RecoveryMergeStrategy.Theirs;
} else if (stratStr === "ours") {
strategy = RecoveryMergeStrategy.Theirs;
} else {
throw Error("invalid recovery strategy");
}
}
await wallet.loadBackupRecovery({
recovery: data,
strategy,
});
});
});
backupCli backupCli
.subcommand("addProvider", "add-provider") .subcommand("addProvider", "add-provider")
.requiredArgument("url", clk.STRING) .requiredArgument("url", clk.STRING)
.flag("activate", ["--activate"])
.action(async (args) => { .action(async (args) => {
await withWallet(args, async (wallet) => { await withWallet(args, async (wallet) => {
wallet.addBackupProvider({ wallet.addBackupProvider({
backupProviderBaseUrl: args.addProvider.url, backupProviderBaseUrl: args.addProvider.url,
activate: args.addProvider.activate,
}); });
}); });
}); });

View File

@ -27,6 +27,7 @@
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { import {
BackupBackupProvider, BackupBackupProvider,
BackupBackupProviderTerms,
BackupCoin, BackupCoin,
BackupCoinSource, BackupCoinSource,
BackupCoinSourceType, BackupCoinSourceType,
@ -52,6 +53,7 @@ import {
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { import {
AbortStatus, AbortStatus,
BackupProviderStatus,
CoinSource, CoinSource,
CoinSourceType, CoinSourceType,
CoinStatus, CoinStatus,
@ -110,6 +112,8 @@ import { initRetryInfo } from "../util/retries";
import { import {
ConfirmPayResultType, ConfirmPayResultType,
PreparePayResultType, PreparePayResultType,
RecoveryLoadRequest,
RecoveryMergeStrategy,
RefreshReason, RefreshReason,
} from "../types/walletTypes"; } from "../types/walletTypes";
import { CryptoApi } from "../crypto/workers/cryptoApi"; import { CryptoApi } from "../crypto/workers/cryptoApi";
@ -303,12 +307,18 @@ export async function exportBackup(
}); });
await tx.iter(Stores.backupProviders).forEach((bp) => { 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({ backupBackupProviders.push({
annual_fee: Amounts.stringify(bp.annualFee), terms,
base_url: canonicalizeBaseUrl(bp.baseUrl), base_url: canonicalizeBaseUrl(bp.baseUrl),
pay_proposal_ids: [], pay_proposal_ids: bp.paymentProposalIds,
storage_limit_in_megabytes: bp.storageLimitInMegabytes,
supported_protocol_version: bp.supportedProtocolVersion,
}); });
}); });
@ -1256,7 +1266,13 @@ export async function importBackup(
case "abort-refund": case "abort-refund":
abortStatus = AbortStatus.AbortRefund; abortStatus = AbortStatus.AbortRefund;
break; break;
case undefined:
abortStatus = AbortStatus.None;
break;
default: default:
logger.warn(
`got backup purchase abort_status ${j2s(backupPurchase.abort_status)}`,
);
throw Error("not reachable"); throw Error("not reachable");
} }
const parsedContractTerms = codecForContractTerms().decode( const parsedContractTerms = codecForContractTerms().decode(
@ -1484,11 +1500,9 @@ function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
*/ */
export async function runBackupCycle(ws: InternalWalletState): Promise<void> { export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
const providers = await ws.db.iter(Stores.backupProviders).toArray(); const providers = await ws.db.iter(Stores.backupProviders).toArray();
const backupConfig = await provideBackupState(ws);
logger.trace("got backup providers", providers); logger.trace("got backup providers", providers);
const backupJson = await exportBackup(ws); const backupJson = await exportBackup(ws);
const backupConfig = await provideBackupState(ws);
const encBackup = await encryptBackup(backupConfig, backupJson); const encBackup = await encryptBackup(backupConfig, backupJson);
const currentBackupHash = hash(encBackup); const currentBackupHash = hash(encBackup);
@ -1549,6 +1563,15 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
if (!proposalId) { if (!proposalId) {
continue; 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); const confirmRes = await confirmPay(ws, proposalId);
switch (confirmRes.type) { switch (confirmRes.type) {
case ConfirmPayResultType.Pending: case ConfirmPayResultType.Pending:
@ -1565,6 +1588,7 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
return; return;
} }
prov.lastBackupHash = encodeCrock(currentBackupHash); prov.lastBackupHash = encodeCrock(currentBackupHash);
prov.lastBackupTimestamp = getTimestampNow();
prov.lastBackupClock = prov.lastBackupClock =
backupJson.clocks[backupJson.current_device_id]; backupJson.clocks[backupJson.current_device_id];
await tx.put(Stores.backupProviders, prov); await tx.put(Stores.backupProviders, prov);
@ -1587,8 +1611,8 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
return; return;
} }
prov.lastBackupHash = encodeCrock(hash(backupEnc)); prov.lastBackupHash = encodeCrock(hash(backupEnc));
prov.lastBackupClock = prov.lastBackupClock = blob.clocks[blob.current_device_id];
blob.clocks[blob.current_device_id]; prov.lastBackupTimestamp = getTimestampNow();
await tx.put(Stores.backupProviders, prov); await tx.put(Stores.backupProviders, prov);
}, },
); );
@ -1620,6 +1644,11 @@ const codecForSyncTermsOfServiceResponse = (): Codec<
export interface AddBackupProviderRequest { export interface AddBackupProviderRequest {
backupProviderBaseUrl: string; backupProviderBaseUrl: string;
/**
* Activate the provider. Should only be done after
* the user has reviewed the provider.
*/
activate?: boolean;
} }
export const codecForAddBackupProviderRequest = (): Codec< export const codecForAddBackupProviderRequest = (): Codec<
@ -1637,6 +1666,10 @@ export async function addBackupProvider(
const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
const oldProv = await ws.db.get(Stores.backupProviders, canonUrl); const oldProv = await ws.db.get(Stores.backupProviders, canonUrl);
if (oldProv) { if (oldProv) {
if (req.activate) {
oldProv.active = true;
await ws.db.put(Stores.backupProviders, oldProv);
}
return; return;
} }
const termsUrl = new URL("terms", canonUrl); const termsUrl = new URL("terms", canonUrl);
@ -1646,11 +1679,14 @@ export async function addBackupProvider(
codecForSyncTermsOfServiceResponse(), codecForSyncTermsOfServiceResponse(),
); );
await ws.db.put(Stores.backupProviders, { await ws.db.put(Stores.backupProviders, {
active: true, active: !!req.activate,
annualFee: terms.annual_fee, terms: {
annualFee: terms.annual_fee,
storageLimitInMegabytes: terms.storage_limit_in_megabytes,
supportedProtocolVersion: terms.version,
},
paymentProposalIds: [],
baseUrl: canonUrl, baseUrl: canonUrl,
storageLimitInMegabytes: terms.storage_limit_in_megabytes,
supportedProtocolVersion: terms.version,
}); });
} }
@ -1667,9 +1703,11 @@ export async function restoreFromRecoverySecret(): Promise<void> {}
* as that's derived from the wallet root key. * as that's derived from the wallet root key.
*/ */
export interface ProviderInfo { export interface ProviderInfo {
active: boolean;
syncProviderBaseUrl: string; syncProviderBaseUrl: string;
lastRemoteClock: number; lastRemoteClock?: number;
lastBackup?: Timestamp; lastBackupTimestamp?: Timestamp;
paymentProposalIds: string[];
} }
export interface BackupInfo { export interface BackupInfo {
@ -1697,7 +1735,20 @@ export async function importBackupPlain(
export async function getBackupInfo( export async function getBackupInfo(
ws: InternalWalletState, ws: InternalWalletState,
): Promise<BackupInfo> { ): Promise<BackupInfo> {
throw Error("not implemented"); const backupConfig = await provideBackupState(ws);
const providers = await ws.db.iter(Stores.backupProviders).toArray();
return {
deviceId: backupConfig.deviceId,
lastLocalClock: backupConfig.clocks[backupConfig.deviceId],
walletRootPub: backupConfig.walletRootPub,
providers: providers.map((x) => ({
active: x.active,
lastRemoteClock: x.lastBackupClock,
syncProviderBaseUrl: x.baseUrl,
lastBackupTimestamp: x.lastBackupTimestamp,
paymentProposalIds: x.paymentProposalIds,
})),
};
} }
export interface BackupRecovery { export interface BackupRecovery {
@ -1727,6 +1778,77 @@ export async function getBackupRecovery(
}; };
} }
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: [],
});
}
}
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( export async function exportBackupEncrypted(
ws: InternalWalletState, ws: InternalWalletState,
): Promise<Uint8Array> { ): Promise<Uint8Array> {

View File

@ -21,27 +21,22 @@
* as the backup schema must remain very stable and should be self-contained. * as the backup schema must remain very stable and should be self-contained.
* *
* Current limitations: * Current limitations:
* 1. Exchange/auditor trust isn't exported yet * 1. "Ghost spends", where a coin is spent unexpectedly by another wallet
* (see https://bugs.gnunet.org/view.php?id=6448)
* 2. Reports to the auditor (cryptographic proofs and/or diagnostics) aren't exported yet
* 3. "Ghost spends", where a coin is spent unexpectedly by another wallet
* and a corresponding transaction (that is missing some details!) should * and a corresponding transaction (that is missing some details!) should
* be added to the transaction history, aren't implemented yet. * be added to the transaction history, aren't implemented yet.
* 4. Clocks for denom/coin selections aren't properly modeled yet. * 2. Clocks for denom/coin selections aren't properly modeled yet.
* (Needed for re-denomination of withdrawal / re-selection of coins) * (Needed for re-denomination of withdrawal / re-selection of coins)
* 5. Preferences about how currencies are to be displayed * 3. Preferences about how currencies are to be displayed
* aren't exported yet (and not even implemented in wallet-core). * aren't exported yet (and not even implemented in wallet-core).
* 6. Returning money to own bank account isn't supported/exported yet. * 4. Returning money to own bank account isn't supported/exported yet.
* 7. Peer-to-peer payments aren't supported yet. * 5. Peer-to-peer payments aren't supported yet.
* 8. Next update time / next refresh time isn't backed up yet. * 6. Next update time / next auto-refresh time isn't backed up yet.
* 9. Coin/denom selections should be forgettable once that information * 7. Coin/denom selections should be forgettable once that information
* becomes irrelevant. * becomes irrelevant.
* 10. Re-denominated payments/refreshes are not shown properly in the total * 8. Re-denominated payments/refreshes are not shown properly in the total
* payment cost. * payment cost.
* 11. Failed refunds do not have any information about why they failed. * 9. Permanently failed operations aren't properly modeled yet
* => This should go into the general "error reports" * 10. Do we somehow need to model the mechanism for first only withdrawing
* 12. Tombstones for removed backup providers
* 13. Do we somehow need to model the mechanism for first only withdrawing
* the amount to pay the backup provider? * the amount to pay the backup provider?
* *
* Questions: * Questions:
@ -299,15 +294,7 @@ export interface BackupTrustExchange {
clock_removed?: ClockValue; clock_removed?: ClockValue;
} }
/** export class BackupBackupProviderTerms {
* Backup information about one backup storage provider.
*/
export class BackupBackupProvider {
/**
* Canonicalized base URL of the provider.
*/
base_url: string;
/** /**
* Last known supported protocol version. * Last known supported protocol version.
*/ */
@ -322,6 +309,22 @@ export class BackupBackupProvider {
* Last known storage limit. * Last known storage limit.
*/ */
storage_limit_in_megabytes: number; storage_limit_in_megabytes: number;
}
/**
* Backup information about one backup storage provider.
*/
export class BackupBackupProvider {
/**
* Canonicalized base URL of the provider.
*/
base_url: string;
/**
* Last known terms. Might be unavailable in some situations, such
* as directly after restoring form a backup recovery document.
*/
terms?: BackupBackupProviderTerms;
/** /**
* Proposal IDs for payments to this provider. * Proposal IDs for payments to this provider.

View File

@ -1426,20 +1426,30 @@ export enum ImportPayloadType {
CoreSchema = "core-schema", CoreSchema = "core-schema",
} }
export enum BackupProviderStatus {
PaymentRequired = "payment-required",
Ready = "ready",
}
export interface BackupProviderRecord { export interface BackupProviderRecord {
baseUrl: string; baseUrl: string;
supportedProtocolVersion: string; /**
* Terms of service of the provider.
annualFee: AmountString; * Might be unavailable in the DB in certain situations
* (such as loading a recovery document).
storageLimitInMegabytes: number; */
terms?: {
supportedProtocolVersion: string;
annualFee: AmountString;
storageLimitInMegabytes: number;
};
active: boolean; active: boolean;
/** /**
* Hash of the last backup that we already * Hash of the last encrypted backup that we already merged
* merged. * or successfully uploaded ourselves.
*/ */
lastBackupHash?: string; lastBackupHash?: string;
@ -1448,6 +1458,12 @@ export interface BackupProviderRecord {
* merged. * merged.
*/ */
lastBackupClock?: number; lastBackupClock?: number;
lastBackupTimestamp?: Timestamp;
currentPaymentProposalId?: string;
paymentProposalIds: string[];
} }
class ExchangesStore extends Store<"exchanges", ExchangeRecord> { class ExchangesStore extends Store<"exchanges", ExchangeRecord> {

View File

@ -56,6 +56,7 @@ import {
ContractTerms, ContractTerms,
} from "./talerTypes"; } from "./talerTypes";
import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes"; import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes";
import { BackupRecovery } from "../operations/backup";
/** /**
* Response for the create reserve request to the wallet. * Response for the create reserve request to the wallet.
@ -896,6 +897,29 @@ export interface MakeSyncSignatureRequest {
newHash: string; newHash: string;
} }
/**
* Strategy for loading recovery information.
*/
export enum RecoveryMergeStrategy {
/**
* Keep the local wallet root key, import and take over providers.
*/
Ours = "ours",
/**
* Migrate to the wallet root key from the recovery information.
*/
Theirs = "theirs",
}
/**
* Load recovery information into the wallet.
*/
export interface RecoveryLoadRequest {
recovery: BackupRecovery;
strategy?: RecoveryMergeStrategy;
}
export const codecForWithdrawTestBalance = (): Codec< export const codecForWithdrawTestBalance = (): Codec<
WithdrawTestBalanceRequest WithdrawTestBalanceRequest
> => > =>

View File

@ -94,6 +94,7 @@ import {
codecForAcceptTipRequest, codecForAcceptTipRequest,
codecForAbortPayWithRefundRequest, codecForAbortPayWithRefundRequest,
ApplyRefundResponse, ApplyRefundResponse,
RecoveryLoadRequest,
} from "./types/walletTypes"; } from "./types/walletTypes";
import { Logger } from "./util/logging"; import { Logger } from "./util/logging";
@ -167,6 +168,9 @@ import {
BackupRecovery, BackupRecovery,
getBackupRecovery, getBackupRecovery,
AddBackupProviderRequest, AddBackupProviderRequest,
getBackupInfo,
BackupInfo,
loadBackupRecovery,
} from "./operations/backup"; } from "./operations/backup";
const builtinCurrencies: CurrencyRecord[] = [ const builtinCurrencies: CurrencyRecord[] = [
@ -959,6 +963,10 @@ export class Wallet {
return getBackupRecovery(this.ws); return getBackupRecovery(this.ws);
} }
async loadBackupRecovery(req: RecoveryLoadRequest): Promise<void> {
return loadBackupRecovery(this.ws, req);
}
async addBackupProvider(req: AddBackupProviderRequest): Promise<void> { async addBackupProvider(req: AddBackupProviderRequest): Promise<void> {
return addBackupProvider(this.ws, req); return addBackupProvider(this.ws, req);
} }
@ -967,6 +975,10 @@ export class Wallet {
return runBackupCycle(this.ws); return runBackupCycle(this.ws);
} }
async getBackupStatus(): Promise<BackupInfo> {
return getBackupInfo(this.ws);
}
/** /**
* Implementation of the "wallet-core" API. * Implementation of the "wallet-core" API.
*/ */