backup WIP
This commit is contained in:
parent
0828e65f88
commit
89f1a281fe
@ -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 {
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
402
packages/taler-wallet-core/src/operations/backup.ts
Normal file
402
packages/taler-wallet-core/src/operations/backup.ts
Normal 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");
|
||||
}
|
215
packages/taler-wallet-core/src/types/backupTypes.ts
Normal file
215
packages/taler-wallet-core/src/types/backupTypes.ts
Normal 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;
|
||||
}
|
@ -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(),
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
> =>
|
||||
|
@ -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 = {
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user