Skip to content

Commit

Permalink
feat(connect): resetDevice with entropy check
Browse files Browse the repository at this point in the history
  • Loading branch information
szymonlesisz committed Dec 28, 2024
1 parent 548017e commit dc41db0
Show file tree
Hide file tree
Showing 9 changed files with 307 additions and 24 deletions.
2 changes: 2 additions & 0 deletions packages/connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions packages/connect/src/api/firmware/__tests__/verifyEntropy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
167 changes: 167 additions & 0 deletions packages/connect/src/api/firmware/verifyEntropy.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; // <address_n, xpub>
};

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 };
}
};
73 changes: 70 additions & 3 deletions packages/connect/src/api/resetDevice.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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,
};
}

Expand All @@ -42,10 +48,71 @@ export default class ResetDevice extends AbstractMethod<'resetDevice', PROTO.Res
};
}

private async getEntropyData(
type: 'ResetDevice' | 'EntropyCheckContinue',
): Promise<EntropyRequestData> {
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<EntropyRequestData> {
const cmd = this.device.getCommands();
const paths = ["m/84'/0'/0'", "m/44'/60'/0'"];
const xpubs: Record<string, string> = {}; // <path, xpub>
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' };
}
}
4 changes: 4 additions & 0 deletions packages/connect/src/data/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,5 +259,9 @@ export const config = {
T2B1: '2.7.0',
},
},
{
capabilities: ['entropyCheck'],
min: { T1B1: '0', T2T1: '2.8.7', T2B1: '2.8.7' },
},
],
};
19 changes: 0 additions & 19 deletions packages/connect/src/device/DeviceCommands.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string> } = {
// PassphraseAck: {
Expand Down Expand Up @@ -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 =>
Expand Down
Loading

0 comments on commit dc41db0

Please sign in to comment.