implement backup encryption, some more CLI commands

This commit is contained in:
Florian Dold 2021-01-07 18:56:09 +01:00
parent b2e213bae6
commit 2650341042
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
5 changed files with 221 additions and 10 deletions

View File

@ -409,6 +409,29 @@ backupCli.subcommand("exportPlain", "export-plain").action(async (args) => {
});
});
backupCli
.subcommand("export", "export")
.requiredArgument("filename", clk.STRING, {
help: "backup filename",
})
.action(async (args) => {
await withWallet(args, async (wallet) => {
const backup = await wallet.exportBackupEncrypted();
fs.writeFileSync(args.export.filename, backup);
});
});
backupCli
.subcommand("import", "import")
.requiredArgument("filename", clk.STRING, {
help: "backup filename",
})
.action(async (args) => {
await withWallet(args, async (wallet) => {
const backupEncBlob = fs.readFileSync(args.import.filename);
await wallet.importBackupEncrypted(backupEncBlob);
});
});
backupCli.subcommand("importPlain", "import-plain").action(async (args) => {
await withWallet(args, async (wallet) => {
@ -417,6 +440,36 @@ backupCli.subcommand("importPlain", "import-plain").action(async (args) => {
});
});
backupCli.subcommand("recoverySave", "save-recovery").action(async (args) => {
await withWallet(args, async (wallet) => {
const recoveryJson = await wallet.getBackupRecovery();
console.log(JSON.stringify(recoveryJson, undefined, 2));
});
});
backupCli.subcommand("run", "run").action(async (args) => {
await withWallet(args, async (wallet) => {
await wallet.runBackupCycle();
});
});
backupCli
.subcommand("recoveryLoad", "load-recovery")
.action(async (args) => {});
backupCli.subcommand("status", "status").action(async (args) => {});
backupCli
.subcommand("addProvider", "add-provider")
.requiredArgument("url", clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
wallet.addBackupProvider({
backupProviderBaseUrl: args.addProvider.url,
});
});
});
const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
help:
"Subcommands for advanced operations (only use if you know what you're doing!).",

View File

