implement backup encryption, some more CLI commands
This commit is contained in:
parent
b2e213bae6
commit
2650341042
@ -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) => {
|
backupCli.subcommand("importPlain", "import-plain").action(async (args) => {
|
||||||
await withWallet(args, async (wallet) => {
|
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", {
|
const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
|
||||||
help:
|
help:
|
||||||
"Subcommands for advanced operations (only use if you know what you're doing!).",
|
"Subcommands for advanced operations (only use if you know what you're doing!).",
|
||||||
|
@ -2990,7 +2990,11 @@ export function sign_ed25519_pk_to_curve25519(
|
|||||||
return x25519_pk;
|
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);
|
checkArrayTypes(msg, nonce, key);
|
||||||
checkLengths(key, nonce);
|
checkLengths(key, nonce);
|
||||||
var m = new Uint8Array(crypto_secretbox_ZEROBYTES + msg.length);
|
var m = new Uint8Array(crypto_secretbox_ZEROBYTES + msg.length);
|
||||||
@ -3005,15 +3009,15 @@ export function secretbox_open(
|
|||||||
box: Uint8Array,
|
box: Uint8Array,
|
||||||
nonce: Uint8Array,
|
nonce: Uint8Array,
|
||||||
key: Uint8Array,
|
key: Uint8Array,
|
||||||
) {
|
): Uint8Array | undefined {
|
||||||
checkArrayTypes(box, nonce, key);
|
checkArrayTypes(box, nonce, key);
|
||||||
checkLengths(key, nonce);
|
checkLengths(key, nonce);
|
||||||
var c = new Uint8Array(crypto_secretbox_BOXZEROBYTES + box.length);
|
var c = new Uint8Array(crypto_secretbox_BOXZEROBYTES + box.length);
|
||||||
var m = new Uint8Array(c.length);
|
var m = new Uint8Array(c.length);
|
||||||
for (var i = 0; i < box.length; i++)
|
for (var i = 0; i < box.length; i++)
|
||||||
c[i + crypto_secretbox_BOXZEROBYTES] = box[i];
|
c[i + crypto_secretbox_BOXZEROBYTES] = box[i];
|
||||||
if (c.length < 32) return null;
|
if (c.length < 32) return undefined;
|
||||||
if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return null;
|
if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return undefined;
|
||||||
return m.subarray(crypto_secretbox_ZEROBYTES);
|
return m.subarray(crypto_secretbox_ZEROBYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +74,7 @@ import {
|
|||||||
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
|
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants";
|
||||||
import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
|
import { AmountJson, Amounts, codecForAmountString } from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
|
bytesToString,
|
||||||
decodeCrock,
|
decodeCrock,
|
||||||
eddsaGetPublic,
|
eddsaGetPublic,
|
||||||
EddsaKeyPair,
|
EddsaKeyPair,
|
||||||
@ -102,11 +103,13 @@ import {
|
|||||||
readSuccessResponseJsonOrThrow,
|
readSuccessResponseJsonOrThrow,
|
||||||
} from "../util/http";
|
} from "../util/http";
|
||||||
import { Logger } from "../util/logging";
|
import { Logger } from "../util/logging";
|
||||||
import { 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 { 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 { str } from "../i18n";
|
||||||
|
|
||||||
interface WalletBackupConfState {
|
interface WalletBackupConfState {
|
||||||
deviceId: string;
|
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(
|
export async function encryptBackup(
|
||||||
config: WalletBackupConfState,
|
config: WalletBackupConfState,
|
||||||
blob: WalletBackupContentV1,
|
blob: WalletBackupContentV1,
|
||||||
): Promise<Uint8Array> {
|
): 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");
|
throw Error("not implemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -778,7 +825,10 @@ async function getDenomSelStateFromBackup(
|
|||||||
exchangeBaseUrl: string,
|
exchangeBaseUrl: string,
|
||||||
sel: BackupDenomSel,
|
sel: BackupDenomSel,
|
||||||
): Promise<DenomSelectionState> {
|
): 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);
|
checkBackupInvariant(!!d0);
|
||||||
const selectedDenoms: {
|
const selectedDenoms: {
|
||||||
denomPubHash: string;
|
denomPubHash: string;
|
||||||
@ -787,16 +837,20 @@ async function getDenomSelStateFromBackup(
|
|||||||
let totalCoinValue = Amounts.getZero(d0.value.currency);
|
let totalCoinValue = Amounts.getZero(d0.value.currency);
|
||||||
let totalWithdrawCost = Amounts.getZero(d0.value.currency);
|
let totalWithdrawCost = Amounts.getZero(d0.value.currency);
|
||||||
for (const s of sel) {
|
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);
|
checkBackupInvariant(!!d);
|
||||||
totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
|
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 {
|
return {
|
||||||
selectedDenoms,
|
selectedDenoms,
|
||||||
totalCoinValue,
|
totalCoinValue,
|
||||||
totalWithdrawCost,
|
totalWithdrawCost,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importBackup(
|
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:
|
* Do one backup cycle that consists of:
|
||||||
* 1. Exporting a backup and try to upload it.
|
* 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.
|
* 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");
|
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);
|
||||||
|
}
|
||||||
|
@ -40,6 +40,7 @@
|
|||||||
* payment cost.
|
* payment cost.
|
||||||
* 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
|
||||||
*
|
*
|
||||||
* Questions:
|
* Questions:
|
||||||
* 1. What happens when two backups are merged that have
|
* 1. What happens when two backups are merged that have
|
||||||
|
@ -162,6 +162,11 @@ import {
|
|||||||
runBackupCycle,
|
runBackupCycle,
|
||||||
exportBackup,
|
exportBackup,
|
||||||
importBackupPlain,
|
importBackupPlain,
|
||||||
|
exportBackupEncrypted,
|
||||||
|
importBackupEncrypted,
|
||||||
|
BackupRecovery,
|
||||||
|
getBackupRecovery,
|
||||||
|
AddBackupProviderRequest,
|
||||||
} from "./operations/backup";
|
} from "./operations/backup";
|
||||||
|
|
||||||
const builtinCurrencies: CurrencyRecord[] = [
|
const builtinCurrencies: CurrencyRecord[] = [
|
||||||
@ -942,6 +947,26 @@ export class Wallet {
|
|||||||
return importBackupPlain(this.ws, backup);
|
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.
|
* Implementation of the "wallet-core" API.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user