summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/taler-wallet-android/src/index.ts7
-rw-r--r--packages/taler-wallet-core/package.json1
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts5
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts18
-rw-r--r--packages/taler-wallet-core/src/db.ts21
-rw-r--r--packages/taler-wallet-core/src/headless/NodeHttpLib.ts56
-rw-r--r--packages/taler-wallet-core/src/operations/backup.ts402
-rw-r--r--packages/taler-wallet-core/src/types/backupTypes.ts215
-rw-r--r--packages/taler-wallet-core/src/types/dbTypes.ts48
-rw-r--r--packages/taler-wallet-core/src/types/schemacore.ts58
-rw-r--r--packages/taler-wallet-core/src/types/walletTypes.ts9
-rw-r--r--packages/taler-wallet-core/src/util/http.ts20
-rw-r--r--packages/taler-wallet-core/src/util/query.ts4
-rw-r--r--packages/taler-wallet-core/src/wallet.ts13
-rw-r--r--packages/taler-wallet-webextension/src/browserHttpLib.ts25
-rw-r--r--pnpm-lock.yaml6
16 files changed, 804 insertions, 104 deletions
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<HttpResponse> {
+ return this.nodeHttpLib.fetch(url, opt);
+ }
+
get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
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<BenchmarkResult> {
return this.doRpc<BenchmarkResult>("benchmark", 1, repetitions);
}
+
+ makeSyncSignature(req: MakeSyncSignatureRequest): Promise<string> {
+ return this.doRpc<string>("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<string, string, any, any> = (si as any)[indexName];
+ const ii: Index<string, string, any, any> = (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<string, any> = (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<string, string, any, any> = (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<HttpResponse> {
+ async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ 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<string> => {
+ const respText = new Uint8Array(resp.data);
+ return bytesToString(respText);
}
+
const makeJson = async (): Promise<any> => {
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<HttpResponse> {
- 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<HttpResponse> {
- 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 <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");
+}
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 <http://www.gnu.org/licenses/>
+ */
+
+
+/**
+ * Type declarations for backup.
+ *
+ * Contains some redundancy with the other type declarations,
+ * as the backup schema must be very stable.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+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<T> {
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<any>> {
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 <http://www.gnu.org/licenses/>
- */
-
-/**
- * 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<any>;
text(): Promise<string>;
+ bytes(): Promise<ArrayBuffer>;
}
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<string, string> = {};
+ this.headerMap.forEach((v, k) => m[k] = v);
+ return m;
+ }
}
/**
@@ -104,6 +116,14 @@ export interface HttpRequestLibrary {
body: any,
opt?: HttpRequestOptions,
): Promise<HttpResponse>;
+
+ /**
+ * Make an HTTP POST request with a JSON body.
+ */
+ fetch(
+ url: string,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse>;
}
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<St extends Store<string, any>>(
- store: St extends Store<infer N, infer R> ? Store<N, R> : never,
- value: St extends Store<any, infer R> ? R : never,
+ store: St,
+ value: StoreContent<St>,
key?: any,
): Promise<any> {
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<HttpResponse> {
+ fetch(url: string, options?: HttpRequestOptions): Promise<HttpResponse> {
+ const method = options?.method ?? "GET";
+ let requestBody = options?.body;
return new Promise<HttpResponse>((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<HttpResponse> {
- return this.req("GET", url, undefined, opt);
+ return this.fetch(url, {
+ method: "GET",
+ ...opt,
+ });
}
postJson(
url: string,
- body: unknown,
+ body: any,
opt?: HttpRequestOptions,
): Promise<HttpResponse> {
- 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