@planq-network/encrypted-backup
Version:
Libraries for implemented password encrypted account backups
399 lines • 25.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.openBackup = exports.createBackup = exports.createPasswordEncryptedBackup = exports.createPinEncryptedBackup = void 0;
var address_1 = require("@planq-network/base/lib/address");
var result_1 = require("@planq-network/base/lib/result");
var circuit_breaker_1 = require("@planq-network/identity/lib/odis/circuit-breaker");
var crypto = __importStar(require("crypto"));
var debug_1 = __importDefault(require("debug"));
var config_1 = require("./config");
var errors_1 = require("./errors");
var odis_1 = require("./odis");
var utils_1 = require("./utils");
var debug = (0, debug_1.default)('kit:encrypted-backup:backup');
/**
* Create a data backup, encrypting it with a hardened key derived from the given PIN.
*
* @remarks Using a 4 or 6 digit PIN for encryption requires an extremely restrictive rate limit for
* attempts to guess the PIN. This is enforced by ODIS through the SequentialDelayDomain with
* settings to allow the user (or an attacker) only a fixed number of attempts to guess their PIN.
*
* Because PINs have very little entropy, the total number of guesses is very restricted.
* * On the first day, the client has 10 attempts. 5 within 10s. 5 more over roughly 45 minutes.
* * On the second day, the client has 5 attempts over roughly 2 minutes.
* * On the third day, the client has 3 attempts over roughly 40 seconds.
* * On the fourth day, the client has 2 attempts over roughly 10 seconds.
* * Overall, the client has 25 attempts over 4 days. All further attempts will be denied.
*
* It is strongly recommended that the calling application implement a PIN blocklist to prevent the
* user from selecting a number of the most common PIN codes (e.g. blocking the top 25k PINs by
* frequency of appearance in the HIBP Passwords dataset). An example implementation can be seen in
* the Valora wallet. {@link
* https://github.com/valora-inc/wallet/blob/3940661c40d08e4c5db952bd0abeaabb0030fc7a/packages/mobile/src/pincode/authentication.ts#L56-L108
* | PIN blocklist implementation}
*
* In order to handle the event of an ODIS service compromise, this configuration additionally
* includes a circuit breaker service run by Valora. In the event of an ODIS compromise, the Valora
* team will take their service offline, preventing backups using the circuit breaker from being
* opened. This ensures that an attacker who has compromised ODIS cannot leverage their attack to
* forcibly open backups created with this function.
*
* @param data The secret data (e.g. BIP-39 mnemonic phrase) to be included in the encrypted backup.
* @param pin PIN to use in deriving the encryption key.
* @param hardening Configuration for how the password should be hardened in deriving the key.
* @param metadata Arbitrary key-value data to include in the backup to identify it.
*/
function createPinEncryptedBackup(_a) {
var data = _a.data, pin = _a.pin, environment = _a.environment, metadata = _a.metadata;
return __awaiter(this, void 0, void 0, function () {
var hardening;
return __generator(this, function (_b) {
if (environment === config_1.EnvironmentIdentifier.ALFAJORES) {
hardening = config_1.PIN_HARDENING_ALFAJORES_CONFIG;
}
else if (environment === config_1.EnvironmentIdentifier.MAINNET || environment === undefined) {
hardening = config_1.PIN_HARDENING_MAINNET_CONFIG;
}
if (hardening === undefined) {
throw new Error('Implementation error: unhandled environment identifier');
}
return [2 /*return*/, createBackup({ data: data, userSecret: pin, hardening: hardening, metadata: metadata })];
});
});
}
exports.createPinEncryptedBackup = createPinEncryptedBackup;
/**
* Create a data backup, encrypting it with a hardened key derived from the given password.
*
* @remarks Because passwords have moderate entropy, the total number of guesses is restricted.
* * The user initially gets 5 attempts without delay.
* * Then the user gets two attempts every 5 seconds for up to 20 attempts.
* * Then the user gets two attempts every 30 seconds for up to 20 attempts.
* * Then the user gets two attempts every 5 minutes for up to 20 attempts.
* * Then the user gets two attempts every hour for up to 20 attempts.
* * Then the user gets two attempts every day for up to 20 attempts.
*
* Following guidelines in NIST-800-63-3 it is strongly recommended that the caller apply a password
* blocklist to the users choice of password.
*
* In order to handle the event of an ODIS service compromise, this configuration additionally
* hardens the password input with a computational hardening function. In particular, scrypt is used
* with IETF recommended parameters {@link
* https://tools.ietf.org/id/draft-whited-kitten-password-storage-00.html#name-scrypt | IETF
* recommended scrypt parameters }
*
* @param data The secret data (e.g. BIP-39 mnemonic phrase) to be included in the encrypted backup.
* @param password Password to use in deriving the encryption key.
* @param hardening Configuration for how the password should be hardened in deriving the key.
* @param metadata Arbitrary key-value data to include in the backup to identify it.
*/
function createPasswordEncryptedBackup(_a) {
var data = _a.data, password = _a.password, environment = _a.environment, metadata = _a.metadata;
return __awaiter(this, void 0, void 0, function () {
var hardening;
return __generator(this, function (_b) {
if (environment === config_1.EnvironmentIdentifier.ALFAJORES) {
hardening = config_1.PASSWORD_HARDENING_ALFAJORES_CONFIG;
}
else if (environment === config_1.EnvironmentIdentifier.MAINNET || environment === undefined) {
hardening = config_1.PASSWORD_HARDENING_MAINNET_CONFIG;
}
if (hardening === undefined) {
throw new Error('Implementation error: unhandled environment identifier');
}
return [2 /*return*/, createBackup({ data: data, userSecret: password, hardening: hardening, metadata: metadata })];
});
});
}
exports.createPasswordEncryptedBackup = createPasswordEncryptedBackup;
/**
* Create a data backup, encrypting it with a hardened key derived from the given password or PIN.
*
* @param data The secret data (e.g. BIP-39 mnemonic phrase) to be included in the encrypted backup.
* @param userSecret Password, PIN, or other user secret to use in deriving the encryption key.
* If a string is provided, it will be UTF-8 encoded into a Buffer before use.
* @param hardening Configuration for how the password should be hardened in deriving the key.
* @param metadata Arbitrary key-value data to include in the backup to identify it.
*
* @privateRemarks Most of this functions code is devoted to key generation starting with the input
* password or PIN and ending up with a hardened encryption key. It is important that the order and
* inputs to each step in the derivation be well considered and implemented correctly. One important
* requirement is that no output included in the backup acts as a "commitment" to the password or PIN
* value, except the final ciphertext. An example of an issue with this would be if a hash of the
* password and nonce were included in the backup. If a commitment to the password or PIN is
* included, an attacker can locally brute force that commitment to recover the password, then use
* that knowledge to complete the derivation.
*/
function createBackup(_a) {
var _b, _c;
var data = _a.data, userSecret = _a.userSecret, hardening = _a.hardening, metadata = _a.metadata;
return __awaiter(this, void 0, void 0, function () {
var nonce, passwordSalt, odisAuthKeySeed, userSecretBuffer, initialKey, encryptedFuseKey, updatedKey, circuitBreakerClient, serviceStatus, fuseKey, wrap, domain, odisHardenedKey, authorizer, odisHardening, computationalHardenedKey, computationalHardening, finalKey, encryption;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
// Password and backup data are not included in any logging as they are likely sensitive.
debug('creating a backup with the following information', hardening, metadata);
// Safety measure to prevent users from accidentally using this API without any hardening.
if (hardening.odis === undefined && hardening.computational === undefined) {
return [2 /*return*/, (0, result_1.Err)(new errors_1.UsageError(new Error('createBackup cannot be used with a empty hardening config')))];
}
nonce = crypto.randomBytes(32);
passwordSalt = nonce.slice(0, 16);
odisAuthKeySeed = nonce.slice(16, 32);
userSecretBuffer = typeof userSecret === 'string' ? Buffer.from(userSecret, 'utf8') : userSecret;
initialKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.PASSWORD, [userSecretBuffer, passwordSalt]);
if (!(hardening.circuitBreaker !== undefined)) return [3 /*break*/, 2];
debug('generating a fuse key to enabled use of the circuit breaker service');
circuitBreakerClient = new circuit_breaker_1.CircuitBreakerClient(hardening.circuitBreaker.environment);
return [4 /*yield*/, circuitBreakerClient.status()];
case 1:
serviceStatus = _d.sent();
if (!serviceStatus.ok) {
return [2 /*return*/, (0, result_1.Err)(serviceStatus.error)];
}
if (serviceStatus.result !== circuit_breaker_1.CircuitBreakerKeyStatus.ENABLED) {
return [2 /*return*/, (0, result_1.Err)(new circuit_breaker_1.CircuitBreakerUnavailableError(serviceStatus.result))];
}
debug('confirmed that the circuit breaker is online');
// Generate a fuse key and encrypt it against the circuit breaker public key.
debug('generating and wrapping the fuse key');
fuseKey = crypto.randomBytes(16);
wrap = circuitBreakerClient.wrapKey(fuseKey);
if (!wrap.ok) {
return [2 /*return*/, (0, result_1.Err)(wrap.error)];
}
encryptedFuseKey = wrap.result;
// Mix the fuse key into the ongoing key hardening. Note that mixing in the circuit breaker key
// occurs before the request to ODIS. This means an attacker would need to acquire the fuse key
// _before_ they can make any attempts to guess the user's secret.
debug('mixing the fuse key into the keying material');
updatedKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.FUSE_KEY, [initialKey, fuseKey]);
return [3 /*break*/, 3];
case 2:
debug('not using the circuit breaker service');
updatedKey = initialKey;
_d.label = 3;
case 3:
if (!(hardening.odis !== undefined)) return [3 /*break*/, 5];
debug('hardening the user key with output from ODIS');
authorizer = (0, odis_1.odisQueryAuthorizer)(odisAuthKeySeed);
domain = (0, odis_1.buildOdisDomain)(hardening.odis, authorizer.address);
debug('sending request to ODIS to harden the backup encryption key');
return [4 /*yield*/, (0, odis_1.odisHardenKey)(updatedKey, domain, hardening.odis.environment, authorizer.wallet)];
case 4:
odisHardening = _d.sent();
if (!odisHardening.ok) {
return [2 /*return*/, (0, result_1.Err)(odisHardening.error)];
}
odisHardenedKey = odisHardening.result;
return [3 /*break*/, 6];
case 5:
debug('not using ODIS for key hardening');
_d.label = 6;
case 6:
if (!(hardening.computational !== undefined)) return [3 /*break*/, 8];
debug('hardening user key with computational function', hardening.computational);
return [4 /*yield*/, (0, utils_1.computationalHardenKey)(updatedKey, hardening.computational)];
case 7:
computationalHardening = _d.sent();
if (!computationalHardening.ok) {
return [2 /*return*/, (0, result_1.Err)(computationalHardening.error)];
}
computationalHardenedKey = computationalHardening.result;
return [3 /*break*/, 9];
case 8:
debug('not using computational key hardening');
_d.label = 9;
case 9:
debug('finalizing encryption key');
finalKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.FINALIZE, [
updatedKey,
odisHardenedKey !== null && odisHardenedKey !== void 0 ? odisHardenedKey : Buffer.alloc(0),
computationalHardenedKey !== null && computationalHardenedKey !== void 0 ? computationalHardenedKey : Buffer.alloc(0),
]);
debug('encrypting backup data with final encryption key');
encryption = (0, utils_1.encrypt)(finalKey, data);
if (!encryption.ok) {
return [2 /*return*/, (0, result_1.Err)(encryption.error)];
}
// Encrypted and wrap the data in a Backup structure.
debug('created encrypted backup');
return [2 /*return*/, (0, result_1.Ok)({
encryptedData: encryption.result,
nonce: nonce,
odisDomain: domain,
encryptedFuseKey: encryptedFuseKey,
computationalHardening: hardening.computational,
// TODO(victor): Bump this to 1.0 when the final crypto is added.
version: '0.0.1',
metadata: metadata,
environment: {
odis: (_b = hardening.odis) === null || _b === void 0 ? void 0 : _b.environment,
circuitBreaker: (_c = hardening.circuitBreaker) === null || _c === void 0 ? void 0 : _c.environment,
},
})];
}
});
});
}
exports.createBackup = createBackup;
/**
* Open an encrypted backup file, using the provided password or PIN to derive the decryption key.
*
* @param backup Backup structure including the ciphertext and key derivation information.
* @param userSecret Password, PIN, or other user secret to use in deriving the encryption key.
* If a string is provided, it will be UTF-8 encoded into a Buffer before use.
*/
function openBackup(_a) {
var _b, _c;
var backup = _a.backup, userSecret = _a.userSecret;
return __awaiter(this, void 0, void 0, function () {
var passwordSalt, odisAuthKeySeed, userSecretBuffer, initialKey, updatedKey, circuitBreakerClient, unwrap, odisHardenedKey, domain, authorizer, odisHardening, computationalHardenedKey, computationalHardening, finalKey, decryption;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
debug('opening an encrypted backup');
passwordSalt = backup.nonce.slice(0, 16);
odisAuthKeySeed = backup.nonce.slice(16, 32);
userSecretBuffer = typeof userSecret === 'string' ? Buffer.from(userSecret, 'utf8') : userSecret;
initialKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.PASSWORD, [userSecretBuffer, passwordSalt]);
if (!(backup.encryptedFuseKey !== undefined)) return [3 /*break*/, 2];
if (((_b = backup.environment) === null || _b === void 0 ? void 0 : _b.circuitBreaker) === undefined) {
return [2 /*return*/, (0, result_1.Err)(new errors_1.InvalidBackupError(new Error('encrypted fuse key is provided but no circuit breaker environment is provided')))];
}
circuitBreakerClient = new circuit_breaker_1.CircuitBreakerClient(backup.environment.circuitBreaker);
debug('requesting the circuit breaker service unwrap the encrypted circuit breaker key', backup.environment.circuitBreaker);
return [4 /*yield*/, circuitBreakerClient.unwrapKey(backup.encryptedFuseKey)];
case 1:
unwrap = _d.sent();
if (!unwrap.ok) {
return [2 /*return*/, (0, result_1.Err)(unwrap.error)];
}
// Mix the fuse key into the ongoing key hardening. Note that mixing in the circuit breaker key
// occurs before the request to ODIS. This means an attacker would need to aquire the fuse key
// _before_ they can make any attempts to guess the user's secret.
updatedKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.FUSE_KEY, [initialKey, unwrap.result]);
return [3 /*break*/, 3];
case 2:
debug('backup did not specify an encrypted fuse key');
updatedKey = initialKey;
_d.label = 3;
case 3:
if (!(backup.odisDomain !== undefined)) return [3 /*break*/, 5];
domain = backup.odisDomain;
authorizer = (0, odis_1.odisQueryAuthorizer)(odisAuthKeySeed);
if (domain.address.defined && !(0, address_1.eqAddress)(authorizer.address, domain.address.value)) {
return [2 /*return*/, (0, result_1.Err)(new errors_1.InvalidBackupError(new Error('domain query authorizer address is provided but is not derived from the backup nonce')))];
}
if (((_c = backup.environment) === null || _c === void 0 ? void 0 : _c.odis) === undefined) {
return [2 /*return*/, (0, result_1.Err)(new errors_1.InvalidBackupError(new Error('ODIS domain is provided by no ODIS environment information')))];
}
debug('requesting a key hardening response from ODIS');
return [4 /*yield*/, (0, odis_1.odisHardenKey)(updatedKey, domain, backup.environment.odis, authorizer.wallet)];
case 4:
odisHardening = _d.sent();
if (!odisHardening.ok) {
return [2 /*return*/, (0, result_1.Err)(odisHardening.error)];
}
odisHardenedKey = odisHardening.result;
return [3 /*break*/, 6];
case 5:
debug('not using ODIS for key hardening');
_d.label = 6;
case 6:
if (!(backup.computationalHardening !== undefined)) return [3 /*break*/, 8];
debug('hardening user key with computational function', backup.computationalHardening);
return [4 /*yield*/, (0, utils_1.computationalHardenKey)(updatedKey, backup.computationalHardening)];
case 7:
computationalHardening = _d.sent();
if (!computationalHardening.ok) {
return [2 /*return*/, (0, result_1.Err)(computationalHardening.error)];
}
computationalHardenedKey = computationalHardening.result;
return [3 /*break*/, 9];
case 8:
debug('not using computational key hardening');
_d.label = 9;
case 9:
debug('finalizing decryption key');
finalKey = (0, utils_1.deriveKey)(utils_1.KDFInfo.FINALIZE, [
updatedKey,
odisHardenedKey !== null && odisHardenedKey !== void 0 ? odisHardenedKey : Buffer.alloc(0),
computationalHardenedKey !== null && computationalHardenedKey !== void 0 ? computationalHardenedKey : Buffer.alloc(0),
]);
debug('decrypting backup with finalized decryption key');
decryption = (0, utils_1.decrypt)(finalKey, backup.encryptedData);
if (!decryption.ok) {
return [2 /*return*/, (0, result_1.Err)(decryption.error)];
}
debug('decrypted backup');
return [2 /*return*/, (0, result_1.Ok)(decryption.result)];
}
});
});
}
exports.openBackup = openBackup;
//# sourceMappingURL=backup.js.map