backup WIP

This commit is contained in:
Florian Dold 2020-12-02 14:55:04 +01:00
parent 0828e65f88
commit 89f1a281fe
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
16 changed files with 804 additions and 104 deletions

View File

@ -38,6 +38,8 @@ import {
WalletNotification, WalletNotification,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_MERCHANT_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION,
bytesToString,
stringToBytes,
} from "taler-wallet-core"; } from "taler-wallet-core";
import fs from "fs"; import fs from "fs";
@ -57,6 +59,10 @@ export class AndroidHttpLib implements HttpRequestLibrary {
constructor(private sendMessage: (m: string) => void) {} 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> { get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
if (this.useNfcTunnel) { if (this.useNfcTunnel) {
const myId = this.requestId++; const myId = this.requestId++;
@ -120,6 +126,7 @@ export class AndroidHttpLib implements HttpRequestLibrary {
requestMethod: "FIXME", requestMethod: "FIXME",
json: async () => JSON.parse(msg.responseText), json: async () => JSON.parse(msg.responseText),
text: async () => msg.responseText, text: async () => msg.responseText,
bytes: async () => { throw Error("bytes() not supported for tunnel response") },
}; };
p.resolve(resp); p.resolve(resp);
} else { } else {

View File

@ -58,6 +58,7 @@
"@types/node": "^14.14.7", "@types/node": "^14.14.7",
"axios": "^0.21.0", "axios": "^0.21.0",
"big-integer": "^1.6.48", "big-integer": "^1.6.48",
"fflate": "^0.3.10",
"idb-bridge": "workspace:*", "idb-bridge": "workspace:*",
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"tslib": "^2.0.3" "tslib": "^2.0.3"

View File

@ -42,6 +42,7 @@ import {
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
DepositInfo, DepositInfo,
MakeSyncSignatureRequest,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import * as timer from "../../util/timer"; import * as timer from "../../util/timer";
@ -455,4 +456,8 @@ export class CryptoApi {
benchmark(repetitions: number): Promise<BenchmarkResult> { benchmark(repetitions: number): Promise<BenchmarkResult> {
return this.doRpc<BenchmarkResult>("benchmark", 1, repetitions); return this.doRpc<BenchmarkResult>("benchmark", 1, repetitions);
} }
makeSyncSignature(req: MakeSyncSignatureRequest): Promise<string> {
return this.doRpc<string>("makeSyncSignature", 3, req);
}
} }

View File

@ -43,6 +43,7 @@ import {
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
DepositInfo, DepositInfo,
MakeSyncSignatureRequest,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import { AmountJson, Amounts } from "../../util/amounts"; import { AmountJson, Amounts } from "../../util/amounts";
import * as timer from "../../util/timer"; import * as timer from "../../util/timer";
@ -85,6 +86,7 @@ enum SignaturePurpose {
WALLET_COIN_LINK = 1204, WALLET_COIN_LINK = 1204,
EXCHANGE_CONFIRM_RECOUP = 1039, EXCHANGE_CONFIRM_RECOUP = 1039,
EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041, EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
SYNC_BACKUP_UPLOAD = 1450,
} }
function amountToBuffer(amount: AmountJson): Uint8Array { 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);
}
} }

View File

@ -1,7 +1,12 @@
import { Stores } from "./types/dbTypes"; import { Stores } from "./types/dbTypes";
import { openDatabase, Database, Store, Index } from "./util/query"; import { openDatabase, Database, Store, Index } from "./util/query";
import { IDBFactory, IDBDatabase, IDBObjectStore, IDBTransaction } from "idb-bridge"; import {
import { Logger } from './util/logging'; IDBFactory,
IDBDatabase,
IDBObjectStore,
IDBTransaction,
} from "idb-bridge";
import { Logger } from "./util/logging";
/** /**
* Name of the Taler database. This is effectively the major * 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 * backwards-compatible way or object stores and indices
* are added. * are added.
*/ */
export const WALLET_DB_MINOR_VERSION = 2; export const WALLET_DB_MINOR_VERSION = 3;
const logger = new Logger("db.ts"); const logger = new Logger("db.ts");
@ -43,7 +48,9 @@ export function openTalerDatabase(
const s = db.createObjectStore(si.name, si.storeParams); const s = db.createObjectStore(si.name, si.storeParams);
for (const indexName in si as any) { for (const indexName in si as any) {
if ((si as any)[indexName] instanceof Index) { 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); s.createIndex(ii.indexName, ii.keyPath, ii.options);
} }
} }
@ -59,7 +66,8 @@ export function openTalerDatabase(
if ((Stores as any)[n] instanceof Store) { if ((Stores as any)[n] instanceof Store) {
const si: Store<string, any> = (Stores as any)[n]; const si: Store<string, any> = (Stores as any)[n];
let s: IDBObjectStore; 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); s = db.createObjectStore(si.name, si.storeParams);
} else { } else {
s = upgradeTransaction.objectStore(si.name); s = upgradeTransaction.objectStore(si.name);
@ -67,7 +75,8 @@ export function openTalerDatabase(
for (const indexName in si as any) { for (const indexName in si as any) {
if ((si as any)[indexName] instanceof Index) { 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];
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); s.createIndex(ii.indexName, ii.keyPath, ii.options);
} }
} }

