backup cycle
This commit is contained in:
parent
2650341042
commit
324f44ae69
@ -133,7 +133,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
|
|||||||
return responseJson;
|
return responseJson;
|
||||||
};
|
};
|
||||||
const makeBytes = async () => {
|
const makeBytes = async () => {
|
||||||
if (!(resp.data instanceof ArrayBuffer)) {
|
if (typeof resp.data.byteLength !== "number") {
|
||||||
throw Error("expected array buffer");
|
throw Error("expected array buffer");
|
||||||
}
|
}
|
||||||
const buf = resp.data;
|
const buf = resp.data;
|
||||||
|
@ -101,22 +101,35 @@ import {
|
|||||||
import {
|
import {
|
||||||
HttpResponseStatus,
|
HttpResponseStatus,
|
||||||
readSuccessResponseJsonOrThrow,
|
readSuccessResponseJsonOrThrow,
|
||||||
|
throwUnexpectedRequestError,
|
||||||
} from "../util/http";
|
} from "../util/http";
|
||||||
import { Logger } from "../util/logging";
|
import { Logger } from "../util/logging";
|
||||||
import { gunzipSync, gzipSync } from "fflate";
|
import { gunzipSync, gzipSync } from "fflate";
|
||||||
import { kdf } from "../crypto/primitives/kdf";
|
import { kdf } from "../crypto/primitives/kdf";
|
||||||
import { initRetryInfo } from "../util/retries";
|
import { initRetryInfo } from "../util/retries";
|
||||||
import { RefreshReason } from "../types/walletTypes";
|
import {
|
||||||
|
ConfirmPayResultType,
|
||||||
|
PreparePayResultType,
|
||||||
|
RefreshReason,
|
||||||
|
} from "../types/walletTypes";
|
||||||
import { CryptoApi } from "../crypto/workers/cryptoApi";
|
import { CryptoApi } from "../crypto/workers/cryptoApi";
|
||||||
import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast";
|
import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast";
|
||||||
import { str } from "../i18n";
|
import { str } from "../i18n";
|
||||||
|
import { confirmPay, preparePayForUri } from "./pay";
|
||||||
|
|
||||||
interface WalletBackupConfState {
|
interface WalletBackupConfState {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
walletRootPub: string;
|
walletRootPub: string;
|
||||||
walletRootPriv: string;
|
walletRootPriv: string;
|
||||||
clocks: { [device_id: string]: number };
|
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.
|
* Timestamp stored in the last backup.
|
||||||
@ -163,7 +176,7 @@ async function provideBackupState(
|
|||||||
clocks: { [deviceId]: 1 },
|
clocks: { [deviceId]: 1 },
|
||||||
walletRootPub: k.pub,
|
walletRootPub: k.pub,
|
||||||
walletRootPriv: k.priv,
|
walletRootPriv: k.priv,
|
||||||
lastBackupHash: undefined,
|
lastBackupPlainHash: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await tx.put(Stores.config, backupStateEntry);
|
await tx.put(Stores.config, backupStateEntry);
|
||||||
@ -574,9 +587,9 @@ export async function exportBackup(
|
|||||||
// If the backup changed, we increment our clock.
|
// If the backup changed, we increment our clock.
|
||||||
|
|
||||||
let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
|
let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
|
||||||
if (h != bs.lastBackupHash) {
|
if (h != bs.lastBackupPlainHash) {
|
||||||
backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId];
|
backupBlob.clocks[bs.deviceId] = ++bs.clocks[bs.deviceId];
|
||||||
bs.lastBackupHash = encodeCrock(
|
bs.lastBackupPlainHash = encodeCrock(
|
||||||
hash(stringToBytes(canonicalJson(backupBlob))),
|
hash(stringToBytes(canonicalJson(backupBlob))),
|
||||||
);
|
);
|
||||||
bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
|
bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
|
||||||
@ -631,17 +644,9 @@ export async function encryptBackup(
|
|||||||
const secret = deriveBlobSecret(config);
|
const secret = deriveBlobSecret(config);
|
||||||
const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
|
const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
|
||||||
chunks.push(encrypted);
|
chunks.push(encrypted);
|
||||||
logger.trace(`enc: ${encodeCrock(encrypted)}`);
|
|
||||||
return concatArrays(chunks);
|
return concatArrays(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptBackup(
|
|
||||||
config: WalletBackupConfState,
|
|
||||||
box: Uint8Array,
|
|
||||||
): Promise<WalletBackupContentV1> {
|
|
||||||
throw Error("not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompletedCoin {
|
interface CompletedCoin {
|
||||||
coinPub: string;
|
coinPub: string;
|
||||||
coinEvHash: string;
|
coinEvHash: string;
|
||||||
@ -1482,19 +1487,18 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
|
|||||||
const backupConfig = await provideBackupState(ws);
|
const backupConfig = await provideBackupState(ws);
|
||||||
|
|
||||||
logger.trace("got backup providers", providers);
|
logger.trace("got backup providers", providers);
|
||||||
const backupJsonContent = canonicalJson(await exportBackup(ws));
|
const backupJson = 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);
|
const encBackup = await encryptBackup(backupConfig, backupJson);
|
||||||
|
|
||||||
|
const currentBackupHash = hash(encBackup);
|
||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
|
const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
|
||||||
logger.trace(`trying to upload backup to ${provider.baseUrl}`);
|
logger.trace(`trying to upload backup to ${provider.baseUrl}`);
|
||||||
|
|
||||||
const syncSig = await ws.cryptoApi.makeSyncSignature({
|
const syncSig = await ws.cryptoApi.makeSyncSignature({
|
||||||
newHash: encodeCrock(h),
|
newHash: encodeCrock(currentBackupHash),
|
||||||
oldHash: provider.lastBackupHash,
|
oldHash: provider.lastBackupHash,
|
||||||
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
|
accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
|
||||||
});
|
});
|
||||||
@ -1508,11 +1512,16 @@ export async function runBackupCycle(ws: InternalWalletState): Promise<void> {
|
|||||||
|
|
||||||
const resp = await ws.http.fetch(accountBackupUrl.href, {
|
const resp = await ws.http.fetch(accountBackupUrl.href, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: compressedContent,
|
body: encBackup,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/octet-stream",
|
"content-type": "application/octet-stream",
|
||||||
"sync-signature": syncSig,
|
"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) {
|
if (resp.status === HttpResponseStatus.PaymentRequired) {
|
||||||
logger.trace("payment required for backup");
|
logger.trace("payment required for backup");
|
||||||
logger.trace(`headers: ${j2s(resp.headers)}`);
|
logger.trace(`headers: ${j2s(resp.headers)}`);
|
||||||
|
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.NoContent) {
|
||||||
|
await ws.db.runWithWriteTransaction(
|
||||||
|
[Stores.backupProviders],
|
||||||
|
async (tx) => {
|
||||||
|
const prov = await tx.get(Stores.backupProviders, provider.baseUrl);
|
||||||
|
if (!prov) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
prov.lastBackupHash = encodeCrock(currentBackupHash);
|
||||||
if (resp.status === HttpResponseStatus.Ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
prov.lastBackupHash = encodeCrock(hash(backupEnc));
|
||||||
logger.trace(`response body: ${j2s(await resp.json())}`);
|
prov.lastBackupClock =
|
||||||
|
blob.clocks[blob.current_device_id];
|
||||||
|
await tx.put(Stores.backupProviders, prov);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logger.info("processed existing backup");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1676,11 +1741,10 @@ export async function exportBackupEncrypted(
|
|||||||
return encryptBackup(bs, blob);
|
return encryptBackup(bs, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importBackupEncrypted(
|
export async function decryptBackup(
|
||||||
ws: InternalWalletState,
|
backupConfig: WalletBackupConfState,
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
): Promise<void> {
|
): Promise<WalletBackupContentV1> {
|
||||||
const backupConfig = await provideBackupState(ws);
|
|
||||||
const rMagic = bytesToString(data.slice(0, 8));
|
const rMagic = bytesToString(data.slice(0, 8));
|
||||||
if (rMagic != magic) {
|
if (rMagic != magic) {
|
||||||
throw Error("invalid backup file (magic tag mismatch)");
|
throw Error("invalid backup file (magic tag mismatch)");
|
||||||
@ -1693,7 +1757,15 @@ export async function importBackupEncrypted(
|
|||||||
if (!dataCompressed) {
|
if (!dataCompressed) {
|
||||||
throw Error("decryption failed");
|
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);
|
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
|
||||||
await importBackup(ws, blob, cryptoData);
|
await importBackup(ws, blob, cryptoData);
|
||||||
}
|
}
|
||||||
|
@ -1255,7 +1255,7 @@ async function generateDepositPermissions(
|
|||||||
export async function confirmPay(
|
export async function confirmPay(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
sessionIdOverride: string | undefined,
|
sessionIdOverride?: string,
|
||||||
): Promise<ConfirmPayResult> {
|
): Promise<ConfirmPayResult> {
|
||||||
logger.trace(
|
logger.trace(
|
||||||
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
|
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
|
||||||
|
@ -41,6 +41,8 @@
|
|||||||
* 11. Failed refunds do not have any information about why they failed.
|
* 11. Failed refunds do not have any information about why they failed.
|
||||||
* => This should go into the general "error reports"
|
* => This should go into the general "error reports"
|
||||||
* 12. Tombstones for removed backup providers
|
* 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:
|
* Questions:
|
||||||
* 1. What happens when two backups are merged that have
|
* 1. What happens when two backups are merged that have
|
||||||
|
@ -61,8 +61,11 @@ export interface HttpRequestOptions {
|
|||||||
|
|
||||||
export enum HttpResponseStatus {
|
export enum HttpResponseStatus {
|
||||||
Ok = 200,
|
Ok = 200,
|
||||||
|
NoContent = 204,
|
||||||
Gone = 210,
|
Gone = 210,
|
||||||
|
NotModified = 304,
|
||||||
PaymentRequired = 402,
|
PaymentRequired = 402,
|
||||||
|
Conflict = 409,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user