From 999d2287e8778430ee8d3a52c290c7a2d880f19c Mon Sep 17 00:00:00 2001 From: Patricio Palladino Date: Sun, 2 Feb 2025 20:43:49 +0000 Subject: [PATCH] Add an encryption module to hardhat-keystore --- .../src/internal/keystores/encryption.ts | 628 ++++++++++ .../test/keystores/encryption.ts | 1093 +++++++++++++++++ 2 files changed, 1721 insertions(+) create mode 100644 v-next/hardhat-keystore/src/internal/keystores/encryption.ts create mode 100644 v-next/hardhat-keystore/test/keystores/encryption.ts diff --git a/v-next/hardhat-keystore/src/internal/keystores/encryption.ts b/v-next/hardhat-keystore/src/internal/keystores/encryption.ts new file mode 100644 index 0000000000..ec086c3aca --- /dev/null +++ b/v-next/hardhat-keystore/src/internal/keystores/encryption.ts @@ -0,0 +1,628 @@ +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_DERIVARION_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 (withoyt "0x" prefix). + */ +export interface EncryptedKeystore { + version: typeof KEYSTORE_VERSION; + crypto: { + masterKeyDerivation: { + algorithm: typeof KEY_DERIVARION_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; +} + +/** + * 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( + 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. + */ +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 = { + 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, +}: { + password: string; + encryptedKeystore: EncryptedKeystore; +}): Uint8Array { + const salt = hexToBytes(encryptedKeystore.crypto.masterKeyDerivation.salt); + + return deriveMasterKey({ password, salt }); +} + +/** + * 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 { + if (!(keyToRemove in encryptedKeystore.secrets)) { + // eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here + throw new SecretNotFoundError(keyToRemove); + } + + validateHmac({ masterKey, encryptedKeystore }); + + 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 { + if (!(key in encryptedKeystore.secrets)) { + // eslint-disable-next-line no-restricted-syntax -- We don't throw HardhatErrors here + throw new SecretNotFoundError(key); + } + + validateHmac({ masterKey, encryptedKeystore }); + + 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 { + 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, +}: { + masterKey: Uint8Array; + encryptedKeystore: Omit; +}): 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(); + } +} diff --git a/v-next/hardhat-keystore/test/keystores/encryption.ts b/v-next/hardhat-keystore/test/keystores/encryption.ts new file mode 100644 index 0000000000..6f28f25978 --- /dev/null +++ b/v-next/hardhat-keystore/test/keystores/encryption.ts @@ -0,0 +1,1093 @@ +import type { + EncryptedData, + EncryptedKeystore, +} from "../../src/internal/keystores/encryption.js"; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { bytesToHex, hexToBytes, randomBytes } from "@noble/hashes/utils"; +import { assertThrows } from "@nomicfoundation/hardhat-test-utils"; + +import { + addSecretToKeystore, + createEmptyEncryptedKeystore, + createMasterKey, + DATA_ENCRYPTION_ALGORITHM, + DATA_ENCRYPTION_IV_LENGTH_BYTES, + DATA_ENCRYPTION_KEY_LENGTH_BITS, + DecryptionError, + decryptSecret, + decryptUtf8String, + deriveMasterKeyFromKeystore, + deserializeEncryptedData, + deterministicJsonStringify, + encryptUtf8String, + generateEncryptedKeystoreHmac, + HMAC_ALGORITHM, + HMAC_KEY_LENGTH_BITS, + InvalidHmacError, + HmacKeyDecryptionError, + KEY_DERIVARION_ALGORITHM, + KEY_DERIVATION_PARAM_N, + KEY_DERIVATION_PARAM_P, + KEY_DERIVATION_PARAM_R, + KEY_DERIVATION_SALT_LENGTH_BYTES, + KEYSTORE_VERSION, + MASTER_KEY_LENGTH_BITS, + PASSWORD_NORMALIZATION_FORM, + removeSecretFromKeystore, + SecretNotFoundError, + serializeEncryptedData, + UnsupportedTypeInDeterministicJsonError, + validateHmac, +} from "../../src/internal/keystores/encryption.js"; + +describe("Serialization utilities", () => { + describe("serializeEncryptedData and deserializeEncryptedData", () => { + it("Should allow a round trip", () => { + const encryptedData: EncryptedData = { + iv: new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + ]), + cypherText: new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]), + }; + + const serializedEncryptedData = serializeEncryptedData(encryptedData); + + const deserializedEncryptedData = deserializeEncryptedData( + serializedEncryptedData, + ); + + assert.deepEqual(deserializedEncryptedData, encryptedData); + + const serializedEncryptedData2 = serializeEncryptedData( + deserializedEncryptedData, + ); + + assert.deepEqual(serializedEncryptedData, serializedEncryptedData2); + }); + }); + + describe("deterministicJsonStringify", () => { + it("deterministicJsonStringify sorts keys of a simple object", async () => { + const obj = { b: "2", a: "1" }; + const result = deterministicJsonStringify(obj); + assert.equal(result, '{"a":"1","b":"2"}'); + }); + + it("deterministicJsonStringify sorts keys in nested objects", async () => { + const obj = { + b: { d: "4", c: "3" }, + a: "1", + }; + const result = deterministicJsonStringify(obj); + assert.equal(result, '{"a":"1","b":{"c":"3","d":"4"}}'); + }); + + it("deterministicJsonStringify omits undefined properties", async () => { + const obj = { + a: undefined, + b: "hello", + }; + const result = deterministicJsonStringify(obj); + // JSON.stringify omits properties whose values are undefined. + assert.equal(result, '{"b":"hello"}'); + }); + + it("deterministicJsonStringify serializes numbers correctly", async () => { + const obj = { y: 1, x: 2 }; + const result = deterministicJsonStringify(obj); + // Even if the input order is different, keys in the output should be sorted. + assert.equal(result, '{"x":2,"y":1}'); + }); + + it("deterministicJsonStringify throws for boolean values", async () => { + const obj = { a: true }; + assertThrows( + () => deterministicJsonStringify(obj), + (e) => e instanceof UnsupportedTypeInDeterministicJsonError, + ); + }); + + it("deterministicJsonStringify throws for array values", async () => { + const obj = { a: [1, 2, 3] }; + assertThrows( + () => deterministicJsonStringify(obj), + (e) => e instanceof UnsupportedTypeInDeterministicJsonError, + ); + }); + + it("deterministicJsonStringify throws for null values", async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Assertion for testing purposes. + const obj = { a: null }; + assertThrows( + () => deterministicJsonStringify(obj), + (e) => e instanceof UnsupportedTypeInDeterministicJsonError, + ); + }); + }); +}); + +describe("Generic crypto utils", () => { + describe("encryptUtf8String and decryptUtf8String", () => { + it("Should allow a round trip", () => { + const encryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8); + const value = "non ascii secret: niño"; + + const encryptedData = encryptUtf8String({ + encryptionKey, + value, + }); + + const decryptedValue = decryptUtf8String({ + encryptionKey, + data: encryptedData, + }); + + assert.equal(decryptedValue, value); + }); + + it("Should use a different iv for each encryption", () => { + const encryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8); + const value = "non ascii secret: niño"; + + const encryptedData1 = encryptUtf8String({ + encryptionKey, + value, + }); + const encryptedData2 = encryptUtf8String({ + encryptionKey, + value, + }); + + assert.notDeepEqual(encryptedData1.iv, encryptedData2.iv); + assert.notDeepEqual(encryptedData1.cypherText, encryptedData2.cypherText); + }); + + it("Should use the right size for the iv", () => { + const encryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8); + const value = "non ascii secret: niño"; + + const encryptedData = encryptUtf8String({ + encryptionKey, + value, + }); + + assert.equal(encryptedData.iv.length, DATA_ENCRYPTION_IV_LENGTH_BYTES); + }); + + it("Should throw if it fails to decrypt", () => { + const encryptionKey = randomBytes(DATA_ENCRYPTION_KEY_LENGTH_BITS / 8); + const value = "non ascii secret: niño"; + + const encryptedData = encryptUtf8String({ + encryptionKey, + value, + }); + + assertThrows( + () => + decryptUtf8String({ + encryptionKey, + data: { + iv: encryptedData.iv, + cypherText: Buffer.concat([ + encryptedData.cypherText, + Buffer.from("00", "hex"), + ]), + }, + }), + (e) => e instanceof DecryptionError, + ); + + const mutatedIv = Buffer.from(encryptedData.iv); + mutatedIv[0] = 0; + mutatedIv[1] = 0; + mutatedIv[2] = 0; + + assertThrows( + () => + decryptUtf8String({ + encryptionKey, + data: { + iv: mutatedIv, + cypherText: encryptedData.cypherText, + }, + }), + (e) => e instanceof DecryptionError, + ); + + const mutatedEncryptionKey = Buffer.from(encryptionKey); + mutatedEncryptionKey[0] = 0; + mutatedEncryptionKey[1] = 0; + mutatedEncryptionKey[2] = 0; + + assertThrows( + () => + decryptUtf8String({ + encryptionKey: mutatedEncryptionKey, + data: encryptedData, + }), + (e) => e instanceof DecryptionError, + ); + }); + }); +}); + +describe("Keystore primitives", () => { + describe("createMasterKey", () => { + it("Should create new master key and salt every time", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + const { salt: salt2, masterKey: masterKey2 } = createMasterKey({ + password, + }); + + assert.notDeepEqual(masterKey, masterKey2); + assert.notDeepEqual(salt, salt2); + }); + + it("Should use the right size for the salt and master key", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + assert.equal(salt.length, KEY_DERIVATION_SALT_LENGTH_BYTES); + assert.equal(masterKey.length, MASTER_KEY_LENGTH_BITS / 8); + }); + }); + + describe("Empty keystore creation", () => { + it("Should use the provided master key and salt", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + // We decrypt the data encryption key and hmac key to make sure they are + // encrypted with the master key. + const dataEncryptionKey = hexToBytes( + decryptUtf8String({ + encryptionKey: masterKey, + data: deserializeEncryptedData(emptyKeystore.dataEncryptionKey), + }), + ); + + const hmacKey = hexToBytes( + decryptUtf8String({ + encryptionKey: masterKey, + data: deserializeEncryptedData(emptyKeystore.hmacKey), + }), + ); + + assert.equal( + dataEncryptionKey.length, + DATA_ENCRYPTION_KEY_LENGTH_BITS / 8, + ); + + assert.equal(hmacKey.length, HMAC_KEY_LENGTH_BITS / 8); + + // We validate that the salt is the same as the one provided + assert.equal( + emptyKeystore.crypto.masterKeyDerivation.salt, + bytesToHex(salt), + ); + + // We derive the master key from the password and the salt and it should + // match the original master key + const masterKey2 = deriveMasterKeyFromKeystore({ + password, + encryptedKeystore: emptyKeystore, + }); + + assert.deepEqual(masterKey, masterKey2); + }); + + it("Should set the right version and encryption values", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assert.equal(emptyKeystore.version, KEYSTORE_VERSION); + assert.deepEqual(emptyKeystore.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), + }); + + assert.deepEqual(emptyKeystore.crypto.encryption, { + algorithm: DATA_ENCRYPTION_ALGORITHM, + keyLength: DATA_ENCRYPTION_KEY_LENGTH_BITS, + }); + + assert.deepEqual(emptyKeystore.crypto.hmac, { + algorithm: HMAC_ALGORITHM, + keyLength: HMAC_KEY_LENGTH_BITS, + }); + }); + + it("Should create different data encryption keys every time", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const emptyKeystore2 = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assert.notDeepEqual( + emptyKeystore.dataEncryptionKey, + emptyKeystore2.dataEncryptionKey, + ); + }); + + it("Should create different hmac keys every time", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const emptyKeystore2 = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assert.notDeepEqual(emptyKeystore.hmacKey, emptyKeystore2.hmacKey); + }); + + it("Should create a valid hmac", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const hmac = generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: emptyKeystore, + }); + + assert.deepEqual(hmac, hexToBytes(emptyKeystore.hmac)); + }); + + it("Should have no secrets", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assert.deepEqual(emptyKeystore.secrets, {}); + }); + }); + + describe("deriveMasterKeyFromKeystore", () => { + it("Should derive the master key from the keystore", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const derivedMasterKey = deriveMasterKeyFromKeystore({ + password, + encryptedKeystore: emptyKeystore, + }); + + assert.deepEqual(derivedMasterKey, masterKey); + }); + }); + + describe("generateEncryptedKeystoreHmac", () => { + it("Should generate the same hmac of an empty encrypted keystore every time", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const hmac2 = generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: emptyKeystore, + }); + + const hmac3 = generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: emptyKeystore, + }); + + assert.deepEqual(hmac3, hmac2); + assert.deepEqual(hmac3, hexToBytes(emptyKeystore.hmac)); + assert.deepEqual(hmac2, hexToBytes(emptyKeystore.hmac)); + }); + + it("Should generate the same hmac of non empty encrypted keystore every time", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const nonEmptyKeystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value: "my-secret-value", + }); + + const hmac2 = generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: nonEmptyKeystore, + }); + + const hmac3 = generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: nonEmptyKeystore, + }); + + assert.deepEqual(hmac3, hmac2); + assert.deepEqual(hmac3, hexToBytes(nonEmptyKeystore.hmac)); + assert.deepEqual(hmac2, hexToBytes(nonEmptyKeystore.hmac)); + }); + + it("Should throw if the hmac key is corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + generateEncryptedKeystoreHmac({ + masterKey, + encryptedKeystore: { + ...emptyKeystore, + hmacKey: { + iv: emptyKeystore.hmacKey.iv, + cypherText: "0000000000" + emptyKeystore.hmac.slice(10), + }, + }, + }), + (e) => e instanceof HmacKeyDecryptionError, + ); + }); + }); + + describe("Adding secrets to keystore", () => { + it("Should add multiple secrets to a keystore, modifying only the secrets and hmac, allowing to overwrite secrets by key, and generating different cyphertext and ivs for the same secret values", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + let previousKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + let newKeystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: previousKeystore, + key: "my-secret", + value: "my-secret-value", + }); + + assert.deepEqual(newKeystore.crypto, previousKeystore.crypto); + assert.deepEqual( + newKeystore.dataEncryptionKey, + previousKeystore.dataEncryptionKey, + ); + assert.deepEqual(newKeystore.hmacKey, previousKeystore.hmacKey); + + assert.notDeepEqual(newKeystore.hmac, previousKeystore.hmac); + assert.notDeepEqual(newKeystore.secrets, previousKeystore.secrets); + + assert.ok( + "my-secret" in newKeystore.secrets, + "The secret should be present", + ); + + // Adding a new secret + previousKeystore = newKeystore; + newKeystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: previousKeystore, + key: "my-secret-2", + value: "my-secret-value-2", + }); + + assert.deepEqual(newKeystore.crypto, previousKeystore.crypto); + assert.deepEqual( + newKeystore.dataEncryptionKey, + previousKeystore.dataEncryptionKey, + ); + assert.deepEqual(newKeystore.hmacKey, previousKeystore.hmacKey); + + assert.notDeepEqual(newKeystore.hmac, previousKeystore.hmac); + assert.notDeepEqual(newKeystore.secrets, previousKeystore.secrets); + + assert.ok( + "my-secret" in newKeystore.secrets, + "The secret should be present", + ); + assert.ok( + "my-secret-2" in newKeystore.secrets, + "The secret should be present", + ); + + // Overwritting a secret + previousKeystore = newKeystore; + newKeystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: previousKeystore, + key: "my-secret-2", + value: "my-secret-value-2", + }); + + assert.deepEqual(newKeystore.crypto, previousKeystore.crypto); + assert.deepEqual( + newKeystore.dataEncryptionKey, + previousKeystore.dataEncryptionKey, + ); + assert.deepEqual(newKeystore.hmacKey, previousKeystore.hmacKey); + + assert.notDeepEqual(newKeystore.hmac, previousKeystore.hmac); + assert.notDeepEqual(newKeystore.secrets, previousKeystore.secrets); + + assert.ok( + "my-secret" in newKeystore.secrets, + "The secret should be present", + ); + assert.ok( + "my-secret-2" in newKeystore.secrets, + "The secret should be present", + ); + + assert.notDeepEqual( + newKeystore.secrets["my-secret-2"], + previousKeystore.secrets["my-secret-2"], + ); + }); + + it("Should validate the hmac", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + addSecretToKeystore({ + masterKey, + encryptedKeystore: { ...emptyKeystore, hmac: "invalid-hmac" }, + key: "my-secret", + value: "my-secret-value", + }), + (e) => e instanceof InvalidHmacError, + ); + + assertThrows( + () => + addSecretToKeystore({ + masterKey, + encryptedKeystore: { + ...emptyKeystore, + hmac: "0000000000" + emptyKeystore.hmac.slice(10), + }, + key: "my-secret", + value: "my-secret-value", + }), + (e) => e instanceof InvalidHmacError, + ); + }); + }); + + describe("Removing secrets to keystore", () => { + it("Should throw if the secret is not present", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + removeSecretFromKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + keyToRemove: "my-secret", + }), + (e) => e instanceof SecretNotFoundError, + ); + }); + + it("Should remove secrets from a keystore, modifying only that secret and the hmac", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + let keystore = emptyKeystore; + keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: keystore, + key: "my-secret", + value: "my-secret-value", + }); + + keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: keystore, + key: "my-secret-2", + value: "my-secret-value-2", + }); + + let previousKeystore = keystore; + + keystore = removeSecretFromKeystore({ + masterKey, + encryptedKeystore: keystore, + keyToRemove: "my-secret", + }); + + assert.deepEqual(keystore.crypto, previousKeystore.crypto); + assert.deepEqual( + keystore.dataEncryptionKey, + previousKeystore.dataEncryptionKey, + ); + assert.deepEqual(keystore.hmacKey, previousKeystore.hmacKey); + assert.deepEqual( + keystore.secrets["my-secret-2"], + previousKeystore.secrets["my-secret-2"], + ); + + assert.notDeepEqual(keystore.hmac, previousKeystore.hmac); + assert.notDeepEqual(keystore.secrets, previousKeystore.secrets); + + assert.ok( + !("my-secret" in keystore.secrets), + "The secret shouldn't be present after removing it", + ); + + previousKeystore = keystore; + + keystore = removeSecretFromKeystore({ + masterKey, + encryptedKeystore: keystore, + keyToRemove: "my-secret-2", + }); + + assert.deepEqual(keystore.crypto, previousKeystore.crypto); + assert.deepEqual( + keystore.dataEncryptionKey, + previousKeystore.dataEncryptionKey, + ); + assert.deepEqual(keystore.hmacKey, previousKeystore.hmacKey); + assert.deepEqual(keystore.secrets, {}); + + assert.notDeepEqual(keystore.hmac, previousKeystore.hmac); + }); + + it("Should validate the hmac", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value: "my-secret-value", + }); + + assertThrows( + () => + removeSecretFromKeystore({ + masterKey, + encryptedKeystore: { + ...keystore, + hmac: "0000000000" + keystore.hmac.slice(10), + }, + keyToRemove: "my-secret", + }), + (e) => e instanceof InvalidHmacError, + ); + }); + }); + + describe("Decrypting a secret from keystore", () => { + it("Should throw if the secret is not present", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + decryptSecret({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + }), + (e) => e instanceof SecretNotFoundError, + ); + }); + + it("Should allow a round trip of adding and decrypting a secret", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const value = "my-secret-value"; + + const keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value, + }); + + const secret = decryptSecret({ + masterKey, + encryptedKeystore: keystore, + key: "my-secret", + }); + + assert.equal(secret, value); + }); + + it("Should throw if the password is incorrect", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const value = "my-secret-value"; + + const keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value, + }); + + const incorrectMasterKey = deriveMasterKeyFromKeystore({ + password: "incorrect-password", + encryptedKeystore: keystore, + }); + + assertThrows( + () => + decryptSecret({ + masterKey: incorrectMasterKey, + encryptedKeystore: keystore, + key: "my-secret", + }), + (e) => e instanceof HmacKeyDecryptionError, + ); + }); + + it("Should validate the hmac", () => { + it("Should validate the hmac", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value: "my-secret-value", + }); + + assertThrows( + () => + decryptSecret({ + masterKey, + encryptedKeystore: { + ...keystore, + hmac: "0000000000" + keystore.hmac.slice(10), + }, + key: "my-secret", + }), + (e) => e instanceof InvalidHmacError, + ); + }); + }); + }); + + describe("hmac validation", () => { + it("Should throw if the hmac is invalid", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + validateHmac({ + masterKey, + encryptedKeystore: { + ...emptyKeystore, + hmac: "0000000000" + emptyKeystore.hmac.slice(10), + }, + }), + (e) => e instanceof InvalidHmacError, + ); + }); + + it("Should throw if the hmac key is corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + validateHmac({ + masterKey, + encryptedKeystore: { + ...emptyKeystore, + hmacKey: { + iv: emptyKeystore.hmacKey.iv, + cypherText: "0000000000" + emptyKeystore.hmac.slice(10), + }, + }, + }), + (e) => e instanceof HmacKeyDecryptionError, + ); + }); + + it("Should throw if the data encryption key is corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + assertThrows( + () => + validateHmac({ + masterKey, + encryptedKeystore: { + ...emptyKeystore, + dataEncryptionKey: { + iv: emptyKeystore.dataEncryptionKey.iv, + cypherText: "0000000000" + emptyKeystore.hmac.slice(10), + }, + }, + }), + (e) => e instanceof InvalidHmacError, + ); + }); + + it("Should throw if the master key salt is corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const corruptedKeystore: EncryptedKeystore = { + ...emptyKeystore, + crypto: { + ...emptyKeystore.crypto, + masterKeyDerivation: { + ...emptyKeystore.crypto.masterKeyDerivation, + salt: + "0000000000" + + emptyKeystore.crypto.masterKeyDerivation.salt.slice(10), + }, + }, + }; + + const corruptedMasterKey = deriveMasterKeyFromKeystore({ + password, + encryptedKeystore: corruptedKeystore, + }); + + assertThrows( + () => + validateHmac({ + masterKey: corruptedMasterKey, + encryptedKeystore: corruptedKeystore, + }), + (e) => e instanceof HmacKeyDecryptionError, + ); + }); + + it("Should throw if the master key derivation params are corrupted", () => { + // While we are changing the scrypt params in this test, we are still able + // to derive the master key from the keystore, because we don't use the + // params in the keystore file, but only validate them. + + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const corruptedKeystore: EncryptedKeystore = { + ...emptyKeystore, + crypto: { + ...emptyKeystore.crypto, + masterKeyDerivation: { + ...emptyKeystore.crypto.masterKeyDerivation, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Intentional cast for testing purposes + paramN: 123 as any, + }, + }, + }; + + const corruptedMasterKey = deriveMasterKeyFromKeystore({ + password, + encryptedKeystore: corruptedKeystore, + }); + + assertThrows( + () => + validateHmac({ + masterKey: corruptedMasterKey, + encryptedKeystore: corruptedKeystore, + }), + (e) => e instanceof InvalidHmacError, + ); + }); + + it("Should throw if the other params are corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const corruptedKeystore: EncryptedKeystore = { + ...emptyKeystore, + crypto: { + ...emptyKeystore.crypto, + encryption: { + ...emptyKeystore.crypto.encryption, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Intentional cast for testing purposes + keyLength: 123 as any, + }, + }, + }; + + assertThrows( + () => + validateHmac({ + masterKey, + encryptedKeystore: corruptedKeystore, + }), + (e) => e instanceof InvalidHmacError, + ); + }); + + it("Should throw if a secret is corrupted", () => { + const password = "viva la ethereum"; + const { salt, masterKey } = createMasterKey({ password }); + + const emptyKeystore = createEmptyEncryptedKeystore({ + masterKey, + salt, + }); + + const keystore = addSecretToKeystore({ + masterKey, + encryptedKeystore: emptyKeystore, + key: "my-secret", + value: "my-secret-value", + }); + + const corruptedKeystore: EncryptedKeystore = { + ...keystore, + secrets: { + ...keystore.secrets, + "my-secret": { + ...keystore.secrets["my-secret"], + cypherText: + "0000000000" + keystore.secrets["my-secret"].cypherText.slice(10), + }, + }, + }; + + assertThrows( + () => + validateHmac({ + masterKey, + encryptedKeystore: corruptedKeystore, + }), + (e) => e instanceof InvalidHmacError, + ); + }); + }); +});