diff options
author | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
commit | e951075d2ef52fa8e9e7489c62031777c3a7e66b (patch) | |
tree | 64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/backup | |
parent | e975740ac4e9ba4bc531226784d640a018c00833 (diff) | |
download | wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.gz wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.bz2 wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.zip |
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/backup')
-rw-r--r-- | packages/taler-wallet-core/src/backup/index.ts | 1059 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/backup/state.ts | 15 |
2 files changed, 1074 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..c9ab6c5d9 --- /dev/null +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -0,0 +1,1059 @@ +/* + 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, + AmountString, + AttentionType, + BackupRecovery, + Codec, + EddsaKeyPair, + HttpStatusCode, + Logger, + PreparePayResult, + 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, + 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 { + BackupProviderRecord, + BackupProviderState, + BackupProviderStateTag, + BackupProviderTerms, + ConfigRecord, + ConfigRecordKey, + WalletBackupConfState, + WalletDbReadOnlyTransaction, + timestampOptionalPreciseFromDb, + timestampPreciseToDb, +} from "../../db.js"; +import { InternalWalletState } from "../../internal-wallet-state.js"; +import { + checkDbInvariant, + checkLogicInvariant, +} from "../../util/invariants.js"; +import { addAttentionRequest, removeAttentionRequest } from "../../attention.js"; +import { + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, +} from "../../common.js"; +import { preparePayForUri } from "../../pay-merchant.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(), + durationFromSpec({ minutes: 5 }), + ), + ); +} + +async function runBackupCycleForProvider( + ws: InternalWalletState, + args: BackupForProviderArgs, +): Promise<TaskRunResult> { + const provider = await ws.db.runReadOnlyTx( + ["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(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.runReadWriteTx(["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(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.runReadWriteTx(["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 ws.db.runReadWriteTx(["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( + ws, + { + 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 ws.db.runReadWriteTx(["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(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.runReadWriteTx(["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(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<TaskRunResult> { + const provider = await ws.db.runReadOnlyTx( + ["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(ws, { + backupProviderBaseUrl: provider.baseUrl, + }); +} + +export interface RemoveBackupProviderRequest { + provider: string; +} + +export const codecForRemoveBackupProvider = + (): Codec<RemoveBackupProviderRequest> => + buildCodecForObject<RemoveBackupProviderRequest>() + .property("provider", codecForString()) + .build("RemoveBackupProviderRequest"); + +export async function removeBackupProvider( + ws: InternalWalletState, + req: RemoveBackupProviderRequest, +): Promise<void> { + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, + req: RunBackupCycleRequest, +): Promise<void> { + const providers = await ws.db.runReadOnlyTx( + ["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(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<SyncTermsOfServiceResponse> => + buildCodecForObject<SyncTermsOfServiceResponse>() + .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<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( + ws: InternalWalletState, + req: AddBackupProviderRequest, +): Promise<AddBackupProviderResponse> { + logger.info(`adding backup provider ${j2s(req)}`); + await provideBackupState(ws); + const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); + await ws.db.runReadWriteTx(["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 ws.http.fetch(termsUrl.href); + const terms = await readSuccessResponseJsonOrThrow( + resp, + codecForSyncTermsOfServiceResponse(), + ); + await ws.db.runReadWriteTx(["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(ws, { + backupProviderBaseUrl: canonUrl, + }); +} + +async function runFirstBackupCycleForProvider( + ws: InternalWalletState, + 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; +} + +/** + * 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<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( + ws: InternalWalletState, +): Promise<BackupInfo> { + const backupConfig = await provideBackupState(ws); + const providerRecords = await ws.db.runReadOnlyTx( + ["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(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<BackupRecovery> { + const bs = await provideBackupState(ws); + const providers = await ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + br: BackupRecovery, +) { + await ws.db.runReadWriteTx(["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(ws: InternalWalletState, br: BackupRecovery) { + throw Error("not implemented"); +} + +export async function loadBackupRecovery( + ws: InternalWalletState, + br: RecoveryLoadRequest, +): Promise<void> { + const bs = await provideBackupState(ws); + const providers = await ws.db.runReadOnlyTx( + ["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(ws, br.recovery); + } else { + return backupRecoveryOurs(ws, 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( + ws: InternalWalletState, +): Promise<WalletBackupConfState> { + const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx( + ["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 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.runReadWriteTx(["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( + ws: InternalWalletState, + deviceId: string, +): Promise<void> { + await provideBackupState(ws); + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, +): Promise<string> { + const bs = await provideBackupState(ws); + return bs.deviceId; +} diff --git a/packages/taler-wallet-core/src/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts new file mode 100644 index 000000000..72f850b25 --- /dev/null +++ b/packages/taler-wallet-core/src/backup/state.ts @@ -0,0 +1,15 @@ +/* + 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/> + */ |