UNPKG

@nomicfoundation/hardhat-keystore

Version:

A module for managing keystore files that store a map from IDs to encrypted string values.

660 lines (583 loc) 18.9 kB
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" as const; export const PASSWORD_NORMALIZATION_FORM = "NFKC" as const; // 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_DERIVATION_ALGORITHM = "scrypt" as const; export const KEY_DERIVATION_PARAM_N = 131_072 as const; export const KEY_DERIVATION_PARAM_R = 8 as const; export const KEY_DERIVATION_PARAM_P = 1 as const; export const KEY_DERIVATION_SALT_LENGTH_BYTES = 32 as const; export const MASTER_KEY_LENGTH_BITS = 256 as const; // HMAC-SHA-256 export const HMAC_ALGORITHM = "HMAC-SHA-256" as const; export const HMAC_KEY_LENGTH_BITS = 256 as const; // AES-GCM-SIV used for tolerance of IV collisions export const DATA_ENCRYPTION_ALGORITHM = "AES-GCM-SIV" as const; export const DATA_ENCRYPTION_KEY_LENGTH_BITS = 256 as const; export const DATA_ENCRYPTION_IV_LENGTH_BYTES = 12 as const; /// //////////////////////////////////////////////////////////////////////////// // Types /// //////////////////////////////////////////////////////////////////////////// /** * This interface represents an encrypted keystore. * * Every data buffer here is represented as a hex string (without "0x" prefix). */ export interface EncryptedKeystore { version: typeof KEYSTORE_VERSION; crypto: { masterKeyDerivation: { algorithm: typeof KEY_DERIVATION_ALGORITHM; paramN: typeof KEY_DERIVATION_PARAM_N; paramP: typeof KEY_DERIVATION_PARAM_P; paramR: typeof KEY_DERIVATION_PARAM_R; unicodeNormalizationForm: typeof PASSWORD_NORMALIZATION_FORM; keyLength: typeof MASTER_KEY_LENGTH_BITS; salt: string; }; encryption: { algorithm: typeof DATA_ENCRYPTION_ALGORITHM; keyLength: typeof DATA_ENCRYPTION_KEY_LENGTH_BITS; }; hmac: { algorithm: typeof HMAC_ALGORITHM; keyLength: typeof HMAC_KEY_LENGTH_BITS; }; }; dataEncryptionKey: SerializedEncryptedData; hmacKey: SerializedEncryptedData; hmac: string; secrets: Record<string, SerializedEncryptedData>; } /** * This interface represents an encrypted data buffer. */ export interface EncryptedData { /** * The initialization vector used to encrypt the data. */ iv: Uint8Array; /** * The encrypted data buffer. */ cypherText: Uint8Array; } /** * The hex-encoding serialization of EncryptedData. */ export interface SerializedEncryptedData { iv: string; // hex encoded cypherText: string; // hex encoded } // ///////////////////////////////////////////////////////////////////////////// // Serialization utilities // ///////////////////////////////////////////////////////////////////////////// /** * Serializes an EncryptedData object into a SerializedEncryptedData object. */ export function serializeEncryptedData( data: EncryptedData, ): SerializedEncryptedData { return { iv: bytesToHex(data.iv), cypherText: bytesToHex(data.cypherText), }; } /** * Deserializes a SerializedEncryptedData object into an EncryptedData object. */ export function deserializeEncryptedData( serializedData: SerializedEncryptedData, ): EncryptedData { 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<ObjectT extends object>( obj: ObjectT, ): string { 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: any = {}; 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. // ///////////////////////////////////////////////////////////////////////////// abstract class CustomError extends Error { public override stack!: string; constructor(message: string, cause?: Error) { super(message, cause !== undefined ? { cause } : undefined); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } } export class UnsupportedTypeInDeterministicJsonError extends CustomError { public readonly type: string; constructor(type: string) { super( `Unsupported type in deterministicJson: ${ type === "object" ? "array or null" : type }`, ); this.type = type; } } export class DecryptionError extends CustomError { constructor(cause?: Error) { 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 { public readonly key: string; constructor(key: string) { super(`Secret with key "${key}" not found in the keystore`); this.key = key; } } export class HmacKeyDecryptionError extends CustomError { constructor(cause?: Error) { 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, }: { encryptionKey: Uint8Array; value: string; }): EncryptedData { 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, }: { encryptionKey: Uint8Array; data: EncryptedData; }): string { let decryptedBuffer: Uint8Array; 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 }: { password: string }): { salt: Uint8Array; masterKey: Uint8Array; } { 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, }: { masterKey: Uint8Array; salt: Uint8Array; }): EncryptedKeystore { const dataEncryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8); const hmacKey = randomBytes(HMAC_KEY_LENGTH_BITS / 8); const hmacPreImageObject: Omit<EncryptedKeystore, "hmac"> = { version: KEYSTORE_VERSION, crypto: { masterKeyDerivation: { algorithm: KEY_DERIVATION_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, }: { password: string; encryptedKeystore: EncryptedKeystore; }): Uint8Array { 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, }: { masterKey: Uint8Array; encryptedKeystore: EncryptedKeystore; key: string; }): boolean { 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, }: { masterKey: Uint8Array; encryptedKeystore: EncryptedKeystore; key: string; value: string; }): EncryptedKeystore { 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, }: { masterKey: Uint8Array; encryptedKeystore: EncryptedKeystore; keyToRemove: string; }): EncryptedKeystore { 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, }: { masterKey: Uint8Array; encryptedKeystore: EncryptedKeystore; key: string; }): string { 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: string; salt: Uint8Array; }): Uint8Array { 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, without the * hmac field. * @returns The hmac. */ export function generateEncryptedKeystoreHmac({ masterKey, encryptedKeystore, }: { masterKey: Uint8Array; encryptedKeystore: Omit<EncryptedKeystore, "hmac">; }): Uint8Array { let hmacKey: Uint8Array; 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, }: { masterKey: Uint8Array; encryptedKeystore: EncryptedKeystore; }): void { 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(); } }