backup cycle

This commit is contained in:
Florian Dold 2021-01-07 19:50:53 +01:00
parent 2650341042
commit 324f44ae69
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
5 changed files with 111 additions and 34 deletions

View File

@ -133,7 +133,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
return responseJson;
};
const makeBytes = async () => {
if (!(resp.data instanceof ArrayBuffer)) {
if (typeof resp.data.byteLength !== "number") {
throw Error("expected array buffer");
}
const buf = resp.data;

View File

@ -101,22 +101,35 @@ import {
import {
HttpResponseStatus,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http";
import { Logger } from "../util/logging";
import { gunzipSync, gzipSync } from "fflate";
import { kdf } from "../crypto/primitives/kdf";
import { initRetryInfo } from "../util/retries";
import { RefreshReason } from "../types/walletTypes";
import {
ConfirmPayResultType,
PreparePayResultType,
RefreshReason,
} from "../types/walletTypes";
import { CryptoApi } from "../crypto/workers/cryptoApi";
import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast";
import { str } from "../i18n";
import { confirmPay, preparePayForUri } from "./pay";
interface WalletBackupConfState {
deviceId: string;
walletRootPub: string;
walletRootPriv: string;
clocks: { [device_id: string]: number };
lastBackupHash?: string;
/**
* Last hash of the canonicalized plain-text backup.
*
* Used to determine whether the wallet's content changed
* and we need to bump the clock.
*/
lastBackupPlainHash?: string;
/**
* Timestamp stored in the last backup.
@ -163,7 +176,7 @@ async function provideBackupState(
clocks: { [deviceId]: 1 },
walletRootPub: k.pub,
walletRootPriv: k.priv,
lastBackupHash: undefined,
lastBackupPlainHash: undefined,
},
};
await tx.put(Stores.config, backupStateEntry);
@ -574,9 +587,9 @@ export async function exportBackup(
// If the backup changed, we increment our clock.
let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
if (h != bs.lastBackupHash) {
if (h != bs.lastBackupPlainHash) {
backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId];
bs.lastBackupHash = encodeCrock(
bs.lastBackupPlainHash = encodeCrock(
hash(stringToBytes(canonicalJson(backupBlob))),
);
bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
@ -631,17 +644,9 @@ export async function encryptBackup(
const secret = deriveBlobSecret(config);
const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
chunks.push(encrypted);
logger.trace(`enc: ${encodeCrock(encrypted)}`);
return concatArrays(chunks);
}
export async function decryptBackup(
config: WalletBackupConfState,
box: Uint8Array,
): Promise<WalletBackupContentV1> {
throw Error("not implemented");
}
interface CompletedCoin {
coinPub: string;
coinEvHash: string;
@ -1482,19 +1487,18 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
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 backupJson = await exportBackup(ws);
const h = hash(compressedContent);
const encBackup = await encryptBackup(backupConfig, backupJson);
const currentBackupHash = hash(encBackup);
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),
newHash: encodeCrock(currentBackupHash),
oldHash: provider.lastBackupHash,
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
});
@ -1508,11 +1512,16 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
const resp = await ws.http.fetch(accountBackupUrl.href, {
method: "POST",
body: compressedContent,
body: encBackup,
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSig,
"if-none-match": encodeCrock(h),
"if-none-match": encodeCrock(currentBackupHash),
...(provider.lastBackupHash
? {
"if-match": provider.lastBackupHash,
}
: {}),
},
});
@ -1521,14 +1530,70 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
if (resp.status === HttpResponseStatus.PaymentRequired) {
logger.trace("payment required for backup");
logger.trace(`headers: ${j2s(resp.headers)}`);
return;
const talerUri = resp.headers.get("taler");
if (!talerUri) {
throw Error("no taler URI available to pay provider");
}
const res = await preparePayForUri(ws, talerUri);
let proposalId: string | undefined;
switch (res.status) {
case PreparePayResultType.InsufficientBalance:
// FIXME: record in provider state!
logger.warn("insufficient balance to pay for backup provider");
break;
case PreparePayResultType.PaymentPossible:
case PreparePayResultType.AlreadyConfirmed:
proposalId = res.proposalId;
break;
}
if (!proposalId) {
continue;
}
const confirmRes = await confirmPay(ws, proposalId);
switch (confirmRes.type) {
case ConfirmPayResultType.Pending:
logger.warn("payment not yet finished yet");
break;
}
}
if (resp.status === HttpResponseStatus.Ok) {
return;
if (resp.status === HttpResponseStatus.NoContent) {
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(currentBackupHash);
prov.lastBackupClock =
backupJson.clocks[backupJson.current_device_id];
await tx.put(Stores.backupProviders, prov);
},
);
continue;
}
if (resp.status === HttpResponseStatus.Conflict) {
logger.info("conflicting backup found");
const backupEnc = new Uint8Array(await resp.bytes());
const backupConfig = await provideBackupState(ws);
const blob = await decryptBackup(backupConfig, backupEnc);
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
await importBackup(ws, blob, cryptoData);
await ws.db.runWithWriteTransaction(
[Stores.backupProviders],
async (tx) => {
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
if (!prov) {
return;
}
prov.lastBackupHash = encodeCrock(hash(backupEnc));
prov.lastBackupClock =
blob.clocks[blob.current_device_id];
await tx.put(Stores.backupProviders, prov);
},
);
logger.info("processed existing backup");
}
logger.trace(`response body: ${j2s(await resp.json())}`);
}
}
@ -1676,11 +1741,10 @@ export async function exportBackupEncrypted(
return encryptBackup(bs, blob);
}
export async function importBackupEncrypted(
ws: InternalWalletState,
export async function decryptBackup(
backupConfig: WalletBackupConfState,
data: Uint8Array,
): Promise<void> {
const backupConfig = await provideBackupState(ws);
): Promise<WalletBackupContentV1> {
const rMagic = bytesToString(data.slice(0, 8));
if (rMagic != magic) {
throw Error("invalid backup file (magic tag mismatch)");
@ -1693,7 +1757,15 @@ export async function importBackupEncrypted(
if (!dataCompressed) {
throw Error("decryption failed");
}
const blob = JSON.parse(bytesToString(gunzipSync(dataCompressed)));
return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
}
export async function importBackupEncrypted(
ws: InternalWalletState,
data: Uint8Array,
): Promise<void> {
const backupConfig = await provideBackupState(ws);
const blob = await decryptBackup(backupConfig, data);
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
await importBackup(ws, blob, cryptoData);
}

View File

@ -1255,7 +1255,7 @@ async function generateDepositPermissions(
export async function confirmPay(
ws: InternalWalletState,
proposalId: string,
sessionIdOverride: string | undefined,
sessionIdOverride?: string,
): Promise<ConfirmPayResult> {
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,

View File

@ -41,6 +41,8 @@
* 11. Failed refunds do not have any information about why they failed.
* => This should go into the general "error reports"
* 12. Tombstones for removed backup providers
* 13. Do we somehow need to model the mechanism for first only withdrawing
* the amount to pay the backup provider?
*
* Questions:
* 1. What happens when two backups are merged that have

View File

@ -61,8 +61,11 @@ export interface HttpRequestOptions {
export enum HttpResponseStatus {
Ok = 200,
NoContent = 204,
Gone = 210,
NotModified = 304,
PaymentRequired = 402,
Conflict = 409,
}
/**