diff --git a/src/controllers/keystore/keystore.ts b/src/controllers/keystore/keystore.ts index 529f0b035..f1dcc9e16 100644 --- a/src/controllers/keystore/keystore.ts +++ b/src/controllers/keystore/keystore.ts @@ -9,16 +9,7 @@ import { encryptWithPublicKey, publicKeyByPrivateKey } from 'eth-crypto' -import { - concat, - getBytes, - hexlify, - keccak256, - Mnemonic, - randomBytes, - toUtf8Bytes, - Wallet -} from 'ethers' +import { concat, getBytes, hexlify, keccak256, Mnemonic, toUtf8Bytes, Wallet } from 'ethers' import scrypt from 'scrypt-js' import EmittableError from '../../classes/EmittableError' @@ -38,6 +29,7 @@ import { } from '../../interfaces/keystore' import { Storage } from '../../interfaces/storage' import { WindowManager } from '../../interfaces/window' +import { EntropyGenerator } from '../../libs/entropyGenerator/entropyGenerator' import { getDefaultKeyLabel, getShouldMigrateKeyMetaNullToKeyMetaCreatedAt, @@ -301,16 +293,14 @@ export class KeystoreController extends EventEmitter { }) let mainKey: MainKey | null = this.#mainKey + const entropyGenerator = new EntropyGenerator() + // We are not unlocked if (!mainKey) { if (!this.#keystoreSecrets.length) { - const key = getBytes(keccak256(concat([randomBytes(32), toUtf8Bytes(extraEntropy)]))).slice( - 0, - 16 - ) mainKey = { - key, - iv: randomBytes(16) + key: entropyGenerator.generateRandomBytes(16, extraEntropy), + iv: entropyGenerator.generateRandomBytes(16, extraEntropy) } } else throw new EmittableError({ @@ -324,7 +314,7 @@ export class KeystoreController extends EventEmitter { } } - const salt = randomBytes(32) + const salt = entropyGenerator.generateRandomBytes(32, extraEntropy) const key = await scrypt.scrypt( getBytesForSecret(secret), salt, @@ -334,7 +324,7 @@ export class KeystoreController extends EventEmitter { scryptDefaults.dkLen, () => {} ) - const iv = randomBytes(16) + const iv = entropyGenerator.generateRandomBytes(16, extraEntropy) const derivedKey = key.slice(0, 16) const macPrefix = key.slice(16, 32) const counter = new aes.Counter(iv) @@ -871,7 +861,7 @@ export class KeystoreController extends EventEmitter { return { seed: decryptedSeed, hdPathTemplate } } - async #changeKeystorePassword(newSecret: string, oldSecret?: string) { + async #changeKeystorePassword(newSecret: string, oldSecret?: string, extraEntropy?: string) { await this.#initialLoadPromise // In the case the user wants to change their device password, @@ -901,12 +891,12 @@ export class KeystoreController extends EventEmitter { }) await this.#removeSecret('password') - await this.#addSecret('password', newSecret, '', true) + await this.#addSecret('password', newSecret, extraEntropy, true) } - async changeKeystorePassword(newSecret: string, oldSecret?: string) { + async changeKeystorePassword(newSecret: string, oldSecret?: string, extraEntropy?: string) { await this.withStatus('changeKeystorePassword', () => - this.#changeKeystorePassword(newSecret, oldSecret) + this.#changeKeystorePassword(newSecret, oldSecret, extraEntropy) ) } diff --git a/src/libs/entropyGenerator/entropyGenerator.test.ts b/src/libs/entropyGenerator/entropyGenerator.test.ts new file mode 100644 index 000000000..ea9f828dc --- /dev/null +++ b/src/libs/entropyGenerator/entropyGenerator.test.ts @@ -0,0 +1,82 @@ +import { EntropyGenerator } from './entropyGenerator' + +describe('EntropyGenerator', () => { + let generator: EntropyGenerator + + beforeEach(() => { + generator = new EntropyGenerator() + }) + + test('should generate random bytes with extra entropy', () => { + const length = 32 + const extraEntropy = 'extra randomness' + const result = generator.generateRandomBytes(length, extraEntropy) + + expect(result).toBeInstanceOf(Uint8Array) + expect(result.length).toBe(length) + }) + test('should throw an error when entropy pool is empty', () => { + jest.spyOn(generator, 'addEntropy').mockImplementation(() => {}) + expect(() => generator.generateRandomBytes(16, '')).toThrow('Entropy pool is empty') + }) + test('should collect time entropy', () => { + jest.spyOn(generator, 'addEntropy') + generator.generateRandomBytes(16, 'test') + expect(generator.addEntropy).toHaveBeenCalled() + }) + test('should collect system noise entropy', () => { + jest.spyOn(generator, 'addEntropy') + generator.generateRandomBytes(16, 'test') + expect(generator.addEntropy).toHaveBeenCalled() + }) + test('should produce different outputs on consecutive calls', () => { + const length = 32 + const extraEntropy = 'extra randomness' + const result1 = generator.generateRandomBytes(length, extraEntropy) + const result2 = generator.generateRandomBytes(length, extraEntropy) + expect(result1).not.toEqual(result2) + }) + test('should ensure randomness by checking uniform distribution', () => { + const length = 32 + const occurrences = new Map() + for (let i = 0; i < 1000; i++) { + const result = generator.generateRandomBytes(length, 'entropy-test') + occurrences.set(result.toString(), (occurrences.get(result.toString()) || 0) + 1) + } + expect(occurrences.size).toBeGreaterThan(999) // Expect at least 999 unique values out of 1000 + }) + test('should not produce predictable patterns', () => { + const length = 32 + const results: Uint8Array[] = [] + for (let i = 0; i < 100; i++) { + results.push(generator.generateRandomBytes(length, '')) + } + const diffs = results.map((r, i) => (i > 0 ? r.toString() !== results[i - 1].toString() : true)) + expect(diffs.includes(false)).toBe(false) + }) + test('should generate a valid mnemonic of 12 words', () => { + const mnemonic = generator.generateRandomMnemonic(12, 'extra entropy') + expect(mnemonic).toHaveProperty('phrase') + expect(mnemonic.phrase.split(' ').length).toBe(12) + }) + test('should generate a valid mnemonic of 24 words', () => { + const mnemonic = generator.generateRandomMnemonic(24, 'extra entropy') + expect(mnemonic).toHaveProperty('phrase') + expect(mnemonic.phrase.split(' ').length).toBe(24) + }) + test('should generate different mnemonics on consecutive calls', () => { + const mnemonic1 = generator.generateRandomMnemonic(12, 'extra entropy') + const mnemonic2 = generator.generateRandomMnemonic(12, 'extra entropy') + expect(mnemonic1.phrase).not.toEqual(mnemonic2.phrase) + }) + test('should use correct entropy length for 12-word mnemonic', () => { + jest.spyOn(generator, 'generateRandomBytes') + generator.generateRandomMnemonic(12, 'extra entropy') + expect(generator.generateRandomBytes).toHaveBeenCalledWith(16, 'extra entropy') + }) + test('should use correct entropy length for 24-word mnemonic', () => { + jest.spyOn(generator, 'generateRandomBytes') + generator.generateRandomMnemonic(24, 'extra entropy') + expect(generator.generateRandomBytes).toHaveBeenCalledWith(32, 'extra entropy') + }) +}) diff --git a/src/libs/entropyGenerator/entropyGenerator.ts b/src/libs/entropyGenerator/entropyGenerator.ts new file mode 100644 index 000000000..38f3adc0a --- /dev/null +++ b/src/libs/entropyGenerator/entropyGenerator.ts @@ -0,0 +1,68 @@ +/* eslint-disable no-bitwise */ +import { getBytes, keccak256, LangEn, Mnemonic, randomBytes } from 'ethers' + +// Custom entropy generator that enhances ethers' randomBytes by incorporating: +// - Time-based entropy for additional randomness. +// - Optional extra entropy (like mouse position, timestamp...) provided by the user for added security. +// This helps improve the security of mainKey generation and random seed phrase creation. +export class EntropyGenerator { + #entropyPool: Uint8Array = new Uint8Array(0) + + generateRandomBytes(length: number, extraEntropy: string): Uint8Array { + this.#resetEntropyPool() + this.#collectCryptographicEntropy(length) + this.#collectTimeEntropy() + + if (extraEntropy) { + const encoder = new TextEncoder() + const uint8Array = encoder.encode(extraEntropy) + this.addEntropy(uint8Array) + } + + if (this.#entropyPool.length === 0) throw new Error('Entropy pool is empty') + + const hash = getBytes(keccak256(this.#entropyPool)) + const randomBytesGenerated = randomBytes(length) + // Introduces additional entropy mixing via XOR + for (let i = 0; i < length; i++) { + randomBytesGenerated[i] ^= hash[i % hash.length] + } + + return randomBytesGenerated + } + + generateRandomMnemonic(wordCount: 12 | 24, extraEntropy: string): Mnemonic { + const wordCountToBytesLength = { 12: 16, 24: 32 } + const bytesLength = wordCountToBytesLength[wordCount] || 16 // defaults to 12-word phrase + const entropy = this.generateRandomBytes(bytesLength, extraEntropy) + const mnemonic = Mnemonic.fromEntropy(entropy, '', LangEn.wordlist()) + return mnemonic + } + + #collectTimeEntropy(): void { + // TODO: steps to add support for the mobile app: + // 1. install the polyfill: `yarn add react-native-performance` + // 2. add it globally in a top-level file: + // if (typeof performance === "undefined") { + // global.performance = { now } + // } + const now = performance.now() + + if (!now) return + + const timeEntropy = new Uint8Array(new Float64Array([now]).buffer) + this.addEntropy(timeEntropy) + } + + #collectCryptographicEntropy(length: number): void { + this.addEntropy(randomBytes(length)) + } + + addEntropy(newEntropy: Uint8Array): void { + this.#entropyPool = new Uint8Array(Buffer.concat([this.#entropyPool, newEntropy])) + } + + #resetEntropyPool() { + this.#entropyPool = new Uint8Array(0) + } +}