/* 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 */ /** * Implementation of wallet backups (export/import/upload) and sync * server management. * * @author Florian Dold */ /** * Imports. */ import { AbsoluteTime, AmountString, AttentionType, BackupRecovery, Codec, DenomKeyType, EddsaKeyPair, HttpStatusCode, Logger, PreparePayResult, PreparePayResultType, RecoveryLoadRequest, RecoveryMergeStrategy, TalerError, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, URL, buildCodecForObject, buildCodecForUnion, bytesToString, canonicalJson, canonicalizeBaseUrl, codecForAmountString, codecForBoolean, codecForConstString, codecForList, codecForNumber, codecForString, codecOptional, decodeCrock, durationFromSpec, eddsaGetPublic, encodeCrock, getRandomBytes, hash, hashDenomPub, j2s, kdf, notEmpty, rsaBlind, secretbox, secretbox_open, stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { gunzipSync, gzipSync } from "fflate"; import { TalerCryptoInterface } from "../../crypto/cryptoImplementation.js"; import { BackupProviderRecord, BackupProviderState, BackupProviderStateTag, BackupProviderTerms, ConfigRecord, ConfigRecordKey, WalletBackupConfState, timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; import { assertUnreachable } from "../../util/assertUnreachable.js"; import { checkDbInvariant, checkLogicInvariant, } from "../../util/invariants.js"; import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; import { TaskRunResult, TaskRunResultType, TaskIdentifiers, } from "../common.js"; import { checkPaymentByProposalId, preparePayForUri } from "../pay-merchant.js"; import { WalletStoresV1 } from "../../db.js"; import { GetReadOnlyAccess } from "../../util/query.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 { 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(), durationFromSpec({ minutes: 5 }), ), ); } async function runBackupCycleForProvider( ws: InternalWalletState, args: BackupForProviderArgs, ): Promise { const provider = await ws.db .mktx((x) => [x.backupProviders]) .runReadOnly(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(ws); 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 ws.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 ws.http.fetch(accountBackupUrl.href, { method: "POST", body: encBackup, headers: { "content-type": "application/octet-stream", "sync-signature": syncSigResp.sig, "if-none-match": newHash, ...(provider.lastBackupHash ? { "if-match": provider.lastBackupHash, } : {}), }, }); logger.trace(`sync response status: ${resp.status}`); if (resp.status === HttpStatusCode.NotModified) { await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(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(ws, { 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(ws, talerUri); } catch (e) { const error = TalerError.fromException(e); if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { throw error; } } if (res === undefined) { //claimed await ws.db .mktx((x) => [x.backupProviders, x.operationRetries]) .runReadWrite(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); }); return { type: TaskRunResultType.Pending, }; } const result = res; await ws.db .mktx((x) => [x.backupProviders, x.operationRetries]) .runReadWrite(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( ws, { type: AttentionType.BackupUnpaid, provider_base_url: provider.baseUrl, talerUri, }, provider.baseUrl, ); return { type: TaskRunResultType.Pending, }; } if (resp.status === HttpStatusCode.NoContent) { await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(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(ws, { 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(ws); // const blob = await decryptBackup(backupConfig, backupEnc); // FIXME: Re-implement backup import with merging // await importBackup(ws, blob, cryptoData); await ws.db .mktx((x) => [x.backupProviders, x.operationRetries]) .runReadWrite(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(ws, 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( ws: InternalWalletState, backupProviderBaseUrl: string, ): Promise { const provider = await ws.db .mktx((x) => [x.backupProviders]) .runReadOnly(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(ws, { backupProviderBaseUrl: provider.baseUrl, }); } export interface RemoveBackupProviderRequest { provider: string; } export const codecForRemoveBackupProvider = (): Codec => buildCodecForObject() .property("provider", codecForString()) .build("RemoveBackupProviderRequest"); export async function removeBackupProvider( ws: InternalWalletState, req: RemoveBackupProviderRequest, ): Promise { await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(async (tx) => { await tx.backupProviders.delete(req.provider); }); } export interface RunBackupCycleRequest { /** * List of providers to backup or empty for all known providers. */ providers?: Array; } export const codecForRunBackupCycle = (): Codec => buildCodecForObject() .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( ws: InternalWalletState, req: RunBackupCycleRequest, ): Promise { const providers = await ws.db .mktx((x) => [x.backupProviders]) .runReadOnly(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(ws, { backupProviderBaseUrl: provider.baseUrl, }); } } export 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; } export const codecForSyncTermsOfServiceResponse = (): Codec => buildCodecForObject() .property("storage_limit_in_megabytes", codecForNumber()) .property("annual_fee", codecForAmountString()) .property("version", codecForString()) .build("SyncTermsOfServiceResponse"); 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 => buildCodecForObject() .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 => buildCodecForObject() .property("status", codecForConstString("ok")) .build("AddBackupProviderOk"); export const codecForAddBackupProviderPaymenrRequired = (): Codec => buildCodecForObject() .property("status", codecForConstString("payment-required")) .property("talerUri", codecOptional(codecForString())) .build("AddBackupProviderPaymentRequired"); export const codecForAddBackupProviderResponse = (): Codec => buildCodecForUnion() .discriminateOn("status") .alternative("ok", codecForAddBackupProviderOk()) .alternative( "payment-required", codecForAddBackupProviderPaymenrRequired(), ) .build("AddBackupProviderResponse"); export async function addBackupProvider( ws: InternalWalletState, req: AddBackupProviderRequest, ): Promise { logger.info(`adding backup provider ${j2s(req)}`); await provideBackupState(ws); const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(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 ws.http.fetch(termsUrl.href); const terms = await readSuccessResponseJsonOrThrow( resp, codecForSyncTermsOfServiceResponse(), ); await ws.db .mktx((x) => [x.backupProviders]) .runReadWrite(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(ws, { backupProviderBaseUrl: canonUrl, }); } async function runFirstBackupCycleForProvider( ws: InternalWalletState, args: BackupForProviderArgs, ): Promise { 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.Longpoll: throw Error( "unexpected runFirstBackupCycleForProvider result (longpoll)", ); case TaskRunResultType.Pending: return { status: "payment-required", talerUri: "FIXME", //talerUri: resp.result.talerUri, }; default: assertUnreachable(resp); } } export async function restoreFromRecoverySecret(): Promise { return; } /** * 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; name: string; terms?: BackupProviderTerms; /** * Last communication issue with the provider. */ lastError?: TalerErrorDetail; lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp; lastAttemptedBackupTimestamp?: TalerPreciseTimestamp; paymentProposalIds: string[]; backupProblem?: BackupProblem; paymentStatus: ProviderPaymentStatus; } export type BackupProblem = | BackupUnreadableProblem | BackupConflictingDeviceProblem; export interface BackupUnreadableProblem { type: "backup-unreadable"; } export interface BackupUnreadableProblem { type: "backup-unreadable"; } export interface BackupConflictingDeviceProblem { type: "backup-conflicting-device"; otherDeviceId: string; myDeviceId: string; backupTimestamp: AbsoluteTime; } export type ProviderPaymentStatus = | ProviderPaymentTermsChanged | ProviderPaymentPaid | ProviderPaymentInsufficientBalance | ProviderPaymentUnpaid | ProviderPaymentPending; export interface BackupInfo { walletRootPub: string; deviceId: string; providers: ProviderInfo[]; } export enum ProviderPaymentType { Unpaid = "unpaid", Pending = "pending", InsufficientBalance = "insufficient-balance", Paid = "paid", TermsChanged = "terms-changed", } export interface ProviderPaymentUnpaid { type: ProviderPaymentType.Unpaid; } export interface ProviderPaymentInsufficientBalance { type: ProviderPaymentType.InsufficientBalance; amount: AmountString; } export interface ProviderPaymentPending { type: ProviderPaymentType.Pending; talerUri?: string; } export interface ProviderPaymentPaid { type: ProviderPaymentType.Paid; paidUntil: AbsoluteTime; } export interface ProviderPaymentTermsChanged { type: ProviderPaymentType.TermsChanged; paidUntil: AbsoluteTime; oldTerms: BackupProviderTerms; newTerms: BackupProviderTerms; } async function getProviderPaymentInfo( ws: InternalWalletState, provider: BackupProviderRecord, ): Promise { 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( ws: InternalWalletState, ): Promise { const backupConfig = await provideBackupState(ws); const providerRecords = await ws.db .mktx((x) => [x.backupProviders, x.operationRetries]) .runReadOnly(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(ws, 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( ws: InternalWalletState, ): Promise { const bs = await provideBackupState(ws); const providers = await ws.db .mktx((x) => [x.backupProviders]) .runReadOnly(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( ws: InternalWalletState, br: BackupRecovery, ) { await ws.db .mktx((x) => [x.config, x.backupProviders]) .runReadWrite(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(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 .mktx((x) => [x.backupProviders]) .runReadOnly(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(ws, br.recovery); } else { return backupRecoveryOurs(ws, br.recovery); } } export async function decryptBackup( backupConfig: WalletBackupConfState, data: Uint8Array, ): Promise { 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( ws: InternalWalletState, ): Promise { const bs: ConfigRecord | undefined = await ws.db .mktx((stores) => [stores.config]) .runReadOnly(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 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 .mktx((x) => [x.config]) .runReadWrite(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: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>, ): Promise { 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( ws: InternalWalletState, deviceId: string, ): Promise { await provideBackupState(ws); await ws.db .mktx((x) => [x.config]) .runReadWrite(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( ws: InternalWalletState, ): Promise { const bs = await provideBackupState(ws); return bs.deviceId; }