Skip to content

Commit

Permalink
Merge pull request #1295 from AmbireTech/feature/keystore-extra-entropy
Browse files Browse the repository at this point in the history
Feature / Keystore Extra Entropy
  • Loading branch information
sonytooo authored Feb 19, 2025
2 parents ade6cd9 + f3a980b commit be9a219
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 22 deletions.
34 changes: 12 additions & 22 deletions src/controllers/keystore/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
}

Expand Down
82 changes: 82 additions & 0 deletions src/libs/entropyGenerator/entropyGenerator.test.ts
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')
})
})
68 changes: 68 additions & 0 deletions src/libs/entropyGenerator/entropyGenerator.ts
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)
}
}

0 comments on commit be9a219

Please sign in to comment.