@nomicfoundation/hardhat-keystore
Version:
A module for managing keystore files that store a map from IDs to encrypted string values.
421 lines • 16.4 kB
JavaScript
import { siv } from "@noble/ciphers/aes";
import { hmac } from "@noble/hashes/hmac";
import { scrypt } from "@noble/hashes/scrypt";
import { sha256 } from "@noble/hashes/sha2";
import { randomBytes, bytesToHex, hexToBytes } from "@noble/hashes/utils";
/// ////////////////////////////////////////////////////////////////////////////
// Constants
/// ////////////////////////////////////////////////////////////////////////////
export const KEYSTORE_VERSION = "hardhat-v3-keystore-1";
export const PASSWORD_NORMALIZATION_FORM = "NFKC";
// Scrypt recommendation based on OWASP and noble-hashes implementation:
// See: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt
// And: https://github.com/paulmillr/noble-hashes/blob/5cadc86d2cae1184607989817854813ecc7033a9/README.md
// Parameters based on OWASP's cheat sheet: N=2^17 (128 MiB), r=8 (1024 bytes), p=1
export const KEY_DERIVARION_ALGORITHM = "scrypt";
export const KEY_DERIVATION_PARAM_N = 131_072;
export const KEY_DERIVATION_PARAM_R = 8;
export const KEY_DERIVATION_PARAM_P = 1;
export const KEY_DERIVATION_SALT_LENGTH_BYTES = 32;
export const MASTER_KEY_LENGTH_BITS = 256;
// HMAC-SHA-256
export const HMAC_ALGORITHM = "HMAC-SHA-256";
export const HMAC_KEY_LENGTH_BITS = 256;
// AES-GCM-SIV used for tolerance of IV collisions
export const DATA_ENCRYPTION_ALGORITHM = "AES-GCM-SIV";
export const DATA_ENCRYPTION_KEY_LENGTH_BITS = 256;
export const DATA_ENCRYPTION_IV_LENGTH_BYTES = 12;
// /////////////////////////////////////////////////////////////////////////////
// Serialization utilities
// /////////////////////////////////////////////////////////////////////////////
/**
* Serializes an EncryptedData object into a SerializedEncryptedData object.
*/
export function serializeEncryptedData(data) {
return {
iv: bytesToHex(data.iv),
cypherText: bytesToHex(data.cypherText),
};
}
/**
* Deserializes a SerializedEncryptedData object into an EncryptedData object.
*/
export function deserializeEncryptedData(serializedData) {
return {
iv: hexToBytes(serializedData.iv),
cypherText: hexToBytes(serializedData.cypherText),
};
}
/**
* Uses JSON.stringify with a custom replacer to make sure that a
* JsonWithNumbersAndStrings is serialized deterministically.
*
* This function only supports objects whose values are numbers, strings, or
* objects with the same constraints.
*/
export function deterministicJsonStringify(obj) {
return JSON.stringify(obj, function stableReplacer(key, value) {
if (typeof value === "string" ||
typeof value === "number" ||
typeof value === "undefined") {
return value;
}
if (typeof value !== "object") {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new UnsupportedTypeInDeterministicJsonError(typeof value);
}
if (value === null) {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new UnsupportedTypeInDeterministicJsonError("null");
}
if (Array.isArray(value)) {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new UnsupportedTypeInDeterministicJsonError("array");
}
// Sort object keys in ascending order, then build a new object.
const sortedKeys = Object.keys(value).sort();
const newObj = {};
for (const k of sortedKeys) {
newObj[k] = value[k];
}
return newObj;
});
}
// /////////////////////////////////////////////////////////////////////////////
// Custom error types: We don't use HardhatError here, because we want this
// module to be as self contained as possible. The only dependencies are
// @noble/ciphers and @noble/hashes.
// /////////////////////////////////////////////////////////////////////////////
class CustomError extends Error {
stack;
constructor(message, cause) {
super(message, cause !== undefined ? { cause } : undefined);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class UnsupportedTypeInDeterministicJsonError extends CustomError {
type;
constructor(type) {
super(`Unsupported type in deterministicJson: ${type === "object" ? "array or null" : type}`);
this.type = type;
}
}
export class DecryptionError extends CustomError {
constructor(cause) {
super("Decryption failed: make sure you are using the right password/key and that your encrypted data isn't corrupted", cause);
}
}
export class SecretNotFoundError extends CustomError {
key;
constructor(key) {
super(`Secret with key "${key}" not found in the keystore`);
this.key = key;
}
}
export class HmacKeyDecryptionError extends CustomError {
constructor(cause) {
super("Invalid hmac key: make sure you are using the right password/key and that your encrypted data isn't corrupted", cause);
}
}
export class InvalidHmacError extends CustomError {
constructor() {
super(`Invalid hmac in keystore`);
}
}
/// ////////////////////////////////////////////////////////////////////////////
// Generic crypto utils
/// ////////////////////////////////////////////////////////////////////////////
/**
* Encrypts the utf-8 encoded value using the master key, and a new random iv.
*
* @param encryptionKey The encryption key to use.
* @param value The value to encrypt, which will be utf-8 encoded.
* @returns An object containing the iv and cypherText.
*
* @remarks
* The random IV is only 12 bytes, so we are assuming that no more than 2^20 encryptions are done with the same key
* as the probability of IV collision reaches 2^-57 at that point.
*/
export function encryptUtf8String({ encryptionKey, value, }) {
const iv = randomBytes(DATA_ENCRYPTION_IV_LENGTH_BYTES);
const cypherText = siv(encryptionKey, iv).encrypt(new TextEncoder().encode(value));
return { iv, cypherText };
}
/**
* Decrypts an utf-8 string using the master key and the iv.
*
* @param encryptionKey The encryption key to use.
* @param iv The iv to use.
* @param cypherText The cypherText to decrypt, which will then be utf-8
* decoded.
* @returns The decrypted value.
*/
export function decryptUtf8String({ encryptionKey, data, }) {
let decryptedBuffer;
try {
decryptedBuffer = siv(encryptionKey, data.iv).decrypt(data.cypherText);
}
catch (error) {
if (!(error instanceof Error)) {
throw error;
}
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new DecryptionError(error);
}
return new TextDecoder().decode(decryptedBuffer);
}
/// /////////////////////////////////////////////////////////////////////////////
// Keystore primitives
/// /////////////////////////////////////////////////////////////////////////////
/**
* Creates a new master key from the password. This function can be called
* multiple times to derive new keys from the same password.
*
* @param password The user's password.
* @returns An object containing the salt and master key.
*/
export function createMasterKey({ password }) {
const salt = randomBytes(KEY_DERIVATION_SALT_LENGTH_BYTES);
const masterKey = deriveMasterKey({ password, salt });
return { salt, masterKey };
}
/**
* Creates an empty EncryptedKeystore.
*
* To add and remove secrets to it see `addSecretToKeystore` and
* `removeSecretFromKeystore`.
*
* @param masterKey The master key to use.
* @param salt The salt of the master key.
* @returns The empty EncryptedKeystore.
*/
export function createEmptyEncryptedKeystore({ masterKey, salt, }) {
const dataEncryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8);
const hmacKey = randomBytes(HMAC_KEY_LENGTH_BITS / 8);
const hmacPreImageObject = {
version: KEYSTORE_VERSION,
crypto: {
masterKeyDerivation: {
algorithm: KEY_DERIVARION_ALGORITHM,
paramN: KEY_DERIVATION_PARAM_N,
paramP: KEY_DERIVATION_PARAM_P,
paramR: KEY_DERIVATION_PARAM_R,
unicodeNormalizationForm: PASSWORD_NORMALIZATION_FORM,
keyLength: MASTER_KEY_LENGTH_BITS,
salt: bytesToHex(salt),
},
encryption: {
algorithm: DATA_ENCRYPTION_ALGORITHM,
keyLength: DATA_ENCRYPTION_KEY_LENGTH_BITS,
},
hmac: {
algorithm: HMAC_ALGORITHM,
keyLength: HMAC_KEY_LENGTH_BITS,
},
},
hmacKey: serializeEncryptedData(encryptUtf8String({
encryptionKey: masterKey,
value: bytesToHex(hmacKey),
})),
dataEncryptionKey: serializeEncryptedData(encryptUtf8String({
encryptionKey: masterKey,
value: bytesToHex(dataEncryptionKey),
})),
secrets: {},
};
return {
...hmacPreImageObject,
hmac: bytesToHex(generateEncryptedKeystoreHmac({
masterKey,
encryptedKeystore: hmacPreImageObject,
})),
};
}
/**
* Derives the master key from an existing keystore, using the user's password.
*
* @param password The user's password.
* @param encryptedKeystore The keystore, where the master key's salt is stored.
* @returns The derived master key. This value is safe to keep in memory.
*/
export function deriveMasterKeyFromKeystore({ password, encryptedKeystore, }) {
const salt = hexToBytes(encryptedKeystore.crypto.masterKeyDerivation.salt);
return deriveMasterKey({ password, salt });
}
/**
* Checks if the specified key exists in the provided encrypted keystore.
*
* @param masterKey The master key to use.
* @param encryptedKeystore - The encrypted keystore object containing the secrets.
* @param key - The name of the secret to check for existence.
* @returns True if the key is present in the keystore, otherwise false.
*
* @remarks
* This function first calls `validateHmac` to verify the cryptographic integrity of
* the keystore before checking for the existence of the specified key.
*/
export function doesKeyExist({ masterKey, encryptedKeystore, key, }) {
validateHmac({ masterKey, encryptedKeystore });
return Object.keys(encryptedKeystore.secrets).includes(key);
}
/**
* Adds a secret to an existing keystore.
*
* @param masterKey The master key to use.
* @param encryptedKeystore The keystore to add the secret to.
* @param key The key of the secret to add.
* @param value The value of the secret to add.
* @returns A new EncryptedKeystore, where the secret has been added.
*/
export function addSecretToKeystore({ masterKey, encryptedKeystore, key, value, }) {
validateHmac({ masterKey, encryptedKeystore });
const dataEncryptionKey = hexToBytes(decryptUtf8String({
encryptionKey: masterKey,
data: deserializeEncryptedData(encryptedKeystore.dataEncryptionKey),
}));
const secrets = {
...encryptedKeystore.secrets,
[key]: serializeEncryptedData(encryptUtf8String({ encryptionKey: dataEncryptionKey, value })),
};
const updatedEncryptedKeystoreWithoutHmac = {
...encryptedKeystore,
secrets,
hmac: undefined,
};
const updatedHmac = generateEncryptedKeystoreHmac({
masterKey,
encryptedKeystore: updatedEncryptedKeystoreWithoutHmac,
});
return {
...updatedEncryptedKeystoreWithoutHmac,
hmac: bytesToHex(updatedHmac),
};
}
/**
* Removes a secret from an existing keystore.
*
* @param masterKey The master key to use.
* @param encryptedKeystore The keystore to remove the secret from.
* @param keyToRemove The key of the secret to remove.
* @returns A new EncryptedKeystore, where the secret has been removed.
*/
export function removeSecretFromKeystore({ masterKey, encryptedKeystore, keyToRemove, }) {
validateHmac({ masterKey, encryptedKeystore });
if (!(keyToRemove in encryptedKeystore.secrets)) {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new SecretNotFoundError(keyToRemove);
}
const secrets = {
...encryptedKeystore.secrets,
};
delete secrets[keyToRemove];
const updatedEncryptedKeystoreWithoutHmac = {
...encryptedKeystore,
secrets,
hmac: undefined,
};
const updatedHmac = generateEncryptedKeystoreHmac({
masterKey,
encryptedKeystore: updatedEncryptedKeystoreWithoutHmac,
});
return {
...updatedEncryptedKeystoreWithoutHmac,
hmac: bytesToHex(updatedHmac),
};
}
/**
* Decrypts an individual secret from the EncryptedKeystoreValuesEnvelope.
*
* @param masterKey The master key to use.
* @param valuesEnvelope The EncryptedKeystoreValuesEnvelope, where the secret
* is stored.
* @param key The key of the secret to decrypt.
* @returns The decrypted secret. Do not keep this value in memory.
*/
export function decryptSecret({ masterKey, encryptedKeystore, key, }) {
validateHmac({ masterKey, encryptedKeystore });
if (!(key in encryptedKeystore.secrets)) {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new SecretNotFoundError(key);
}
const dataEncryptionKey = hexToBytes(decryptUtf8String({
encryptionKey: masterKey,
data: deserializeEncryptedData(encryptedKeystore.dataEncryptionKey),
}));
const encryptedData = encryptedKeystore.secrets[key];
return decryptUtf8String({
encryptionKey: dataEncryptionKey,
data: deserializeEncryptedData(encryptedData),
});
}
// /////////////////////////////////////////////////////////////////////////////
// Internal keystore primitives: Some are exported for testing purposes
// /////////////////////////////////////////////////////////////////////////////
/**
* Derives a master key based on the user's password and an existing salt
* (normally obtained from an EncryptedKeystore).
*
* @param password The user's password.
* @param salt The existing salt.
* @returns The derived master key.
*/
function deriveMasterKey({ password, salt, }) {
password = password.normalize(PASSWORD_NORMALIZATION_FORM);
const masterKey = scrypt(password, salt, {
N: KEY_DERIVATION_PARAM_N,
r: KEY_DERIVATION_PARAM_R,
p: KEY_DERIVATION_PARAM_P,
dkLen: MASTER_KEY_LENGTH_BITS / 8,
});
return masterKey;
}
/**
* Generates the hmac of an encrypted keystore.
*
* @param masterKey The keystore's master key to use.
* @param encryptedKeystore The keystore to generate the hmac for, whithout the
* hmac field.
* @returns The hmac.
*/
export function generateEncryptedKeystoreHmac({ masterKey, encryptedKeystore, }) {
let hmacKey;
try {
const hmacKeyString = decryptUtf8String({
encryptionKey: masterKey,
data: deserializeEncryptedData(encryptedKeystore.hmacKey),
});
hmacKey = hexToBytes(hmacKeyString);
}
catch (error) {
if (!(error instanceof DecryptionError)) {
throw error;
}
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new HmacKeyDecryptionError(error);
}
const json = deterministicJsonStringify({
...encryptedKeystore,
hmac: undefined,
});
return hmac(sha256, hmacKey, new TextEncoder().encode(json));
}
/**
* Throws an error if the hmac present in the encrypted keystore doesn't match
* a newly generated one.
*
* @param masterKey The keystore's master key to use.
* @param encryptedKeystore The keystore whose hmac should be validated.
*/
export function validateHmac({ masterKey, encryptedKeystore, }) {
const generatedHmac = generateEncryptedKeystoreHmac({
masterKey,
encryptedKeystore,
});
const generatedHmacHex = bytesToHex(generatedHmac);
if (generatedHmacHex !== encryptedKeystore.hmac) {
// eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here
throw new InvalidHmacError();
}
}
//# sourceMappingURL=encryption.js.map