diff options
Diffstat (limited to 'packages/taler-wallet-core/src/backup/index.ts')
-rw-r--r-- | packages/taler-wallet-core/src/backup/index.ts | 985 |
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; +} |