From dc41db05cc2cd6dddf35126759a67aa8a6d79287 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Tue, 10 Dec 2024 20:22:00 +0100 Subject: [PATCH] feat(connect): `resetDevice` with entropy check --- packages/connect/package.json | 2 + .../firmware/__tests__/verifyEntropy.test.ts | 36 ++++ .../connect/src/api/firmware/verifyEntropy.ts | 167 ++++++++++++++++++ packages/connect/src/api/resetDevice.ts | 73 +++++++- packages/connect/src/data/config.ts | 4 + packages/connect/src/device/DeviceCommands.ts | 19 -- .../__tests__/deviceFeaturesUtils.test.ts | 6 + .../connect-dependencies.txt | 4 +- yarn.lock | 20 ++- 9 files changed, 307 insertions(+), 24 deletions(-) create mode 100644 packages/connect/src/api/firmware/__tests__/verifyEntropy.test.ts create mode 100644 packages/connect/src/api/firmware/verifyEntropy.ts diff --git a/packages/connect/package.json b/packages/connect/package.json index 6c274c7cf79..c5bc0ffe11c 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -72,6 +72,7 @@ "@ethereumjs/common": "^4.4.0", "@ethereumjs/tx": "^5.4.0", "@fivebinaries/coin-selection": "3.0.0", + "@noble/hashes": "^1.6.1", "@trezor/blockchain-link": "workspace:*", "@trezor/blockchain-link-types": "workspace:*", "@trezor/connect-analytics": "workspace:*", @@ -82,6 +83,7 @@ "@trezor/transport": "workspace:*", "@trezor/utils": "workspace:*", "@trezor/utxo-lib": "workspace:*", + "bip39": "^3.1.0", "blakejs": "^1.2.1", "bs58": "^6.0.0", "bs58check": "^4.0.0", diff --git a/packages/connect/src/api/firmware/__tests__/verifyEntropy.test.ts b/packages/connect/src/api/firmware/__tests__/verifyEntropy.test.ts new file mode 100644 index 00000000000..c6509d2c8a2 --- /dev/null +++ b/packages/connect/src/api/firmware/__tests__/verifyEntropy.test.ts @@ -0,0 +1,36 @@ +import { verifyEntropy } from '../verifyEntropy'; + +describe('firmware/verifyEntropy', () => { + it('bip39 success', async () => { + const response = await verifyEntropy({ + strength: 256, + hostEntropy: 'e14806194511f95f2e6b7c5267fcb824469a478007d97339da08abb379244553', + commitment: '09c7dff5c85814852fb9cb12feecd11183f7af1c10296a5075f276c5eca9fb44', + trezorEntropy: 'ffa4581852ee93e789b9e83554f58dd1a3e09765a64d91d8cd08f6b5c813745d', + xpubs: { + "m/84'/0'/0'": + 'xpub6CCMQserNP7QkjspvUVWfCdjK1FcgFdmka3ZgzVgZKqkkCL5bfQoscxZ9UzTLLLedPGwkhQobEGE84gWvZY1tXaHJsVLMHA6cXNUmXnrj3s', + "m/44'/60'/0'": + 'xpub6Cdh8AtW8tSXTDYYGirGkmTCgYe4SCCAuqJLmqkhDFYVCkHLngQ7JmNSVuCQuQARqx5tDJJ9my1JCgaUHHizioZZoXRWw6LR95uQUkbKJi3', + }, + }); + expect(response.success).toEqual(true); + }); + + it('slip39 success', async () => { + const response = await verifyEntropy({ + type: 1, + strength: 256, + hostEntropy: '20e1524b5ea128b581c8882ddd5b030dd54e3bf49c8e7063768be13e0add4420', + commitment: '0ea37a3ae4e765ac59f6d721548920c92667bb9cc09b53ebf81404d3c07794a1', + trezorEntropy: '00610ab95fe09b3d32320662e96ba195344461b00322a09b86df68432db1e745', + xpubs: { + "m/84'/0'/0'": + 'xpub6CxDGHMZekeQtFmny74NHcf1cA8opN8yWHLdmXhwhin7WrjCWKgypDyG5SoCR7ae57JqPT8ZWd2st56yzgC8bzzpHRDurXxZxkaZfXeF1bW', + "m/44'/60'/0'": + 'xpub6CV17nmnkijMua6ZpyRU7MNnZjHoByRGoWf2nPxJmKF1EriH92awnhV7KS2X1mB6ke1fuRerGir3kvZr6uRcQqn2Pnv48gmhtsyaHcLALVG', + }, + }); + expect(response.success).toEqual(true); + }); +}); diff --git a/packages/connect/src/api/firmware/verifyEntropy.ts b/packages/connect/src/api/firmware/verifyEntropy.ts new file mode 100644 index 00000000000..355a77fea0e --- /dev/null +++ b/packages/connect/src/api/firmware/verifyEntropy.ts @@ -0,0 +1,167 @@ +import { entropyToMnemonic, mnemonicToSeed } from 'bip39'; +import { hmac } from '@noble/hashes/hmac'; +import { crypto } from '@noble/hashes/crypto'; +import { sha256 } from '@noble/hashes/sha256'; +import { randomBytes } from '@noble/hashes/utils'; + +import { bip32 } from '@trezor/utxo-lib'; + +import { PROTO } from '../../constants'; + +export const generateEntropy = (len: number) => { + try { + return Buffer.from(randomBytes(len)); + } catch { + throw new Error('generateEntropy: Environment does not support crypto random'); + } +}; + +// https://github.com/trezor/python-shamir-mnemonic/blob/master/shamir_mnemonic/cipher.py +const BASE_ITERATION_COUNT = 10000; +const ROUND_COUNT = 4; + +// https://github.com/trezor/python-shamir-mnemonic/blob/master/shamir_mnemonic/cipher.py +const roundFunction = async (i: number, passphrase: Buffer, e: number, salt: Buffer, r: Buffer) => { + const data = Buffer.concat([Buffer.from([i]), passphrase]); + const iterations = Math.floor((BASE_ITERATION_COUNT << e) / ROUND_COUNT); + + // '@noble/hashes/pbkdf2' takes ~ 8sec. in the web build + // const result = pbkdf2(sha256, data, Buffer.concat([salt, r]), { + // c: iterations, + // dkLen: r.length, + // }); + + // Nodejs only + // return crypto.pbkdf2Sync(data, Buffer.concat([salt, r]), iterations, r.length, 'sha256'); + + // Nodejs + WebCrypto equivalent + const { subtle } = crypto as Crypto; + const key = await subtle.importKey('raw', data, 'PBKDF2', false, ['deriveBits']); + const bits = await subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: Buffer.concat([salt, r]), + iterations, + }, + key, + r.length * 8, + ); + + return Buffer.from(bits); +}; + +// https://github.com/trezor/python-shamir-mnemonic/blob/master/shamir_mnemonic/cipher.py +const xor = (a: Buffer, b: Buffer) => { + if (a.length !== b.length) { + throw new Error('Buffers must be of equal length to XOR.'); + } + const result = Buffer.alloc(a.length); + for (let i = 0; i < a.length; i++) { + result[i] = a[i] ^ b[i]; + } + + return result; +}; + +// https://github.com/trezor/python-shamir-mnemonic/blob/master/shamir_mnemonic/cipher.py +// simplified "decrypt" function +const entropyToSeedSlip39 = async (encryptedSecret: Buffer) => { + const iterationExponent = 1; + // const identifier = 0; + // const extendable = true, + const passphrase = Buffer.from('', 'utf-8'); // empty passphrase + const salt = Buffer.alloc(0); // extendable: True => no salt + + const half = Math.floor(encryptedSecret.length / 2); + let l = encryptedSecret.subarray(0, half); + let r = encryptedSecret.subarray(half); + for (let round = ROUND_COUNT - 1; round >= 0; round--) { + const f = await roundFunction(round, passphrase, iterationExponent, salt, r); + const rr = xor(l, f); + l = r; + r = rr; + } + + return Buffer.concat([r, l]); +}; + +const getEntropy = (trezorEntropy: string, hostEntropy: string, strength: number) => { + const data = Buffer.concat([ + Buffer.from(trezorEntropy, 'hex'), + Buffer.from(hostEntropy, 'hex'), + ]); + const entropy = sha256(data); + + return Buffer.from(entropy.subarray(0, Math.floor(strength / 8))); +}; + +const computeSeed = (type: VerifyEntropyOptions['type'], secret: Buffer) => { + const BackupType = PROTO.Enum_BackupType; + if ( + type && + [ + BackupType.Slip39_Basic, + BackupType.Slip39_Advanced, + BackupType.Slip39_Single_Extendable, + BackupType.Slip39_Basic_Extendable, + BackupType.Slip39_Advanced_Extendable, + ].includes(type) + ) { + // use slip39 + return entropyToSeedSlip39(secret); + } + + // use bip39 + return mnemonicToSeed(entropyToMnemonic(secret)); +}; + +const verifyCommitment = (entropy: string, commitment: string) => { + const hmacDigest = hmac(sha256, Buffer.from(entropy, 'hex'), Buffer.alloc(0)); + if (!Buffer.from(hmacDigest).equals(Buffer.from(commitment, 'hex'))) { + throw new Error('Invalid entropy commitment'); + } +}; + +type VerifyEntropyOptions = { + type?: PROTO.Enum_BackupType; // ResetDevice.backup_type + strength?: number; // ResetDevice.strength + commitment?: string; // entropy_commitment received from previous EntropyRequest + hostEntropy: string; // host_entropy used in previous EntropyAck + trezorEntropy?: string; // prev_entropy received from current EntropyRequest, after ResetDeviceContinue + xpubs: Record; // +}; + +export const verifyEntropy = async ({ + type, + strength, + trezorEntropy, + hostEntropy, + commitment, + xpubs, +}: VerifyEntropyOptions) => { + try { + if (!trezorEntropy || !commitment || !strength || Object.keys(xpubs).length < 1) { + throw new Error('Missing verifyEntropy data'); + } + + verifyCommitment(trezorEntropy, commitment); + // compute seed + const secret = getEntropy(trezorEntropy, hostEntropy, strength); + const seed = await computeSeed(type, secret); + + // derive xpubs and compare with FW results + const node = bip32.fromSeed(seed); + Object.keys(xpubs).forEach(path => { + const pubKey = node.derivePath(path); + const xpub = pubKey.neutered().toBase58(); + if (xpub !== xpubs[path]) { + throw new Error('verifyEntropy xpub mismatch'); + } + }); + + return { success: true as const }; + } catch (error) { + return { success: false as const, error: error.message }; + } +}; diff --git a/packages/connect/src/api/resetDevice.ts b/packages/connect/src/api/resetDevice.ts index d19f695c271..b9a81809e53 100644 --- a/packages/connect/src/api/resetDevice.ts +++ b/packages/connect/src/api/resetDevice.ts @@ -1,11 +1,15 @@ // origin: https://github.com/trezor/connect/blob/develop/src/js/core/methods/ResetDevice.js - import { Assert } from '@trezor/schema-utils'; +import { getRandomInt } from '@trezor/utils'; import { AbstractMethod } from '../core/AbstractMethod'; import { UI } from '../events'; import { getFirmwareRange } from './common/paramsValidator'; import { PROTO } from '../constants'; +import { validatePath } from '../utils/pathUtils'; +import { generateEntropy, verifyEntropy } from '../api/firmware/verifyEntropy'; + +type EntropyRequestData = PROTO.EntropyRequest & { host_entropy: string }; export default class ResetDevice extends AbstractMethod<'resetDevice', PROTO.ResetDevice> { init() { @@ -28,6 +32,8 @@ export default class ResetDevice extends AbstractMethod<'resetDevice', PROTO.Res skip_backup: payload.skip_backup, no_backup: payload.no_backup, backup_type: payload.backup_type, + entropy_check: + typeof payload.entropy_check === 'boolean' ? payload.entropy_check : true, }; } @@ -42,10 +48,71 @@ export default class ResetDevice extends AbstractMethod<'resetDevice', PROTO.Res }; } + private async getEntropyData( + type: 'ResetDevice' | 'EntropyCheckContinue', + ): Promise { + const cmd = this.device.getCommands(); + const entropy = generateEntropy(32).toString('hex'); + const params = type === 'ResetDevice' ? this.params : {}; + const entropyRequest = await cmd.typedCall(type, 'EntropyRequest', params); + await cmd.typedCall('EntropyAck', ['EntropyCheckReady', 'Success'], { entropy }); + + return { + ...entropyRequest.message, + host_entropy: entropy, + }; + } + + private async entropyCheck(prevData: EntropyRequestData): Promise { + const cmd = this.device.getCommands(); + const paths = ["m/84'/0'/0'", "m/44'/60'/0'"]; + const xpubs: Record = {}; // + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + const pubKey = await cmd.getPublicKey({ address_n: validatePath(p) }); + xpubs[p] = pubKey.xpub; + } + + const currentData = await this.getEntropyData('EntropyCheckContinue'); + const res = await verifyEntropy({ + type: this.params.backup_type, + strength: this.params.strength, + commitment: prevData.entropy_commitment, + hostEntropy: prevData.host_entropy, + trezorEntropy: currentData.prev_entropy, + xpubs, + }); + if (res.error) { + throw new Error(res.error); + } + + return currentData; + } + async run() { const cmd = this.device.getCommands(); - const response = await cmd.typedCall('ResetDevice', 'Success', this.params); - return response.message; + if (this.params.entropy_check && this.device.unavailableCapabilities['entropyCheck']) { + // entropy check requested but not supported by the firmware + this.params.entropy_check = false; + } + // Entropy check workflow: + // https://github.com/trezor/trezor-firmware/blob/andrewkozlik/display_random/docs/common/message-workflows.md#entropy-check-workflow + // steps: 1 - 4 + // ResetDevice > EntropyRequest > EntropyAck > EntropyCheckReady (new fw) || Success (old fw) + let entropyData = await this.getEntropyData('ResetDevice'); + + if (this.params.entropy_check) { + const tries = getRandomInt(1, 5); + for (let i = 0; i < tries; i++) { + // steps: 5 - 6 + // GetPublicKey > ResetDeviceContinue > EntropyRequest > EntropyAck > EntropyCheckReady + entropyData = await this.entropyCheck(entropyData); + } + // step 7 EntropyCheckContinue > EntropyCheckReady + await cmd.typedCall('EntropyCheckContinue', 'EntropyCheckReady', { finish: true }); + } + + return { message: 'Success' }; } } diff --git a/packages/connect/src/data/config.ts b/packages/connect/src/data/config.ts index 93d17614379..96018139c27 100644 --- a/packages/connect/src/data/config.ts +++ b/packages/connect/src/data/config.ts @@ -259,5 +259,9 @@ export const config = { T2B1: '2.7.0', }, }, + { + capabilities: ['entropyCheck'], + min: { T1B1: '0', T2T1: '2.8.7', T2B1: '2.8.7' }, + }, ], }; diff --git a/packages/connect/src/device/DeviceCommands.ts b/packages/connect/src/device/DeviceCommands.ts index 9c714b706ce..e20d7cd5b4a 100644 --- a/packages/connect/src/device/DeviceCommands.ts +++ b/packages/connect/src/device/DeviceCommands.ts @@ -1,7 +1,5 @@ // original file https://github.com/trezor/connect/blob/develop/src/js/device/DeviceCommands.js -import { randomBytes } from 'crypto'; - import { Transport, Session } from '@trezor/transport'; import { MessagesSchema as Messages } from '@trezor/protobuf'; import { createTimeoutPromise, versionUtils } from '@trezor/utils'; @@ -43,17 +41,6 @@ const assertType = (res: DefaultPayloadMessage, resType: MessageKey | MessageKey } }; -const generateEntropy = (len: number) => { - try { - return randomBytes(len); - } catch { - throw ERRORS.TypedError( - 'Runtime', - 'generateEntropy: Environment does not support crypto random', - ); - } -}; - const filterForLog = (type: string, msg: any) => { const blacklist: { [key: string]: Record } = { // PassphraseAck: { @@ -431,12 +418,6 @@ export class DeviceCommands { return this._commonCall('ButtonAck', {}); } - if (res.type === 'EntropyRequest') { - return this._commonCall('EntropyAck', { - entropy: generateEntropy(32).toString('hex'), - }); - } - if (res.type === 'PinMatrixRequest') { return promptPin(this.device, res.message.type).then( pin => diff --git a/packages/connect/src/utils/__tests__/deviceFeaturesUtils.test.ts b/packages/connect/src/utils/__tests__/deviceFeaturesUtils.test.ts index 319a27cd1c6..8ca5d784b61 100644 --- a/packages/connect/src/utils/__tests__/deviceFeaturesUtils.test.ts +++ b/packages/connect/src/utils/__tests__/deviceFeaturesUtils.test.ts @@ -164,6 +164,7 @@ describe('utils/deviceFeaturesUtils', () => { coinjoin: 'update-required', signMessageNoScriptType: 'update-required', chunkify: 'no-support', + entropyCheck: 'no-support', }); }); @@ -193,6 +194,7 @@ describe('utils/deviceFeaturesUtils', () => { sol: 'no-capability', dsol: 'no-capability', chunkify: 'update-required', + entropyCheck: 'update-required', }); }); @@ -224,6 +226,7 @@ describe('utils/deviceFeaturesUtils', () => { vtc: 'no-support', xem: 'no-support', chunkify: 'update-required', + entropyCheck: 'update-required', }); }); @@ -307,6 +310,7 @@ describe('utils/deviceFeaturesUtils', () => { replaceTransaction: 'update-required', signMessageNoScriptType: 'update-required', taproot: 'update-required', + entropyCheck: 'update-required', }); }); @@ -338,6 +342,7 @@ describe('utils/deviceFeaturesUtils', () => { replaceTransaction: 'update-required', signMessageNoScriptType: 'update-required', taproot: 'update-required', + entropyCheck: 'no-support', }); }); @@ -370,6 +375,7 @@ describe('utils/deviceFeaturesUtils', () => { replaceTransaction: 'update-required', signMessageNoScriptType: 'update-required', taproot: 'update-required', + entropyCheck: 'no-support', }); }); }); diff --git a/scripts/list-outdated-dependencies/connect-dependencies.txt b/scripts/list-outdated-dependencies/connect-dependencies.txt index 2b642fb6543..da7527930c1 100644 --- a/scripts/list-outdated-dependencies/connect-dependencies.txt +++ b/scripts/list-outdated-dependencies/connect-dependencies.txt @@ -95,4 +95,6 @@ jest-extended jest-environment-node scroll-into-view-if-needed @types/bn.js -@types/events \ No newline at end of file +@types/events +bip39 +@noble/hashes \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 40bbdee6305..d08687718f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4881,13 +4881,20 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.4.0, @noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.4.0": +"@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" checksum: 10/e156e65794c473794c52fa9d06baf1eb20903d0d96719530f523cc4450f6c721a957c544796e6efd0197b2296e7cd70efeb312f861465e17940a3e3c7e0febc6 languageName: node linkType: hard +"@noble/hashes@npm:^1.2.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.6.1": + version: 1.6.1 + resolution: "@noble/hashes@npm:1.6.1" + checksum: 10/74d9ad7b1437a22ba3b877584add3367587fbf818113152f293025d20d425aa74c191d18d434797312f2270458bc9ab3241c34d14ec6115fb16438b3248f631f + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -11798,6 +11805,7 @@ __metadata: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" "@fivebinaries/coin-selection": "npm:3.0.0" + "@noble/hashes": "npm:^1.6.1" "@trezor/blockchain-link": "workspace:*" "@trezor/blockchain-link-types": "workspace:*" "@trezor/connect-analytics": "workspace:*" @@ -11812,6 +11820,7 @@ __metadata: "@trezor/utxo-lib": "workspace:*" "@types/karma": "npm:^6.3.8" babel-loader: "npm:^9.1.3" + bip39: "npm:^3.1.0" blakejs: "npm:^1.2.1" bs58: "npm:^6.0.0" bs58check: "npm:^4.0.0" @@ -16386,6 +16395,15 @@ __metadata: languageName: node linkType: hard +"bip39@npm:^3.1.0": + version: 3.1.0 + resolution: "bip39@npm:3.1.0" + dependencies: + "@noble/hashes": "npm:^1.2.0" + checksum: 10/406c0b5bdab0d43df2ff8916c5b57bb7f65cd36a115cbd7620a75b972638f8511840bfc27bfc68946027086fd33ed3050d8cd304e85fd527503b23d857a8486e + languageName: node + linkType: hard + "bip66@npm:^2.0.0": version: 2.0.0 resolution: "bip66@npm:2.0.0"