/*
This file is part of GNU Taler
(C) 2020 Taler Systems SA
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
/**
* Implementation of wallet backups (export/import/upload) and sync
* server management.
*
* @author Florian Dold
*/
/**
* Imports.
*/
import { InternalWalletState } from "./state";
import {
BackupCoin,
BackupCoinSource,
BackupCoinSourceType,
BackupExchangeData,
WalletBackupContentV1,
} from "../types/backupTypes";
import { TransactionHandle } from "../util/query";
import {
CoinSourceType,
CoinStatus,
ConfigRecord,
Stores,
} from "../types/dbTypes";
import { checkDbInvariant } from "../util/invariants";
import { Amounts, codecForAmountString } from "../util/amounts";
import {
decodeCrock,
eddsaGetPublic,
EddsaKeyPair,
encodeCrock,
getRandomBytes,
hash,
stringToBytes,
} from "../crypto/talerCrypto";
import { canonicalizeBaseUrl, canonicalJson, j2s } from "../util/helpers";
import { Timestamp } from "../util/time";
import { URL } from "../util/url";
import { AmountString } from "../types/talerTypes";
import {
buildCodecForObject,
Codec,
codecForNumber,
codecForString,
} from "../util/codec";
import {
HttpResponseStatus,
readSuccessResponseJsonOrThrow,
} from "../util/http";
import { Logger } from "../util/logging";
import { gzipSync } from "fflate";
import { sign_keyPair_fromSeed } from "../crypto/primitives/nacl-fast";
import { kdf } from "../crypto/primitives/kdf";
interface WalletBackupConfState {
walletRootPub: string;
walletRootPriv: string;
clock: number;
lastBackupHash?: string;
lastBackupNonce?: string;
}
const WALLET_BACKUP_STATE_KEY = "walletBackupState";
const logger = new Logger("operations/backup.ts");
async function provideBackupState(
ws: InternalWalletState,
): Promise {
const bs: ConfigRecord | undefined = await ws.db.get(
Stores.config,
WALLET_BACKUP_STATE_KEY,
);
if (bs) {
return bs.value;
}
// We need to generate the key outside of the transaction
// due to how IndexedDB works.
const k = await ws.cryptoApi.createEddsaKeypair();
return await ws.db.runWithWriteTransaction([Stores.config], async (tx) => {
let backupStateEntry:
| ConfigRecord
| undefined = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
if (!backupStateEntry) {
backupStateEntry = {
key: WALLET_BACKUP_STATE_KEY,
value: {
walletRootPub: k.pub,
walletRootPriv: k.priv,
clock: 0,
lastBackupHash: undefined,
},
};
await tx.put(Stores.config, backupStateEntry);
}
return backupStateEntry.value;
});
}
async function getWalletBackupState(
ws: InternalWalletState,
tx: TransactionHandle,
): Promise {
let bs = await tx.get(Stores.config, WALLET_BACKUP_STATE_KEY);
checkDbInvariant(!!bs, "wallet backup state should be in DB");
return bs.value;
}
export async function exportBackup(
ws: InternalWalletState,
): Promise {
await provideBackupState(ws);
return ws.db.runWithWriteTransaction(
[Stores.config, Stores.exchanges, Stores.coins],
async (tx) => {
const bs = await getWalletBackupState(ws, tx);
const exchanges: BackupExchangeData[] = [];
const coins: BackupCoin[] = [];
await tx.iter(Stores.exchanges).forEach((ex) => {
if (!ex.details) {
return;
}
exchanges.push({
exchangeBaseUrl: ex.baseUrl,
exchangeMasterPub: ex.details?.masterPublicKey,
termsOfServiceAcceptedEtag: ex.termsOfServiceAcceptedEtag,
});
});
await tx.iter(Stores.coins).forEach((coin) => {
let bcs: BackupCoinSource;
switch (coin.coinSource.type) {
case CoinSourceType.Refresh:
bcs = {
type: BackupCoinSourceType.Refresh,
oldCoinPub: coin.coinSource.oldCoinPub,
};
break;
case CoinSourceType.Tip:
bcs = {
type: BackupCoinSourceType.Tip,
coinIndex: coin.coinSource.coinIndex,
walletTipId: coin.coinSource.walletTipId,
};
break;
case CoinSourceType.Withdraw:
bcs = {
type: BackupCoinSourceType.Withdraw,
coinIndex: coin.coinSource.coinIndex,
reservePub: coin.coinSource.reservePub,
withdrawalGroupId: coin.coinSource.withdrawalGroupId,
};
break;
}
coins.push({
exchangeBaseUrl: coin.exchangeBaseUrl,
blindingKey: coin.blindingKey,
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
coinSource: bcs,
currentAmount: Amounts.stringify(coin.currentAmount),
fresh: coin.status === CoinStatus.Fresh,
});
});
const backupBlob: WalletBackupContentV1 = {
schemaId: "gnu-taler-wallet-backup",
schemaVersion: 1,
clock: bs.clock,
coins: coins,
exchanges: exchanges,
planchets: [],
refreshSessions: [],
reserves: [],
walletRootPub: bs.walletRootPub,
};
// If the backup changed, we increment our clock.
let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
if (h != bs.lastBackupHash) {
backupBlob.clock = ++bs.clock;
bs.lastBackupHash = encodeCrock(
hash(stringToBytes(canonicalJson(backupBlob))),
);
bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
await tx.put(Stores.config, {
key: WALLET_BACKUP_STATE_KEY,
value: bs,
});
}
return backupBlob;
},
);
}
export interface BackupRequest {
backupBlob: any;
}
export async function encryptBackup(
config: WalletBackupConfState,
blob: WalletBackupContentV1,
): Promise {
throw Error("not implemented");
}
export function importBackup(
ws: InternalWalletState,
backupRequest: BackupRequest,
): Promise {
throw Error("not implemented");
}
function deriveAccountKeyPair(
bc: WalletBackupConfState,
providerUrl: string,
): EddsaKeyPair {
const privateKey = kdf(
32,
decodeCrock(bc.walletRootPriv),
stringToBytes("taler-sync-account-key-salt"),
stringToBytes(providerUrl),
);
return {
eddsaPriv: privateKey,
eddsaPub: eddsaGetPublic(privateKey),
};
}
/**
* Do one backup cycle that consists of:
* 1. Exporting a backup and try to upload it.
* Stop if this step succeeds.
* 2. Download, verify and import backups from connected sync accounts.
* 3. Upload the updated backup blob.
*/
export async function runBackupCycle(ws: InternalWalletState): Promise {
const providers = await ws.db.iter(Stores.backupProviders).toArray();
const backupConfig = await provideBackupState(ws);
logger.trace("got backup providers", providers);
const backupJsonContent = canonicalJson(await exportBackup(ws));
logger.trace("backup JSON size", backupJsonContent.length);
const compressedContent = gzipSync(stringToBytes(backupJsonContent));
logger.trace("backup compressed JSON size", compressedContent.length);
const h = hash(compressedContent);
for (const provider of providers) {
const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
logger.trace(`trying to upload backup to ${provider.baseUrl}`);
const syncSig = await ws.cryptoApi.makeSyncSignature({
newHash: encodeCrock(h),
oldHash: provider.lastBackupHash,
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
});
logger.trace(`sync signature is ${syncSig}`);
const accountBackupUrl = new URL(
`/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
provider.baseUrl,
);
const resp = await ws.http.fetch(accountBackupUrl.href, {
method: "POST",
body: compressedContent,
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSig,
"if-none-match": encodeCrock(h),
},
});
logger.trace(`response status: ${resp.status}`);
if (resp.status === HttpResponseStatus.PaymentRequired) {
logger.trace("payment required for backup");
logger.trace(`headers: ${j2s(resp.headers)}`)
return;
}
if (resp.status === HttpResponseStatus.Ok) {
return;
}
logger.trace(`response body: ${j2s(await resp.json())}`);
}
}
interface SyncTermsOfServiceResponse {
// maximum backup size supported
storage_limit_in_megabytes: number;
// Fee for an account, per year.
annual_fee: AmountString;
// protocol version supported by the server,
// for now always "0.0".
version: string;
}
const codecForSyncTermsOfServiceResponse = (): Codec<
SyncTermsOfServiceResponse
> =>
buildCodecForObject()
.property("storage_limit_in_megabytes", codecForNumber())
.property("annual_fee", codecForAmountString())
.property("version", codecForString())
.build("SyncTermsOfServiceResponse");
export interface AddBackupProviderRequest {
backupProviderBaseUrl: string;
}
export const codecForAddBackupProviderRequest = (): Codec<
AddBackupProviderRequest
> =>
buildCodecForObject()
.property("backupProviderBaseUrl", codecForString())
.build("AddBackupProviderRequest");
export async function addBackupProvider(
ws: InternalWalletState,
req: AddBackupProviderRequest,
): Promise {
await provideBackupState(ws);
const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
const oldProv = await ws.db.get(Stores.backupProviders, canonUrl);
if (oldProv) {
return;
}
const termsUrl = new URL("terms", canonUrl);
const resp = await ws.http.get(termsUrl.href);
const terms = await readSuccessResponseJsonOrThrow(
resp,
codecForSyncTermsOfServiceResponse(),
);
await ws.db.put(Stores.backupProviders, {
active: true,
annualFee: terms.annual_fee,
baseUrl: canonUrl,
storageLimitInMegabytes: terms.storage_limit_in_megabytes,
supportedProtocolVersion: terms.version,
});
}
export async function removeBackupProvider(
syncProviderBaseUrl: string,
): Promise {}
export async function restoreFromRecoverySecret(): Promise {}
/**
* Information about one provider.
*
* We don't store the account key here,
* as that's derived from the wallet root key.
*/
export interface ProviderInfo {
syncProviderBaseUrl: string;
lastRemoteClock: number;
lastBackup?: Timestamp;
}
export interface BackupInfo {
walletRootPub: string;
deviceId: string;
lastLocalClock: number;
providers: ProviderInfo[];
}
/**
* Get information about the current state of wallet backups.
*/
export function getBackupInfo(ws: InternalWalletState): Promise {
throw Error("not implemented");
}