diff options
author | Florian Dold <florian@dold.me> | 2020-12-02 14:55:04 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2020-12-02 14:55:04 +0100 |
commit | 89f1a281fea66b986fc0a003dc10446f6ed6e4a2 (patch) | |
tree | 8ffe90d572bc6967ee86bdcffc1eb6dc1240d17c /packages/taler-wallet-core/src/operations/backup.ts | |
parent | 0828e65f8845dc4b148c0d3b0697fb589b338239 (diff) | |
download | wallet-core-89f1a281fea66b986fc0a003dc10446f6ed6e4a2.tar.xz |
backup WIP
Diffstat (limited to 'packages/taler-wallet-core/src/operations/backup.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/backup.ts | 402 |
1 files changed, 402 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts new file mode 100644 index 000000000..dbcb33374 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/backup.ts @@ -0,0 +1,402 @@ +/* + 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 { InternalWalletState } from "./state"; +import { + BackupCoin, + BackupCoinSource, + BackupCoinSourceType, + BackupExchangeData, + WalletBackupContentV1, +} from "../types/backupTypes"; +import { TransactionHandle } from "../util/query"; +import { + CoinSourceType, + CoinStatus, + ConfigRecord, + Stores, +} from "../types/dbTypes"; +import { checkDbInvariant } from "../util/invariants"; +import { Amounts, codecForAmountString } from "../util/amounts"; +import { + decodeCrock, + eddsaGetPublic, + EddsaKeyPair, + encodeCrock, + getRandomBytes, + hash, + stringToBytes, +} from "../crypto/talerCrypto"; +import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers"; +import { Timestamp } from "../util/time"; +import { URL } from "../util/url"; +import { AmountString } from "../types/talerTypes"; +import { + buildCodecForObject, + Codec, + codecForNumber, + codecForString, +} from "../util/codec"; +import { + HttpResponseStatus, + readSuccessResponseJsonOrThrow, +} from "../util/http"; +import { Logger } from "../util/logging"; +import { gzipSync } from "fflate"; +import { sign_keyPair_fromSeed } from "../crypto/primitives/nacl-fast"; +import { kdf } from "../crypto/primitives/kdf"; + +interface WalletBackupConfState { + walletRootPub: string; + walletRootPriv: string; + clock: number; + lastBackupHash?: string; + lastBackupNonce?: string; +} + +const WALLET_BACKUP_STATE_KEY = "walletBackupState"; + +const logger = new Logger("operations/backup.ts"); + +async function provideBackupState( + ws: InternalWalletState, +): Promise<WalletBackupConfState> { + const bs: ConfigRecord<WalletBackupConfState> | undefined = await ws.db.get( + Stores.config, + WALLET_BACKUP_STATE_KEY, + ); + if (bs) { + return bs.value; + } + // We need to generate the key outside of the transaction + // due to how IndexedDB works. + const k = await ws.cryptoApi.createEddsaKeypair(); + return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => { + let backupStateEntry: + | ConfigRecord<WalletBackupConfState> + | undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + if (!backupStateEntry) { + backupStateEntry = { + key: WALLET_BACKUP_STATE_KEY, + value: { + walletRootPub: k.pub, + walletRootPriv: k.priv, + clock: 0, + lastBackupHash: undefined, + }, + }; + await tx.put(Stores.config, backupStateEntry); + } + return backupStateEntry.value; + }); +} + +async function getWalletBackupState( + ws: InternalWalletState, + tx: TransactionHandle<typeof Stores.config>, +): Promise<WalletBackupConfState> { + let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY); + checkDbInvariant(!!bs, "wallet backup state should be in DB"); + return bs.value; +} + +export async function exportBackup( + ws: InternalWalletState, +): Promise<WalletBackupContentV1> { + await provideBackupState(ws); + return ws.db.runWithWriteTransaction( + [Stores.config, Stores.exchanges, Stores.coins], + async (tx) => { + const bs = await getWalletBackupState(ws, tx); + + const exchanges: BackupExchangeData[] = []; + const coins: BackupCoin[] = []; + + await tx.iter(Stores.exchanges).forEach((ex) => { + if (!ex.details) { + return; + } + exchanges.push({ + exchangeBaseUrl: ex.baseUrl, + exchangeMasterPub: ex.details?.masterPublicKey, + termsOfServiceAcceptedEtag: ex.termsOfServiceAcceptedEtag, + }); + }); + + await tx.iter(Stores.coins).forEach((coin) => { + let bcs: BackupCoinSource; + switch (coin.coinSource.type) { + case CoinSourceType.Refresh: + bcs = { + type: BackupCoinSourceType.Refresh, + oldCoinPub: coin.coinSource.oldCoinPub, + }; + break; + case CoinSourceType.Tip: + bcs = { + type: BackupCoinSourceType.Tip, + coinIndex: coin.coinSource.coinIndex, + walletTipId: coin.coinSource.walletTipId, + }; + break; + case CoinSourceType.Withdraw: + bcs = { + type: BackupCoinSourceType.Withdraw, + coinIndex: coin.coinSource.coinIndex, + reservePub: coin.coinSource.reservePub, + withdrawalGroupId: coin.coinSource.withdrawalGroupId, + }; + break; + } + + coins.push({ + exchangeBaseUrl: coin.exchangeBaseUrl, + blindingKey: coin.blindingKey, + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + coinSource: bcs, + currentAmount: Amounts.stringify(coin.currentAmount), + fresh: coin.status === CoinStatus.Fresh, + }); + }); + + const backupBlob: WalletBackupContentV1 = { + schemaId: "gnu-taler-wallet-backup", + schemaVersion: 1, + clock: bs.clock, + coins: coins, + exchanges: exchanges, + planchets: [], + refreshSessions: [], + reserves: [], + walletRootPub: bs.walletRootPub, + }; + + // If the backup changed, we increment our clock. + + let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob)))); + if (h != bs.lastBackupHash) { + backupBlob.clock = ++bs.clock; + bs.lastBackupHash = encodeCrock( + hash(stringToBytes(canonicalJson(backupBlob))), + ); + bs.lastBackupNonce = encodeCrock(getRandomBytes(32)); + await tx.put(Stores.config, { + key: WALLET_BACKUP_STATE_KEY, + value: bs, + }); + } + + return backupBlob; + }, + ); +} + +export interface BackupRequest { + backupBlob: any; +} + +export async function encryptBackup( + config: WalletBackupConfState, + blob: WalletBackupContentV1, +): Promise<Uint8Array> { + throw Error("not implemented"); +} + +export function importBackup( + ws: InternalWalletState, + backupRequest: BackupRequest, +): Promise<void> { + throw Error("not implemented"); +} + +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), + }; +} + +/** + * 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): Promise<void> { + const providers = await ws.db.iter(Stores.backupProviders).toArray(); + const backupConfig = await provideBackupState(ws); + + logger.trace("got backup providers", providers); + const backupJsonContent = canonicalJson(await exportBackup(ws)); + logger.trace("backup JSON size", backupJsonContent.length); + const compressedContent = gzipSync(stringToBytes(backupJsonContent)); + logger.trace("backup compressed JSON size", compressedContent.length); + + const h = hash(compressedContent); + + for (const provider of providers) { + const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl); + logger.trace(`trying to upload backup to ${provider.baseUrl}`); + + const syncSig = await ws.cryptoApi.makeSyncSignature({ + newHash: encodeCrock(h), + oldHash: provider.lastBackupHash, + accountPriv: encodeCrock(accountKeyPair.eddsaPriv), + }); + + logger.trace(`sync signature is ${syncSig}`); + + const accountBackupUrl = new URL( + `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`, + provider.baseUrl, + ); + + const resp = await ws.http.fetch(accountBackupUrl.href, { + method: "POST", + body: compressedContent, + headers: { + "content-type": "application/octet-stream", + "sync-signature": syncSig, + "if-none-match": encodeCrock(h), + }, + }); + + logger.trace(`response status: ${resp.status}`); + + if (resp.status === HttpResponseStatus.PaymentRequired) { + logger.trace("payment required for backup"); + logger.trace(`headers: ${j2s(resp.headers)}`) + return; + } + + if (resp.status === HttpResponseStatus.Ok) { + return; + } + + logger.trace(`response body: ${j2s(await resp.json())}`); + } +} + +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; +} + +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; +} + +export const codecForAddBackupProviderRequest = (): Codec< + AddBackupProviderRequest +> => + buildCodecForObject<AddBackupProviderRequest>() + .property("backupProviderBaseUrl", codecForString()) + .build("AddBackupProviderRequest"); + +export async function addBackupProvider( + ws: InternalWalletState, + req: AddBackupProviderRequest, +): Promise<void> { + await provideBackupState(ws); + const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); + const oldProv = await ws.db.get(Stores.backupProviders, canonUrl); + if (oldProv) { + return; + } + const termsUrl = new URL("terms", canonUrl); + const resp = await ws.http.get(termsUrl.href); + const terms = await readSuccessResponseJsonOrThrow( + resp, + codecForSyncTermsOfServiceResponse(), + ); + await ws.db.put(Stores.backupProviders, { + active: true, + annualFee: terms.annual_fee, + baseUrl: canonUrl, + storageLimitInMegabytes: terms.storage_limit_in_megabytes, + supportedProtocolVersion: terms.version, + }); +} + +export async function removeBackupProvider( + syncProviderBaseUrl: string, +): Promise<void> {} + +export async function restoreFromRecoverySecret(): Promise<void> {} + +/** + * Information about one provider. + * + * We don't store the account key here, + * as that's derived from the wallet root key. + */ +export interface ProviderInfo { + syncProviderBaseUrl: string; + lastRemoteClock: number; + lastBackup?: Timestamp; +} + +export interface BackupInfo { + walletRootPub: string; + deviceId: string; + lastLocalClock: number; + providers: ProviderInfo[]; +} + +/** + * Get information about the current state of wallet backups. + */ +export function getBackupInfo(ws: InternalWalletState): Promise<BackupInfo> { + throw Error("not implemented"); +} |