diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 87e0e00d1..87a51f30d 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -36,6 +36,8 @@ import { NodeThreadCryptoWorkerFactory, CryptoApi, rsaBlind, + RecoveryMergeStrategy, + stringToBytes, } from "taler-wallet-core"; import * as clk from "./clk"; 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 .subcommand("recoveryLoad", "load-recovery") - .action(async (args) => {}); - -backupCli.subcommand("status", "status").action(async (args) => {}); + .maybeOption("strategy", ["--strategy"], clk.STRING, { + help: + "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 .subcommand("addProvider", "add-provider") .requiredArgument("url", clk.STRING) + .flag("activate", ["--activate"]) .action(async (args) => { await withWallet(args, async (wallet) => { wallet.addBackupProvider({ backupProviderBaseUrl: args.addProvider.url, + activate: args.addProvider.activate, }); }); }); diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts index f67d32e50..72fdf7aa1 100644 --- a/packages/taler-wallet-core/src/operations/backup.ts +++ b/packages/taler-wallet-core/src/operations/backup.ts @@ -27,6 +27,7 @@ import { InternalWalletState } from "./state"; import { BackupBackupProvider, + BackupBackupProviderTerms, BackupCoin, BackupCoinSource, BackupCoinSourceType, @@ -52,6 +53,7 @@ import { import { TransactionHandle } from "../util/query"; import { AbortStatus, + BackupProviderStatus, CoinSource, CoinSourceType, CoinStatus, @@ -110,6 +112,8 @@ import { initRetryInfo } from "../util/retries"; import { ConfirmPayResultType, PreparePayResultType, + RecoveryLoadRequest, + RecoveryMergeStrategy, RefreshReason, } from "../types/walletTypes"; import { CryptoApi } from "../crypto/workers/cryptoApi"; @@ -303,12 +307,18 @@ export async function exportBackup( }); 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({ - annual_fee: Amounts.stringify(bp.annualFee), + terms, base_url: canonicalizeBaseUrl(bp.baseUrl), - pay_proposal_ids: [], - storage_limit_in_megabytes: bp.storageLimitInMegabytes, - supported_protocol_version: bp.supportedProtocolVersion, + pay_proposal_ids: bp.paymentProposalIds, }); }); @@ -1256,7 +1266,13 @@ export async function importBackup( 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( @@ -1484,11 +1500,9 @@ function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array { */ export async function runBackupCycle(ws: InternalWalletState): Promise { const providers = await ws.db.iter(Stores.backupProviders).toArray(); - const backupConfig = await provideBackupState(ws); - 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); @@ -1549,6 +1563,15 @@ export async function runBackupCycle(ws: InternalWalletState): Promise { 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: @@ -1565,6 +1588,7 @@ export async function runBackupCycle(ws: InternalWalletState): Promise { return; } prov.lastBackupHash = encodeCrock(currentBackupHash); + prov.lastBackupTimestamp = getTimestampNow(); prov.lastBackupClock = backupJson.clocks[backupJson.current_device_id]; await tx.put(Stores.backupProviders, prov); @@ -1587,8 +1611,8 @@ export async function runBackupCycle(ws: InternalWalletState): Promise { return; } prov.lastBackupHash = encodeCrock(hash(backupEnc)); - prov.lastBackupClock = - blob.clocks[blob.current_device_id]; + prov.lastBackupClock = blob.clocks[blob.current_device_id]; + prov.lastBackupTimestamp = getTimestampNow(); await tx.put(Stores.backupProviders, prov); }, ); @@ -1620,6 +1644,11 @@ const codecForSyncTermsOfServiceResponse = (): Codec< 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< @@ -1637,6 +1666,10 @@ export async function addBackupProvider( const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); const oldProv = await ws.db.get(Stores.backupProviders, canonUrl); if (oldProv) { + if (req.activate) { + oldProv.active = true; + await ws.db.put(Stores.backupProviders, oldProv); + } return; } const termsUrl = new URL("terms", canonUrl); @@ -1646,11 +1679,14 @@ export async function addBackupProvider( codecForSyncTermsOfServiceResponse(), ); await ws.db.put(Stores.backupProviders, { - active: true, - annualFee: terms.annual_fee, + active: !!req.activate, + terms: { + annualFee: terms.annual_fee, + storageLimitInMegabytes: terms.storage_limit_in_megabytes, + supportedProtocolVersion: terms.version, + }, + paymentProposalIds: [], baseUrl: canonUrl, - storageLimitInMegabytes: terms.storage_limit_in_megabytes, - supportedProtocolVersion: terms.version, }); } @@ -1667,9 +1703,11 @@ export async function restoreFromRecoverySecret(): Promise {} * as that's derived from the wallet root key. */ export interface ProviderInfo { + active: boolean; syncProviderBaseUrl: string; - lastRemoteClock: number; - lastBackup?: Timestamp; + lastRemoteClock?: number; + lastBackupTimestamp?: Timestamp; + paymentProposalIds: string[]; } export interface BackupInfo { @@ -1697,7 +1735,20 @@ export async function importBackupPlain( export async function getBackupInfo( ws: InternalWalletState, ): Promise { - 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 { @@ -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 + | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + checkDbInvariant(!!backupStateEntry); + backupStateEntry.value.lastBackupNonce = undefined; + backupStateEntry.value.lastBackupTimestamp = undefined; + backupStateEntry.value.lastBackupCheckTimestamp = undefined; + backupStateEntry.value.lastBackupPlainHash = undefined; + backupStateEntry.value.walletRootPriv = br.walletRootPriv; + backupStateEntry.value.walletRootPub = encodeCrock( + eddsaGetPublic(decodeCrock(br.walletRootPriv)), + ); + await tx.put(Stores.config, backupStateEntry); + for (const prov of br.providers) { + const existingProv = await tx.get(Stores.backupProviders, prov.url); + if (!existingProv) { + await tx.put(Stores.backupProviders, { + active: true, + baseUrl: prov.url, + paymentProposalIds: [], + }); + } + } + const providers = await tx.iter(Stores.backupProviders).toArray(); + for (const prov of providers) { + prov.lastBackupTimestamp = undefined; + prov.lastBackupHash = undefined; + prov.lastBackupClock = undefined; + await tx.put(Stores.backupProviders, prov); + } + }, + ); +} + +async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) { + throw Error("not implemented"); +} + +export async function loadBackupRecovery( + ws: InternalWalletState, + br: RecoveryLoadRequest, +): Promise { + const bs = await provideBackupState(ws); + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + let strategy = br.strategy; + if ( + br.recovery.walletRootPriv != bs.walletRootPriv && + providers.length > 0 && + !strategy + ) { + throw Error( + "recovery load strategy must be specified for wallet with existing providers", + ); + } else if (!strategy) { + // Default to using the new key if we don't have providers yet. + strategy = RecoveryMergeStrategy.Theirs; + } + if (strategy === RecoveryMergeStrategy.Theirs) { + return backupRecoveryTheirs(ws, br.recovery); + } else { + return backupRecoveryOurs(ws, br.recovery); + } +} + export async function exportBackupEncrypted( ws: InternalWalletState, ): Promise { diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts index caab92cf8..56b50d71c 100644 --- a/packages/taler-wallet-core/src/types/backupTypes.ts +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -21,27 +21,22 @@ * as the backup schema must remain very stable and should be self-contained. * * Current limitations: - * 1. Exchange/auditor trust isn't exported yet - * (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 + * 1. "Ghost spends", where a coin is spent unexpectedly by another wallet * and a corresponding transaction (that is missing some details!) should * 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) - * 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). - * 6. Returning money to own bank account isn't supported/exported yet. - * 7. Peer-to-peer payments aren't supported yet. - * 8. Next update time / next refresh time isn't backed up yet. - * 9. Coin/denom selections should be forgettable once that information + * 4. Returning money to own bank account isn't supported/exported yet. + * 5. Peer-to-peer payments aren't supported yet. + * 6. Next update time / next auto-refresh time isn't backed up yet. + * 7. Coin/denom selections should be forgettable once that information * becomes irrelevant. - * 10. Re-denominated payments/refreshes are not shown properly in the total - * payment cost. - * 11. Failed refunds do not have any information about why they failed. - * => This should go into the general "error reports" - * 12. Tombstones for removed backup providers - * 13. Do we somehow need to model the mechanism for first only withdrawing + * 8. Re-denominated payments/refreshes are not shown properly in the total + * payment cost. + * 9. Permanently failed operations aren't properly modeled yet + * 10. Do we somehow need to model the mechanism for first only withdrawing * the amount to pay the backup provider? * * Questions: @@ -299,15 +294,7 @@ export interface BackupTrustExchange { clock_removed?: ClockValue; } -/** - * Backup information about one backup storage provider. - */ -export class BackupBackupProvider { - /** - * Canonicalized base URL of the provider. - */ - base_url: string; - +export class BackupBackupProviderTerms { /** * Last known supported protocol version. */ @@ -322,6 +309,22 @@ export class BackupBackupProvider { * Last known storage limit. */ 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. @@ -790,11 +793,11 @@ export interface BackupPurchase { /** * Total cost initially shown to the user. - * + * * This includes the amount taken by the merchant, fees (wire/deposit) contributed * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings" * of coins that are too small to spend. - * + * * Note that in rare situations, this cost might not be accurate (e.g. * when the payment or refresh gets re-denominated). * We might show adjustments to this later, but currently we don't do so. diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 1c9f546d9..551495a68 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -1426,20 +1426,30 @@ export enum ImportPayloadType { CoreSchema = "core-schema", } +export enum BackupProviderStatus { + PaymentRequired = "payment-required", + Ready = "ready", +} + export interface BackupProviderRecord { baseUrl: string; - supportedProtocolVersion: string; - - annualFee: AmountString; - - storageLimitInMegabytes: number; + /** + * Terms of service of the provider. + * Might be unavailable in the DB in certain situations + * (such as loading a recovery document). + */ + terms?: { + supportedProtocolVersion: string; + annualFee: AmountString; + storageLimitInMegabytes: number; + }; active: boolean; /** - * Hash of the last backup that we already - * merged. + * Hash of the last encrypted backup that we already merged + * or successfully uploaded ourselves. */ lastBackupHash?: string; @@ -1448,6 +1458,12 @@ export interface BackupProviderRecord { * merged. */ lastBackupClock?: number; + + lastBackupTimestamp?: Timestamp; + + currentPaymentProposalId?: string; + + paymentProposalIds: string[]; } class ExchangesStore extends Store<"exchanges", ExchangeRecord> { diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 1b962e1c4..235ea11f1 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -56,6 +56,7 @@ import { ContractTerms, } from "./talerTypes"; import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes"; +import { BackupRecovery } from "../operations/backup"; /** * Response for the create reserve request to the wallet. @@ -896,6 +897,29 @@ export interface MakeSyncSignatureRequest { 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< WithdrawTestBalanceRequest > => diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 0b2b4d639..56e3d82d1 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -94,6 +94,7 @@ import { codecForAcceptTipRequest, codecForAbortPayWithRefundRequest, ApplyRefundResponse, + RecoveryLoadRequest, } from "./types/walletTypes"; import { Logger } from "./util/logging"; @@ -167,6 +168,9 @@ import { BackupRecovery, getBackupRecovery, AddBackupProviderRequest, + getBackupInfo, + BackupInfo, + loadBackupRecovery, } from "./operations/backup"; const builtinCurrencies: CurrencyRecord[] = [ @@ -959,6 +963,10 @@ export class Wallet { return getBackupRecovery(this.ws); } + async loadBackupRecovery(req: RecoveryLoadRequest): Promise { + return loadBackupRecovery(this.ws, req); + } + async addBackupProvider(req: AddBackupProviderRequest): Promise { return addBackupProvider(this.ws, req); } @@ -967,6 +975,10 @@ export class Wallet { return runBackupCycle(this.ws); } + async getBackupStatus(): Promise { + return getBackupInfo(this.ws); + } + /** * Implementation of the "wallet-core" API. */