View File

@ -31,6 +31,7 @@ import { OperationFailedError, makeErrorDetails } from "../operations/errors";
import { TalerErrorCode } from "../TalerErrorCode"; import { TalerErrorCode } from "../TalerErrorCode";
import { URL } from "../util/url"; import { URL } from "../util/url";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { bytesToString } from '../crypto/talerCrypto';
const logger = new Logger("NodeHttpLib.ts"); const logger = new Logger("NodeHttpLib.ts");
@ -48,12 +49,10 @@ export class NodeHttpLib implements HttpRequestLibrary {
this.throttlingEnabled = enabled; this.throttlingEnabled = enabled;
} }
private async req( async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
method: "POST" | "GET", const method = opt?.method ?? "GET";
url: string, let body = opt?.body;
body: any,
opt?: HttpRequestOptions,
): Promise<HttpResponse> {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
if (this.throttlingEnabled && this.throttle.applyThrottle(url)) { if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
throw OperationFailedError.fromCode( throw OperationFailedError.fromCode(
@ -75,7 +74,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
resp = await Axios({ resp = await Axios({
method, method,
url: url, url: url,
responseType: "text", responseType: "arraybuffer",
headers: opt?.headers, headers: opt?.headers,
validateStatus: () => true, validateStatus: () => true,
transformResponse: (x) => x, transformResponse: (x) => x,
@ -93,26 +92,18 @@ export class NodeHttpLib implements HttpRequestLibrary {
); );
} }
const respText = resp.data; const makeText = async(): Promise<string> => {
if (typeof respText !== "string") { const respText = new Uint8Array(resp.data);
throw new OperationFailedError( return bytesToString(respText);
makeErrorDetails(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"unexpected response type",
{
httpStatusCode: resp.status,
requestUrl: url,
requestMethod: method,
},
),
);
} }
const makeJson = async (): Promise<any> => { const makeJson = async (): Promise<any> => {
let responseJson; let responseJson;
const respText = await makeText();
try { try {
responseJson = JSON.parse(respText); responseJson = JSON.parse(respText);
} catch (e) { } catch (e) {
logger.trace(`invalid json: '${respText}'`); logger.trace(`invalid json: '${resp.data}'`);
throw new OperationFailedError( throw new OperationFailedError(
makeErrorDetails( makeErrorDetails(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
@ -141,6 +132,13 @@ export class NodeHttpLib implements HttpRequestLibrary {
} }
return responseJson; 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(); const headers = new Headers();
for (const hn of Object.keys(resp.headers)) { for (const hn of Object.keys(resp.headers)) {
headers.set(hn, resp.headers[hn]); headers.set(hn, resp.headers[hn]);
@ -150,13 +148,17 @@ export class NodeHttpLib implements HttpRequestLibrary {
requestMethod: method, requestMethod: method,
headers, headers,
status: resp.status, status: resp.status,
text: async () => resp.data, text: makeText,
json: makeJson, json: makeJson,
bytes: makeBytes,
}; };
}
}
async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
return this.req("GET", url, undefined, opt); return this.fetch(url, {
method: "GET",
...opt,
});
} }
async postJson( async postJson(
@ -164,6 +166,10 @@ export class NodeHttpLib implements HttpRequestLibrary {
body: any, body: any,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
): Promise<HttpResponse> { ): Promise<HttpResponse> {
return this.req("POST", url, body, opt); return this.fetch(url, {
method: "POST",
body,
...opt,
});
} }
} }

View File

@ -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");
}

View File

@ -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;
}

View File

@ -31,6 +31,7 @@ import {
MerchantInfo, MerchantInfo,
Product, Product,
InternationalizedString, InternationalizedString,
AmountString,
} from "./talerTypes"; } from "./talerTypes";
import { Index, Store } from "../util/query"; import { Index, Store } from "../util/query";
@ -706,6 +707,10 @@ export enum CoinSourceType {
export interface WithdrawCoinSource { export interface WithdrawCoinSource {
type: CoinSourceType.Withdraw; type: CoinSourceType.Withdraw;
/**
* Can be the empty string for orphaned coins.
*/
withdrawalGroupId: string; withdrawalGroupId: string;
/** /**
@ -1395,9 +1400,9 @@ export interface PurchaseRecord {
* Configuration key/value entries to configure * Configuration key/value entries to configure
* the wallet. * the wallet.
*/ */
export interface ConfigRecord { export interface ConfigRecord<T> {
key: string; key: string;
value: any; value: T;
} }
export interface DenominationSelectionInfo { export interface DenominationSelectionInfo {
@ -1531,6 +1536,30 @@ export enum ImportPayloadType {
CoreSchema = "core-schema", 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> { class ExchangesStore extends Store<"exchanges", ExchangeRecord> {
constructor() { constructor() {
super("exchanges", { keyPath: "baseUrl" }); 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() { constructor() {
super("config", { keyPath: "key" }); 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. * The stores and indices for the wallet database.
*/ */
@ -1716,4 +1757,5 @@ export const Stores = {
withdrawalGroups: new WithdrawalGroupsStore(), withdrawalGroups: new WithdrawalGroupsStore(),
planchets: new PlanchetsStore(), planchets: new PlanchetsStore(),
bankWithdrawUris: new BankWithdrawUrisStore(), bankWithdrawUris: new BankWithdrawUrisStore(),
backupProviders: new BackupProvidersStore(),
}; };

View File

@ -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;
}

View File

@ -885,6 +885,15 @@ export const withdrawTestBalanceDefaults = {
exchangeBaseUrl: "https://exchange.test.taler.net/", 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< export const codecForWithdrawTestBalance = (): Codec<
WithdrawTestBalanceRequest WithdrawTestBalanceRequest
> => > =>

View File

@ -17,6 +17,8 @@
/** /**
* Helpers for doing XMLHttpRequest-s that are based on ES6 promises. * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
* Allows for easy mocking for test cases. * 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; headers: Headers;
json(): Promise<any>; json(): Promise<any>;
text(): Promise<string>; text(): Promise<string>;
bytes(): Promise<ArrayBuffer>;
} }
export interface HttpRequestOptions { export interface HttpRequestOptions {
method?: "POST" | "PUT" | "GET";
headers?: { [name: string]: string }; headers?: { [name: string]: string };
timeout?: Duration; timeout?: Duration;
body?: string | ArrayBuffer | ArrayBufferView;
} }
export enum HttpResponseStatus { export enum HttpResponseStatus {
Ok = 200, Ok = 200,
Gone = 210, Gone = 210,
PaymentRequired = 402,
} }
/** /**
@ -82,6 +88,12 @@ export class Headers {
this.headerMap.set(normalizedName, value); 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, body: any,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
): Promise<HttpResponse>; ): Promise<HttpResponse>;
/**
* Make an HTTP POST request with a JSON body.
*/
fetch(
url: string,
opt?: HttpRequestOptions,
): Promise<HttpResponse>;
} }
type TalerErrorResponse = { type TalerErrorResponse = {

View File

@ -595,8 +595,8 @@ export class Database {
} }
async put<St extends Store<string, any>>( async put<St extends Store<string, any>>(
store: St extends Store<infer N, infer R> ? Store<N, R> : never, store: St,
value: St extends Store<any, infer R> ? R : never, value: StoreContent<St>,
key?: any, key?: any,
): Promise<any> { ): Promise<any> {
const tx = this.db.transaction([store.name], "readwrite"); const tx = this.db.transaction([store.name], "readwrite");

View File

@ -152,6 +152,7 @@ import {
testPay, testPay,
} from "./operations/testing"; } from "./operations/testing";
import { TalerErrorCode } from "."; import { TalerErrorCode } from ".";
import { addBackupProvider, codecForAddBackupProviderRequest, runBackupCycle, exportBackup } from './operations/backup';
const builtinCurrencies: CurrencyRecord[] = [ const builtinCurrencies: CurrencyRecord[] = [
{ {
@ -1074,6 +1075,18 @@ export class Wallet {
await this.acceptTip(req.walletTipId); await this.acceptTip(req.walletTipId);
return {}; 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( throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,

View File

@ -34,12 +34,9 @@ const logger = new Logger("browserHttpLib");
* browser's XMLHttpRequest. * browser's XMLHttpRequest.
*/ */
export class BrowserHttpLib implements HttpRequestLibrary { export class BrowserHttpLib implements HttpRequestLibrary {
private req( fetch(url: string, options?: HttpRequestOptions): Promise<HttpResponse> {
method: string, const method = options?.method ?? "GET";
url: string, let requestBody = options?.body;
requestBody?: any,
options?: HttpRequestOptions,
): Promise<HttpResponse> {
return new Promise<HttpResponse>((resolve, reject) => { return new Promise<HttpResponse>((resolve, reject) => {
const myRequest = new XMLHttpRequest(); const myRequest = new XMLHttpRequest();
myRequest.open(method, url); myRequest.open(method, url);
@ -48,7 +45,7 @@ export class BrowserHttpLib implements HttpRequestLibrary {
myRequest.setRequestHeader(headerName, options.headers[headerName]); myRequest.setRequestHeader(headerName, options.headers[headerName]);
} }
} }
myRequest.setRequestHeader; myRequest.responseType = "arraybuffer";
if (requestBody) { if (requestBody) {
myRequest.send(requestBody); myRequest.send(requestBody);
} else { } else {
@ -130,6 +127,7 @@ export class BrowserHttpLib implements HttpRequestLibrary {
requestMethod: method, requestMethod: method,
json: makeJson, json: makeJson,
text: async () => myRequest.responseText, text: async () => myRequest.responseText,
bytes: async () => myRequest.response,
}; };
resolve(resp); resolve(resp);
} }
@ -138,15 +136,22 @@ export class BrowserHttpLib implements HttpRequestLibrary {
} }
get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
return this.req("GET", url, undefined, opt); return this.fetch(url, {
method: "GET",
...opt,
});
} }
postJson( postJson(
url: string, url: string,
body: unknown, body: any,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
): Promise<HttpResponse> { ): Promise<HttpResponse> {
return this.req("POST", url, JSON.stringify(body), opt); return this.fetch(url, {
method: "POST",
body,
...opt,
});
} }
stop(): void { stop(): void {

View File

@ -124,6 +124,7 @@ importers:
'@types/node': 14.14.7 '@types/node': 14.14.7
axios: 0.21.0 axios: 0.21.0
big-integer: 1.6.48 big-integer: 1.6.48
fflate: 0.3.10
idb-bridge: 'link:../idb-bridge' idb-bridge: 'link:../idb-bridge'
source-map-support: 0.5.19 source-map-support: 0.5.19
tslib: 2.0.3 tslib: 2.0.3
@ -167,6 +168,7 @@ importers:
eslint-plugin-react: ^7.21.5 eslint-plugin-react: ^7.21.5
eslint-plugin-react-hooks: ^4.2.0 eslint-plugin-react-hooks: ^4.2.0
esm: ^3.2.25 esm: ^3.2.25
fflate: ^0.3.10
idb-bridge: 'workspace:*' idb-bridge: 'workspace:*'
jed: ^1.1.1 jed: ^1.1.1
nyc: ^15.1.0 nyc: ^15.1.0
@ -2338,6 +2340,10 @@ packages:
dev: true dev: true
resolution: resolution:
integrity: sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w== integrity: sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w==
/fflate/0.3.10:
dev: false
resolution:
integrity: sha512-s5j69APkUPPbzdI20Ix4pPtQP+1Qi58YcFRpE7aO/P1kEywUYjbl2RjZRVEMdnySO9pr4MB0BHPbxkiahrtD/Q==
/figures/3.2.0: /figures/3.2.0:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5