-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1295 from AmbireTech/feature/keystore-extra-entropy
Feature / Keystore Extra Entropy
- Loading branch information
Showing
3 changed files
with
162 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |