diff --git a/packages/taler-wallet-android/src/index.ts b/packages/taler-wallet-android/src/index.ts index 07d15d584..bfda8ab71 100644 --- a/packages/taler-wallet-android/src/index.ts +++ b/packages/taler-wallet-android/src/index.ts @@ -38,6 +38,8 @@ import { WalletNotification, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, + bytesToString, + stringToBytes, } from "taler-wallet-core"; import fs from "fs"; @@ -57,6 +59,10 @@ export class AndroidHttpLib implements HttpRequestLibrary { constructor(private sendMessage: (m: string) => void) {} + fetch(url: string, opt?: HttpRequestOptions): Promise { + return this.nodeHttpLib.fetch(url, opt); + } + get(url: string, opt?: HttpRequestOptions): Promise { if (this.useNfcTunnel) { const myId = this.requestId++; @@ -120,6 +126,7 @@ export class AndroidHttpLib implements HttpRequestLibrary { requestMethod: "FIXME", json: async () => JSON.parse(msg.responseText), text: async () => msg.responseText, + bytes: async () => { throw Error("bytes() not supported for tunnel response") }, }; p.resolve(resp); } else { diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 72f9f3797..62e4c8988 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -58,6 +58,7 @@ "@types/node": "^14.14.7", "axios": "^0.21.0", "big-integer": "^1.6.48", + "fflate": "^0.3.10", "idb-bridge": "workspace:*", "source-map-support": "^0.5.19", "tslib": "^2.0.3" diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts index 286de5a17..29f3b02b2 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts @@ -42,6 +42,7 @@ import { PlanchetCreationResult, PlanchetCreationRequest, DepositInfo, + MakeSyncSignatureRequest, } from "../../types/walletTypes"; import * as timer from "../../util/timer"; @@ -455,4 +456,8 @@ export class CryptoApi { benchmark(repetitions: number): Promise { return this.doRpc("benchmark", 1, repetitions); } + + makeSyncSignature(req: MakeSyncSignatureRequest): Promise { + return this.doRpc("makeSyncSignature", 3, req); + } } diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts index 46ac7c8a6..41836fdfa 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts @@ -43,6 +43,7 @@ import { PlanchetCreationResult, PlanchetCreationRequest, DepositInfo, + MakeSyncSignatureRequest, } from "../../types/walletTypes"; import { AmountJson, Amounts } from "../../util/amounts"; import * as timer from "../../util/timer"; @@ -85,6 +86,7 @@ enum SignaturePurpose { WALLET_COIN_LINK = 1204, EXCHANGE_CONFIRM_RECOUP = 1039, EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041, + SYNC_BACKUP_UPLOAD = 1450, } function amountToBuffer(amount: AmountJson): Uint8Array { @@ -589,4 +591,20 @@ export class CryptoImplementation { }, }; } + + makeSyncSignature(req: MakeSyncSignatureRequest): string { + const hNew = decodeCrock(req.newHash); + let hOld: Uint8Array; + if (req.oldHash) { + hOld = decodeCrock(req.oldHash); + } else { + hOld = new Uint8Array(64); + } + const sigBlob = new SignaturePurposeBuilder(SignaturePurpose.SYNC_BACKUP_UPLOAD) + .put(hOld) + .put(hNew) + .build(); + const uploadSig = eddsaSign(sigBlob, decodeCrock(req.accountPriv)); + return encodeCrock(uploadSig); + } } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index ecc5509dc..6f5b6b453 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1,7 +1,12 @@ import { Stores } from "./types/dbTypes"; import { openDatabase, Database, Store, Index } from "./util/query"; -import { IDBFactory, IDBDatabase, IDBObjectStore, IDBTransaction } from "idb-bridge"; -import { Logger } from './util/logging'; +import { + IDBFactory, + IDBDatabase, + IDBObjectStore, + IDBTransaction, +} from "idb-bridge"; +import { Logger } from "./util/logging"; /** * Name of the Taler database. This is effectively the major @@ -18,7 +23,7 @@ const TALER_DB_NAME = "taler-wallet-prod-v1"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 2; +export const WALLET_DB_MINOR_VERSION = 3; const logger = new Logger("db.ts"); @@ -43,7 +48,9 @@ export function openTalerDatabase( const s = db.createObjectStore(si.name, si.storeParams); for (const indexName in si as any) { if ((si as any)[indexName] instanceof Index) { - const ii: Index = (si as any)[indexName]; + const ii: Index = (si as any)[ + indexName + ]; s.createIndex(ii.indexName, ii.keyPath, ii.options); } } @@ -59,7 +66,8 @@ export function openTalerDatabase( if ((Stores as any)[n] instanceof Store) { const si: Store = (Stores as any)[n]; let s: IDBObjectStore; - if ((si.storeParams?.versionAdded ?? 1) > oldVersion) { + const storeVersionAdded = si.storeParams?.versionAdded ?? 1; + if (storeVersionAdded > oldVersion) { s = db.createObjectStore(si.name, si.storeParams); } else { s = upgradeTransaction.objectStore(si.name); @@ -67,7 +75,8 @@ export function openTalerDatabase( for (const indexName in si as any) { if ((si as any)[indexName] instanceof Index) { const ii: Index = (si as any)[indexName]; - if ((ii.options?.versionAdded ?? 0) > oldVersion) { + const indexVersionAdded = ii.options?.versionAdded ?? 0; + if (indexVersionAdded > oldVersion || storeVersionAdded > oldVersion) { s.createIndex(ii.indexName, ii.keyPath, ii.options); } } diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts index ed4e0e1eb..5eefb24f9 100644 --- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts +++ b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts @@ -31,6 +31,7 @@ import { OperationFailedError, makeErrorDetails } from "../operations/errors"; import { TalerErrorCode } from "../TalerErrorCode"; import { URL } from "../util/url"; import { Logger } from "../util/logging"; +import { bytesToString } from '../crypto/talerCrypto'; const logger = new Logger("NodeHttpLib.ts"); @@ -48,12 +49,10 @@ export class NodeHttpLib implements HttpRequestLibrary { this.throttlingEnabled = enabled; } - private async req( - method: "POST" | "GET", - url: string, - body: any, - opt?: HttpRequestOptions, - ): Promise { + async fetch(url: string, opt?: HttpRequestOptions): Promise { + const method = opt?.method ?? "GET"; + let body = opt?.body; + const parsedUrl = new URL(url); if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { throw OperationFailedError.fromCode( @@ -75,7 +74,7 @@ export class NodeHttpLib implements HttpRequestLibrary { resp = await Axios({ method, url: url, - responseType: "text", + responseType: "arraybuffer", headers: opt?.headers, validateStatus: () => true, transformResponse: (x) => x, @@ -93,26 +92,18 @@ export class NodeHttpLib implements HttpRequestLibrary { ); } - const respText = resp.data; - if (typeof respText !== "string") { - throw new OperationFailedError( - makeErrorDetails( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - "unexpected response type", - { - httpStatusCode: resp.status, - requestUrl: url, - requestMethod: method, - }, - ), - ); + const makeText = async(): Promise => { + const respText = new Uint8Array(resp.data); + return bytesToString(respText); } + const makeJson = async (): Promise => { let responseJson; + const respText = await makeText(); try { responseJson = JSON.parse(respText); } catch (e) { - logger.trace(`invalid json: '${respText}'`); + logger.trace(`invalid json: '${resp.data}'`); throw new OperationFailedError( makeErrorDetails( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, @@ -141,6 +132,13 @@ export class NodeHttpLib implements HttpRequestLibrary { } return responseJson; }; + const makeBytes = async () => { + if (!(resp.data instanceof ArrayBuffer)) { + throw Error("expected array buffer"); + } + const buf = resp.data; + return buf; + }; const headers = new Headers(); for (const hn of Object.keys(resp.headers)) { headers.set(hn, resp.headers[hn]); @@ -150,13 +148,17 @@ export class NodeHttpLib implements HttpRequestLibrary { requestMethod: method, headers, status: resp.status, - text: async () => resp.data, + text: makeText, json: makeJson, + bytes: makeBytes, }; - } + } async get(url: string, opt?: HttpRequestOptions): Promise { - return this.req("GET", url, undefined, opt); + return this.fetch(url, { + method: "GET", + ...opt, + }); } async postJson( @@ -164,6 +166,10 @@ export class NodeHttpLib implements HttpRequestLibrary { body: any, opt?: HttpRequestOptions, ): Promise { - return this.req("POST", url, body, opt); + return this.fetch(url, { + method: "POST", + body, + ...opt, + }); } } 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 + */ + +/** + * Implementation of wallet backups (export/import/upload) and sync + * server management. + * + * @author Florian Dold + */ + +/** + * 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 { + const bs: ConfigRecord | 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 + | 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, +): Promise { + 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 { + 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 { + throw Error("not implemented"); +} + +export function importBackup( + ws: InternalWalletState, + backupRequest: BackupRequest, +): Promise { + 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 { + 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() + .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() + .property("backupProviderBaseUrl", codecForString()) + .build("AddBackupProviderRequest"); + +export async function addBackupProvider( + ws: InternalWalletState, + req: AddBackupProviderRequest, +): Promise { + 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 {} + +export async function restoreFromRecoverySecret(): Promise {} + +/** + * 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 { + throw Error("not implemented"); +} diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts new file mode 100644 index 000000000..72d0486b1 --- /dev/null +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -0,0 +1,215 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + 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 + */ + + +/** + * Type declarations for backup. + * + * Contains some redundancy with the other type declarations, + * as the backup schema must be very stable. + * + * @author Florian Dold + */ + +type BackupAmountString = string; + +/** + * Content of the backup. + * + * The contents of the wallet must be serialized in a deterministic + * way across implementations, so that the normalized backup content + * JSON is identical when the wallet's content is identical. + */ +export interface WalletBackupContentV1 { + schemaId: "gnu-taler-wallet-backup"; + + schemaVersion: 1; + + /** + * Monotonically increasing clock of the wallet, + * used to determine causality when merging backups. + */ + clock: number; + + walletRootPub: string; + + /** + * Per-exchange data sorted by exchange master public key. + */ + exchanges: BackupExchangeData[]; + + reserves: ReserveBackupData[]; + + coins: BackupCoin[]; + + planchets: BackupWithdrawalPlanchet[]; + + refreshSessions: BackupRefreshSession[]; +} + +export interface BackupRefreshSession { + +} + + +export interface BackupReserve { + reservePub: string; + reservePriv: string; + /** + * The exchange base URL. + */ + exchangeBaseUrl: string; + + bankConfirmUrl?: string; + + /** + * Wire information (as payto URI) for the bank account that + * transfered funds for this reserve. + */ + senderWire?: string; +} + +export interface ReserveBackupData { + /** + * The reserve public key. + */ + reservePub: string; + + /** + * The reserve private key. + */ + reservePriv: string; + + /** + * The exchange base URL. + */ + exchangeBaseUrl: string; + + instructedAmount: string; + + /** + * Wire information (as payto URI) for the bank account that + * transfered funds for this reserve. + */ + senderWire?: string; +} + +export interface BackupExchangeData { + exchangeBaseUrl: string; + exchangeMasterPub: string; + + /** + * ETag for last terms of service download. + */ + termsOfServiceAcceptedEtag: string | undefined; +} + + +export interface BackupWithdrawalPlanchet { + coinSource: BackupWithdrawCoinSource | BackupTipCoinSource; + blindingKey: string; + coinPriv: string; + coinPub: string; + denomPubHash: string; + + /** + * Base URL that identifies the exchange from which we are getting the + * coin. + */ + exchangeBaseUrl: string; +} + + +export enum BackupCoinSourceType { + Withdraw = "withdraw", + Refresh = "refresh", + Tip = "tip", +} + +export interface BackupWithdrawCoinSource { + type: BackupCoinSourceType.Withdraw; + withdrawalGroupId: string; + + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + + /** + * Reserve public key for the reserve we got this coin from. + */ + reservePub: string; +} + +export interface BackupRefreshCoinSource { + type: BackupCoinSourceType.Refresh; + oldCoinPub: string; +} + +export interface BackupTipCoinSource { + type: BackupCoinSourceType.Tip; + walletTipId: string; + coinIndex: number; +} + +export type BackupCoinSource = + | BackupWithdrawCoinSource + | BackupRefreshCoinSource + | BackupTipCoinSource; + +/** + * Coin that has been withdrawn and might have been + * (partially) spent. + */ +export interface BackupCoin { + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key of the coin. + */ + coinPriv: string; + + /** + * Where did the coin come from (withdrawal/refresh/tip)? + * Used for recouping coins. + */ + coinSource: BackupCoinSource; + + /** + * Is the coin still fresh + */ + fresh: boolean; + + /** + * Blinding key used when withdrawing the coin. + * Potentionally used again during payback. + */ + blindingKey: string; + + /** + * Amount that's left on the coin. + */ + currentAmount: BackupAmountString; + + /** + * Base URL that identifies the exchange from which we got the + * coin. + */ + exchangeBaseUrl: string; +} diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 349713ebc..26400dd3a 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -31,6 +31,7 @@ import { MerchantInfo, Product, InternationalizedString, + AmountString, } from "./talerTypes"; import { Index, Store } from "../util/query"; @@ -706,6 +707,10 @@ export enum CoinSourceType { export interface WithdrawCoinSource { type: CoinSourceType.Withdraw; + + /** + * Can be the empty string for orphaned coins. + */ withdrawalGroupId: string; /** @@ -1395,9 +1400,9 @@ export interface PurchaseRecord { * Configuration key/value entries to configure * the wallet. */ -export interface ConfigRecord { +export interface ConfigRecord { key: string; - value: any; + value: T; } export interface DenominationSelectionInfo { @@ -1531,6 +1536,30 @@ export enum ImportPayloadType { CoreSchema = "core-schema", } +export interface BackupProviderRecord { + baseUrl: string; + + supportedProtocolVersion: string; + + annualFee: AmountString; + + storageLimitInMegabytes: number; + + active: boolean; + + /** + * Hash of the last backup that we already + * merged. + */ + lastBackupHash?: string; + + /** + * Clock of the last backup that we already + * merged. + */ + lastBackupClock?: number; +} + class ExchangesStore extends Store<"exchanges", ExchangeRecord> { constructor() { super("exchanges", { keyPath: "baseUrl" }); @@ -1609,7 +1638,7 @@ class CurrenciesStore extends Store<"currencies", CurrencyRecord> { } } -class ConfigStore extends Store<"config", ConfigRecord> { +class ConfigStore extends Store<"config", ConfigRecord> { constructor() { super("config", { keyPath: "key" }); } @@ -1690,6 +1719,18 @@ class BankWithdrawUrisStore extends Store< } } + +/** + */ +class BackupProvidersStore extends Store< + "backupProviders", + BackupProviderRecord +> { + constructor() { + super("backupProviders", { keyPath: "baseUrl", versionAdded: 3 }); + } +} + /** * The stores and indices for the wallet database. */ @@ -1716,4 +1757,5 @@ export const Stores = { withdrawalGroups: new WithdrawalGroupsStore(), planchets: new PlanchetsStore(), bankWithdrawUris: new BankWithdrawUrisStore(), + backupProviders: new BackupProvidersStore(), }; diff --git a/packages/taler-wallet-core/src/types/schemacore.ts b/packages/taler-wallet-core/src/types/schemacore.ts deleted file mode 100644 index 820f68d18..000000000 --- a/packages/taler-wallet-core/src/types/schemacore.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 Taler Systems S.A. - - 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 - */ - -/** - * Core of the wallet's schema, used for painless export, import - * and schema migration. - * - * If this schema is extended, it must be extended in a completely - * backwards-compatible way. - */ - -interface CoreCoin { - exchangeBaseUrl: string; - coinPub: string; - coinPriv: string; - amountRemaining: string; -} - -interface CorePurchase { - noncePub: string; - noncePriv: string; - paySig: string; - contractTerms: any; -} - -interface CoreReserve { - reservePub: string; - reservePriv: string; - exchangeBaseUrl: string; -} - -interface SchemaCore { - coins: CoreCoin[]; - purchases: CorePurchase[]; - - /** - * Schema version (of full schema) of wallet that exported the core schema. - */ - versionExporter: number; - - /** - * Schema version of the database that has been exported to the core schema - */ - versionSourceDatabase: number; -} diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 7940497a3..ab7d3b4db 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -885,6 +885,15 @@ export const withdrawTestBalanceDefaults = { exchangeBaseUrl: "https://exchange.test.taler.net/", }; +/** + * Request to the crypto worker to make a sync signature. + */ +export interface MakeSyncSignatureRequest { + accountPriv: string; + oldHash: string | undefined; + newHash: string; +} + export const codecForWithdrawTestBalance = (): Codec< WithdrawTestBalanceRequest > => diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index 1a2459f7e..1ec9c2f50 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -17,6 +17,8 @@ /** * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. * Allows for easy mocking for test cases. + * + * The API is inspired by the HTML5 fetch API. */ /** @@ -47,16 +49,20 @@ export interface HttpResponse { headers: Headers; json(): Promise; text(): Promise; + bytes(): Promise; } export interface HttpRequestOptions { + method?: "POST" | "PUT" | "GET"; headers?: { [name: string]: string }; timeout?: Duration; + body?: string | ArrayBuffer | ArrayBufferView; } export enum HttpResponseStatus { Ok = 200, Gone = 210, + PaymentRequired = 402, } /** @@ -82,6 +88,12 @@ export class Headers { this.headerMap.set(normalizedName, value); } } + + toJSON(): any { + const m: Record = {}; + this.headerMap.forEach((v, k) => m[k] = v); + return m; + } } /** @@ -104,6 +116,14 @@ export interface HttpRequestLibrary { body: any, opt?: HttpRequestOptions, ): Promise; + + /** + * Make an HTTP POST request with a JSON body. + */ + fetch( + url: string, + opt?: HttpRequestOptions, + ): Promise; } type TalerErrorResponse = { diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts index 08572fbd2..beb14cad0 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/util/query.ts @@ -595,8 +595,8 @@ export class Database { } async put>( - store: St extends Store ? Store : never, - value: St extends Store ? R : never, + store: St, + value: StoreContent, key?: any, ): Promise { const tx = this.db.transaction([store.name], "readwrite"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 1140a13c3..4491a167b 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -152,6 +152,7 @@ import { testPay, } from "./operations/testing"; import { TalerErrorCode } from "."; +import { addBackupProvider, codecForAddBackupProviderRequest, runBackupCycle, exportBackup } from './operations/backup'; const builtinCurrencies: CurrencyRecord[] = [ { @@ -1074,6 +1075,18 @@ export class Wallet { await this.acceptTip(req.walletTipId); return {}; } + case "exportBackup": { + return exportBackup(this.ws); + } + case "addBackupProvider": { + const req = codecForAddBackupProviderRequest().decode(payload); + await addBackupProvider(this.ws, req); + return {}; + } + case "runBackupCycle": { + await runBackupCycle(this.ws); + return {}; + } } throw OperationFailedError.fromCode( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts b/packages/taler-wallet-webextension/src/browserHttpLib.ts index 96484bc97..bfc855633 100644 --- a/packages/taler-wallet-webextension/src/browserHttpLib.ts +++ b/packages/taler-wallet-webextension/src/browserHttpLib.ts @@ -34,12 +34,9 @@ const logger = new Logger("browserHttpLib"); * browser's XMLHttpRequest. */ export class BrowserHttpLib implements HttpRequestLibrary { - private req( - method: string, - url: string, - requestBody?: any, - options?: HttpRequestOptions, - ): Promise { + fetch(url: string, options?: HttpRequestOptions): Promise { + const method = options?.method ?? "GET"; + let requestBody = options?.body; return new Promise((resolve, reject) => { const myRequest = new XMLHttpRequest(); myRequest.open(method, url); @@ -48,7 +45,7 @@ export class BrowserHttpLib implements HttpRequestLibrary { myRequest.setRequestHeader(headerName, options.headers[headerName]); } } - myRequest.setRequestHeader; + myRequest.responseType = "arraybuffer"; if (requestBody) { myRequest.send(requestBody); } else { @@ -130,6 +127,7 @@ export class BrowserHttpLib implements HttpRequestLibrary { requestMethod: method, json: makeJson, text: async () => myRequest.responseText, + bytes: async () => myRequest.response, }; resolve(resp); } @@ -138,15 +136,22 @@ export class BrowserHttpLib implements HttpRequestLibrary { } get(url: string, opt?: HttpRequestOptions): Promise { - return this.req("GET", url, undefined, opt); + return this.fetch(url, { + method: "GET", + ...opt, + }); } postJson( url: string, - body: unknown, + body: any, opt?: HttpRequestOptions, ): Promise { - return this.req("POST", url, JSON.stringify(body), opt); + return this.fetch(url, { + method: "POST", + body, + ...opt, + }); } stop(): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e233d5396..a81089d8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,7 @@ importers: '@types/node': 14.14.7 axios: 0.21.0 big-integer: 1.6.48 + fflate: 0.3.10 idb-bridge: 'link:../idb-bridge' source-map-support: 0.5.19 tslib: 2.0.3 @@ -167,6 +168,7 @@ importers: eslint-plugin-react: ^7.21.5 eslint-plugin-react-hooks: ^4.2.0 esm: ^3.2.25 + fflate: ^0.3.10 idb-bridge: 'workspace:*' jed: ^1.1.1 nyc: ^15.1.0 @@ -2338,6 +2340,10 @@ packages: dev: true resolution: integrity: sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w== + /fflate/0.3.10: + dev: false + resolution: + integrity: sha512-s5j69APkUPPbzdI20Ix4pPtQP+1Qi58YcFRpE7aO/P1kEywUYjbl2RjZRVEMdnySO9pr4MB0BHPbxkiahrtD/Q== /figures/3.2.0: dependencies: escape-string-regexp: 1.0.5