@ -2990,7 +2990,11 @@ export function sign_ed25519_pk_to_curve25519(
return x25519_pk;
}
export function secretbox(msg: Uint8Array, nonce: Uint8Array, key: Uint8Array) {
export function secretbox(
msg: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array {
checkArrayTypes(msg, nonce, key);
checkLengths(key, nonce);
var m = new Uint8Array(crypto_secretbox_ZEROBYTES + msg.length);
@ -3005,15 +3009,15 @@ export function secretbox_open(
box: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
) {
): Uint8Array | undefined {
checkArrayTypes(box, nonce, key);
checkLengths(key, nonce);
var c = new Uint8Array(crypto_secretbox_BOXZEROBYTES + box.length);
var m = new Uint8Array(c.length);
for (var i = 0; i < box.length; i++)
c[i + crypto_secretbox_BOXZEROBYTES] = box[i];
if (c.length < 32) return null;
if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return null;
if (c.length < 32) return undefined;
if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return undefined;
return m.subarray(crypto_secretbox_ZEROBYTES);
}

View File

@ -74,6 +74,7 @@ import {
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
import {
bytesToString,
decodeCrock,
eddsaGetPublic,
EddsaKeyPair,
@ -102,11 +103,13 @@ import {
readSuccessResponseJsonOrThrow,
} from "../util/http";
import { Logger } from "../util/logging";
import { gzipSync } from "fflate";
import { gunzipSync, gzipSync } from "fflate";
import { kdf } from "../crypto/primitives/kdf";
import { initRetryInfo } from "../util/retries";
import { RefreshReason } from "../types/walletTypes";
import { CryptoApi } from "../crypto/workers/cryptoApi";
import { secretbox, secretbox_open } from "../crypto/primitives/nacl-fast";
import { str } from "../i18n";
interface WalletBackupConfState {
deviceId: string;
@ -588,10 +591,54 @@ export async function exportBackup(
);
}
function concatArrays(xs: Uint8Array[]): Uint8Array {
let len = 0;
for (const x of xs) {
len += x.byteLength;
}
const out = new Uint8Array(len);
let offset = 0;
for (const x of xs) {
out.set(x, offset);
offset += x.length;
}
return out;
}
const magic = "TLRWBK01";
/**
* Encrypt the backup.
*
* Blob format:
* Magic "TLRWBK01" (8 bytes)
* Nonce (24 bytes)
* Compressed JSON blob (rest)
*/
export async function encryptBackup(
config: WalletBackupConfState,
blob: WalletBackupContentV1,
): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
chunks.push(stringToBytes(magic));
const nonceStr = config.lastBackupNonce;
checkLogicInvariant(!!nonceStr);
const nonce = decodeCrock(nonceStr).slice(0, 24);
chunks.push(nonce);
const backupJsonContent = canonicalJson(blob);
logger.trace("backup JSON size", backupJsonContent.length);
const compressedContent = gzipSync(stringToBytes(backupJsonContent));
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");
}
@ -778,7 +825,10 @@ async function getDenomSelStateFromBackup(
exchangeBaseUrl: string,
sel: BackupDenomSel,
): Promise<DenomSelectionState> {
const d0 = await tx.get(Stores.denominations, [exchangeBaseUrl, sel[0].denom_pub_hash]);
const d0 = await tx.get(Stores.denominations, [
exchangeBaseUrl,
sel[0].denom_pub_hash,
]);
checkBackupInvariant(!!d0);
const selectedDenoms: {
denomPubHash: string;
@ -787,16 +837,20 @@ async function getDenomSelStateFromBackup(
let totalCoinValue = Amounts.getZero(d0.value.currency);
let totalWithdrawCost = Amounts.getZero(d0.value.currency);
for (const s of sel) {
const d = await tx.get(Stores.denominations, [exchangeBaseUrl, s.denom_pub_hash]);
const d = await tx.get(Stores.denominations, [
exchangeBaseUrl,
s.denom_pub_hash,
]);
checkBackupInvariant(!!d);
totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw).amount;
totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw)
.amount;
}
return {
selectedDenoms,
totalCoinValue,
totalWithdrawCost,
}
};
}
export async function importBackup(
@ -1407,6 +1461,15 @@ function deriveAccountKeyPair(
};
}
function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
return kdf(
32,
decodeCrock(bc.walletRootPriv),
stringToBytes("taler-sync-blob-secret-salt"),
stringToBytes("taler-sync-blob-secret-info"),
);
}
/**
* Do one backup cycle that consists of:
* 1. Exporting a backup and try to upload it.
@ -1566,6 +1629,71 @@ export async function importBackupPlain(
/**
* Get information about the current state of wallet backups.
*/
export function getBackupInfo(ws: InternalWalletState): Promise<BackupInfo> {
export async function getBackupInfo(
ws: InternalWalletState,
): Promise<BackupInfo> {
throw Error("not implemented");
}
export interface BackupRecovery {
walletRootPriv: string;
providers: {
url: string;
}[];
}
/**
* Get information about the current state of wallet backups.
*/
export async function getBackupRecovery(
ws: InternalWalletState,
): Promise<BackupRecovery> {
const bs = await provideBackupState(ws);
const providers = await ws.db.iter(Stores.backupProviders).toArray();
return {
providers: providers
.filter((x) => x.active)
.map((x) => {
return {
url: x.baseUrl,
};
}),
walletRootPriv: bs.walletRootPriv,
};
}
export async function exportBackupEncrypted(
ws: InternalWalletState,
): Promise<Uint8Array> {
await provideBackupState(ws);
const blob = await exportBackup(ws);
const bs = await ws.db.runWithWriteTransaction(
[Stores.config],
async (tx) => {
return await getWalletBackupState(ws, tx);
},
);
return encryptBackup(bs, blob);
}
export async function importBackupEncrypted(
ws: InternalWalletState,
data: Uint8Array,
): Promise<void> {
const backupConfig = await provideBackupState(ws);
const rMagic = bytesToString(data.slice(0, 8));
if (rMagic != magic) {
throw Error("invalid backup file (magic tag mismatch)");
}
const nonce = data.slice(8, 8 + 24);
const box = data.slice(8 + 24);
const secret = deriveBlobSecret(backupConfig);
const dataCompressed = secretbox_open(box, nonce, secret);
if (!dataCompressed) {
throw Error("decryption failed");
}
const blob = JSON.parse(bytesToString(gunzipSync(dataCompressed)));
const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
await importBackup(ws, blob, cryptoData);
}

View File

@ -40,6 +40,7 @@
* payment cost.
* 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
*
* Questions:
* 1. What happens when two backups are merged that have

View File

@ -162,6 +162,11 @@ import {
runBackupCycle,
exportBackup,
importBackupPlain,
exportBackupEncrypted,
importBackupEncrypted,
BackupRecovery,
getBackupRecovery,
AddBackupProviderRequest,
} from "./operations/backup";
const builtinCurrencies: CurrencyRecord[] = [
@ -942,6 +947,26 @@ export class Wallet {
return importBackupPlain(this.ws, backup);
}
async exportBackupEncrypted() {
return exportBackupEncrypted(this.ws);
}
async importBackupEncrypted(backup: Uint8Array) {
return importBackupEncrypted(this.ws, backup);
}
async getBackupRecovery(): Promise<BackupRecovery> {
return getBackupRecovery(this.ws);
}
async addBackupProvider(req: AddBackupProviderRequest): Promise<void> {
return addBackupProvider(this.ws, req);
}
async runBackupCycle(): Promise<void> {
return runBackupCycle(this.ws);
}
/**
* Implementation of the "wallet-core" API.
*/