summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/backup/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/backup/index.ts')
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts985
1 files changed, 985 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
new file mode 100644
index 000000000..16b5488e7
--- /dev/null
+++ b/packages/taler-wallet-core/src/backup/index.ts
@@ -0,0 +1,985 @@
+/*
+ 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 {
+ AbsoluteTime,
+ AttentionType,
+ BackupRecovery,
+ Codec,
+ Duration,
+ EddsaKeyPair,
+ HttpStatusCode,
+ Logger,
+ PreparePayResult,
+ ProviderInfo,
+ ProviderPaymentStatus,
+ RecoveryLoadRequest,
+ RecoveryMergeStrategy,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ URL,
+ buildCodecForObject,
+ buildCodecForUnion,
+ bytesToString,
+ canonicalJson,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codecForBoolean,
+ codecForConstString,
+ codecForList,
+ codecForString,
+ codecForSyncTermsOfServiceResponse,
+ codecOptional,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ kdf,
+ notEmpty,
+ secretbox,
+ secretbox_open,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { gunzipSync, gzipSync } from "fflate";
+import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+} from "../common.js";
+import {
+ BackupProviderRecord,
+ BackupProviderState,
+ BackupProviderStateTag,
+ ConfigRecord,
+ ConfigRecordKey,
+ WalletBackupConfState,
+ WalletDbReadOnlyTransaction,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseToDb,
+} from "../db.js";
+import { preparePayForUri } from "../pay-merchant.js";
+import { InternalWalletState, WalletExecutionContext } from "../wallet.js";
+
+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: any,
+): 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), {
+ mtime: 0,
+ });
+ const secret = deriveBlobSecret(config);
+ const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
+ chunks.push(encrypted);
+ return concatArrays(chunks);
+}
+
+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"),
+ );
+}
+
+interface BackupForProviderArgs {
+ backupProviderBaseUrl: string;
+}
+
+function getNextBackupTimestamp(): TalerPreciseTimestamp {
+ // FIXME: Randomize!
+ return AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ );
+}
+
+async function runBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return tx.backupProviders.get(args.backupProviderBaseUrl);
+ },
+ );
+
+ if (!provider) {
+ logger.warn("provider disappeared");
+ return TaskRunResult.finished();
+ }
+
+ //const backupJson = await exportBackup(ws);
+ // FIXME: re-implement backup
+ const backupJson = {};
+ const backupConfig = await provideBackupState(wex);
+ const encBackup = await encryptBackup(backupConfig, backupJson);
+ const currentBackupHash = hash(encBackup);
+
+ const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
+
+ const newHash = encodeCrock(currentBackupHash);
+ const oldHash = provider.lastBackupHash;
+
+ logger.trace(`trying to upload backup to ${provider.baseUrl}`);
+ logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
+
+ const syncSigResp = await wex.cryptoApi.makeSyncSignature({
+ newHash: encodeCrock(currentBackupHash),
+ oldHash: provider.lastBackupHash,
+ accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
+ });
+
+ logger.trace(`sync signature is ${syncSigResp}`);
+
+ const accountBackupUrl = new URL(
+ `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
+ provider.baseUrl,
+ );
+
+ if (provider.shouldRetryFreshProposal) {
+ accountBackupUrl.searchParams.set("fresh", "yes");
+ }
+
+ const resp = await wex.http.fetch(accountBackupUrl.href, {
+ method: "POST",
+ body: encBackup,
+ headers: {
+ "content-type": "application/octet-stream",
+ "sync-signature": syncSigResp.sig,
+ "if-none-match": JSON.stringify(newHash),
+ ...(provider.lastBackupHash
+ ? {
+ "if-match": JSON.stringify(provider.lastBackupHash),
+ }
+ : {}),
+ },
+ });
+
+ logger.trace(`sync response status: ${resp.status}`);
+
+ if (resp.status === HttpStatusCode.NotModified) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return TaskRunResult.finished();
+ }
+
+ if (resp.status === HttpStatusCode.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");
+ }
+
+ //We can't delay downloading the proposal since we need the id
+ //FIXME: check download errors
+ let res: PreparePayResult | undefined = undefined;
+ try {
+ res = await preparePayForUri(wex, talerUri);
+ } catch (e) {
+ const error = TalerError.fromException(e);
+ if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
+ throw error;
+ }
+ }
+
+ if (res === undefined) {
+ //claimed
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.shouldRetryFreshProposal = true;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+ const result = res;
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
+ prov.currentPaymentProposalId = result.proposalId;
+ prov.shouldRetryFreshProposal = false;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ addAttentionRequest(
+ wex,
+ {
+ type: AttentionType.BackupUnpaid,
+ provider_base_url: provider.baseUrl,
+ talerUri,
+ },
+ provider.baseUrl,
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+
+ if (resp.status === HttpStatusCode.NoContent) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(currentBackupHash);
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ logger.info("conflicting backup found");
+ const backupEnc = new Uint8Array(await resp.bytes());
+ const backupConfig = await provideBackupState(wex);
+ // const blob = await decryptBackup(backupConfig, backupEnc);
+ // FIXME: Re-implement backup import with merging
+ // await importBackup(ws, blob, cryptoData);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(hash(backupEnc));
+ // FIXME: Allocate error code for this situation?
+ // FIXME: Add operation retry record!
+ const opId = TaskIdentifiers.forBackup(prov);
+ //await scheduleRetryInTx(ws, tx, opId);
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+ logger.info("processed existing backup");
+ // Now upload our own, merged backup.
+ return await runBackupCycleForProvider(wex, args);
+ }
+
+ // 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)}`);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: err,
+ };
+}
+
+export async function processBackupForProvider(
+ wex: WalletExecutionContext,
+ backupProviderBaseUrl: string,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.get(backupProviderBaseUrl);
+ },
+ );
+ if (!provider) {
+ throw Error("unknown backup provider");
+ }
+
+ logger.info(`running backup for provider ${backupProviderBaseUrl}`);
+
+ return await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+}
+
+export interface RemoveBackupProviderRequest {
+ provider: string;
+}
+
+export const codecForRemoveBackupProvider =
+ (): Codec<RemoveBackupProviderRequest> =>
+ buildCodecForObject<RemoveBackupProviderRequest>()
+ .property("provider", codecForString())
+ .build("RemoveBackupProviderRequest");
+
+export async function removeBackupProvider(
+ wex: WalletExecutionContext,
+ req: RemoveBackupProviderRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ await tx.backupProviders.delete(req.provider);
+ },
+ );
+}
+
+export interface RunBackupCycleRequest {
+ /**
+ * List of providers to backup or empty for all known providers.
+ */
+ providers?: Array<string>;
+}
+
+export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
+ buildCodecForObject<RunBackupCycleRequest>()
+ .property("providers", codecOptional(codecForList(codecForString())))
+ .build("RunBackupCycleRequest");
+
+/**
+ * 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(
+ wex: WalletExecutionContext,
+ req: RunBackupCycleRequest,
+): Promise<void> {
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ if (req.providers) {
+ const rs = await Promise.all(
+ req.providers.map((id) => tx.backupProviders.get(id)),
+ );
+ return rs.filter(notEmpty);
+ }
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+
+ for (const provider of providers) {
+ await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+ }
+}
+
+export interface AddBackupProviderRequest {
+ backupProviderBaseUrl: string;
+
+ name: 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("name", codecForString())
+ .property("activate", codecOptional(codecForBoolean()))
+ .build("AddBackupProviderRequest");
+
+export type AddBackupProviderResponse =
+ | AddBackupProviderOk
+ | AddBackupProviderPaymentRequired;
+
+interface AddBackupProviderOk {
+ status: "ok";
+}
+interface AddBackupProviderPaymentRequired {
+ status: "payment-required";
+ talerUri?: string;
+}
+
+export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
+ buildCodecForObject<AddBackupProviderOk>()
+ .property("status", codecForConstString("ok"))
+ .build("AddBackupProviderOk");
+
+export const codecForAddBackupProviderPaymenrRequired =
+ (): Codec<AddBackupProviderPaymentRequired> =>
+ buildCodecForObject<AddBackupProviderPaymentRequired>()
+ .property("status", codecForConstString("payment-required"))
+ .property("talerUri", codecOptional(codecForString()))
+ .build("AddBackupProviderPaymentRequired");
+
+export const codecForAddBackupProviderResponse =
+ (): Codec<AddBackupProviderResponse> =>
+ buildCodecForUnion<AddBackupProviderResponse>()
+ .discriminateOn("status")
+ .alternative("ok", codecForAddBackupProviderOk())
+ .alternative(
+ "payment-required",
+ codecForAddBackupProviderPaymenrRequired(),
+ )
+ .build("AddBackupProviderResponse");
+
+export async function addBackupProvider(
+ wex: WalletExecutionContext,
+ req: AddBackupProviderRequest,
+): Promise<AddBackupProviderResponse> {
+ logger.info(`adding backup provider ${j2s(req)}`);
+ await provideBackupState(wex);
+ const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const oldProv = await tx.backupProviders.get(canonUrl);
+ if (oldProv) {
+ logger.info("old backup provider found");
+ if (req.activate) {
+ oldProv.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ logger.info("setting existing backup provider to active");
+ await tx.backupProviders.put(oldProv);
+ }
+ return;
+ }
+ },
+ );
+ const termsUrl = new URL("config", canonUrl);
+ const resp = await wex.http.fetch(termsUrl.href);
+ const terms = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForSyncTermsOfServiceResponse(),
+ );
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ let state: BackupProviderState;
+ //FIXME: what is the difference provisional and ready?
+ if (req.activate) {
+ state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ } else {
+ state = {
+ tag: BackupProviderStateTag.Provisional,
+ };
+ }
+ await tx.backupProviders.put({
+ state,
+ name: req.name,
+ terms: {
+ annualFee: terms.annual_fee,
+ storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+ supportedProtocolVersion: terms.version,
+ },
+ shouldRetryFreshProposal: false,
+ paymentProposalIds: [],
+ baseUrl: canonUrl,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ },
+ );
+
+ return await runFirstBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: canonUrl,
+ });
+}
+
+async function runFirstBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<AddBackupProviderResponse> {
+ throw Error("not implemented");
+ // const resp = await runBackupCycleForProvider(ws, args);
+ // switch (resp.type) {
+ // case TaskRunResultType.Error:
+ // throw TalerError.fromDetail(
+ // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ // resp.errorDetail as any, //FIXME create an error for backup problems
+ // );
+ // case TaskRunResultType.Finished:
+ // return {
+ // status: "ok",
+ // };
+ // case TaskRunResultType.Pending:
+ // return {
+ // status: "payment-required",
+ // talerUri: "FIXME",
+ // //talerUri: resp.result.talerUri,
+ // };
+ // default:
+ // assertUnreachable(resp);
+ // }
+}
+
+export async function restoreFromRecoverySecret(): Promise<void> {
+ return;
+}
+
+export interface BackupInfo {
+ walletRootPub: string;
+ deviceId: string;
+ providers: ProviderInfo[];
+}
+
+async function getProviderPaymentInfo(
+ wex: WalletExecutionContext,
+ provider: BackupProviderRecord,
+): Promise<ProviderPaymentStatus> {
+ throw Error("not implemented");
+ // if (!provider.currentPaymentProposalId) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+ // const status = await checkPaymentByProposalId(
+ // ws,
+ // provider.currentPaymentProposalId,
+ // ).catch(() => undefined);
+
+ // if (!status) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+
+ // switch (status.status) {
+ // case PreparePayResultType.InsufficientBalance:
+ // return {
+ // type: ProviderPaymentType.InsufficientBalance,
+ // amount: status.amountRaw,
+ // };
+ // case PreparePayResultType.PaymentPossible:
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // case PreparePayResultType.AlreadyConfirmed:
+ // if (status.paid) {
+ // return {
+ // type: ProviderPaymentType.Paid,
+ // paidUntil: AbsoluteTime.addDuration(
+ // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
+ // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
+ // ),
+ // };
+ // } else {
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // }
+ // default:
+ // assertUnreachable(status);
+ // }
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupInfo(
+ wex: WalletExecutionContext,
+): Promise<BackupInfo> {
+ const backupConfig = await provideBackupState(wex);
+ const providerRecords = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders", "operationRetries"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().mapAsync(async (bp) => {
+ const opId = TaskIdentifiers.forBackup(bp);
+ const retryRecord = await tx.operationRetries.get(opId);
+ return {
+ provider: bp,
+ retryRecord,
+ };
+ });
+ },
+ );
+ const providers: ProviderInfo[] = [];
+ for (const x of providerRecords) {
+ providers.push({
+ active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
+ syncProviderBaseUrl: x.provider.baseUrl,
+ lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
+ x.provider.lastBackupCycleTimestamp,
+ ),
+ paymentProposalIds: x.provider.paymentProposalIds,
+ lastError:
+ x.provider.state.tag === BackupProviderStateTag.Retrying
+ ? x.retryRecord?.lastError
+ : undefined,
+ paymentStatus: await getProviderPaymentInfo(wex, x.provider),
+ terms: x.provider.terms,
+ name: x.provider.name,
+ });
+ }
+ return {
+ deviceId: backupConfig.deviceId,
+ walletRootPub: backupConfig.walletRootPub,
+ providers,
+ };
+}
+
+/**
+ * Get backup recovery information, including the wallet's
+ * private key.
+ */
+export async function getBackupRecovery(
+ wex: WalletExecutionContext,
+): Promise<BackupRecovery> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ return {
+ providers: providers
+ .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
+ .map((x) => {
+ return {
+ name: x.name,
+ url: x.baseUrl,
+ };
+ }),
+ walletRootPriv: bs.walletRootPriv,
+ };
+}
+
+async function backupRecoveryTheirs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders", "config"] },
+ async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ checkDbInvariant(!!backupStateEntry);
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ 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.config.put(backupStateEntry);
+ for (const prov of br.providers) {
+ const existingProv = await tx.backupProviders.get(prov.url);
+ if (!existingProv) {
+ await tx.backupProviders.put({
+ baseUrl: prov.url,
+ name: prov.name,
+ paymentProposalIds: [],
+ shouldRetryFreshProposal: false,
+ state: {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ },
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
+ }
+ const providers = await tx.backupProviders.iter().toArray();
+ for (const prov of providers) {
+ prov.lastBackupCycleTimestamp = undefined;
+ prov.lastBackupHash = undefined;
+ await tx.backupProviders.put(prov);
+ }
+ },
+ );
+}
+
+async function backupRecoveryOurs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ throw Error("not implemented");
+}
+
+export async function loadBackupRecovery(
+ wex: WalletExecutionContext,
+ br: RecoveryLoadRequest,
+): Promise<void> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().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(wex, br.recovery);
+ } else {
+ return backupRecoveryOurs(wex, br.recovery);
+ }
+}
+
+export async function decryptBackup(
+ backupConfig: WalletBackupConfState,
+ data: Uint8Array,
+): Promise<any> {
+ 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 provideBackupState(
+ wex: WalletExecutionContext,
+): Promise<WalletBackupConfState> {
+ const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx(
+ { storeNames: ["config"] },
+ async (tx) => {
+ return await tx.config.get(ConfigRecordKey.WalletBackupState);
+ },
+ );
+ if (bs) {
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+ }
+ // We need to generate the key outside of the transaction
+ // due to how IndexedDB works.
+ const k = await wex.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 wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (!backupStateEntry) {
+ backupStateEntry = {
+ key: ConfigRecordKey.WalletBackupState,
+ value: {
+ deviceId,
+ walletRootPub: k.pub,
+ walletRootPriv: k.priv,
+ lastBackupPlainHash: undefined,
+ },
+ };
+ await tx.config.put(backupStateEntry);
+ }
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ return backupStateEntry.value;
+ });
+}
+
+export async function getWalletBackupState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["config"]>,
+): Promise<WalletBackupConfState> {
+ const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
+ checkDbInvariant(!!bs, "wallet backup state should be in DB");
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+}
+
+export async function setWalletDeviceId(
+ wex: WalletExecutionContext,
+ deviceId: string,
+): Promise<void> {
+ await provideBackupState(wex);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (
+ !backupStateEntry ||
+ backupStateEntry.key !== ConfigRecordKey.WalletBackupState
+ ) {
+ return;
+ }
+ backupStateEntry.value.deviceId = deviceId;
+ await tx.config.put(backupStateEntry);
+ });
+}
+
+export async function getWalletDeviceId(
+ wex: WalletExecutionContext,
+): Promise<string> {
+ const bs = await provideBackupState(wex);
+ return bs.deviceId;
+}