Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/connect: Entropy check workflow in ResetDevice #15887

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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,
Comment on lines +104 to +105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of slip39 in this file assumes that the backup is extendable, meaning that using a non-extendable backup will likely result in the "verifyEntropy xpub mismatch" error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i did it 1:1 with python-trezor implementation, which assumes that as well:
https://github.com/trezor/trezor-firmware/blob/ce803a5452c5d12eb7b026fa2c20b521957fb8c8/python/src/trezorlib/device.py#L268

seed = shamir_mnemonic.cipher.decrypt(
            secret, b"", iteration_exponent=1, identifier=0, extendable=True
        )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough.

However, I think both implementations should raise an exception in this case. Nonetheless, this is not a security issue.

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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should verify that these XPUBs match the XPUBs later used by the suite to derive change and receive addresses.

I think this has already been discussed in person so I'm adding this comment merely as a reminder.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, marked as "followup" above

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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: return specific error to suite

}

return currentData;
}

async run() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the entropy check fails, a small error message is displayed to the user and the user can continue with the onboarding process. We should warn the user similarly to how they are warned when the authenticity check fails.

I think this has already been discussed in person so I'm adding this comment merely as a reminder.

Copy link
Contributor Author

@szymonlesisz szymonlesisz Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, i've left a TODO above: return specific error to suite

btw. when this happens user cannot continue with onboarding as the seed was not created (ResetDeviceFinish was not called)

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;
Copy link
Contributor

@onvej-sl onvej-sl Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a security perspective, if the firmware does not support the entropy check, we should display the same error message as we would if the entropy check failed.

The reason is that a counterfeit trezor could bypass the entropy check by pretending not to support it, that is the trezor can present itself as having a lower firmware version than it actually does. However, we can instruct the user to update the firmware before generating their seed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feature needs to be backward compatible with firmwares which does not support it for example afaik T1 will not be supported.
i need to rely on something to make the decision whether ResetDeviceContinue will be sent or not.
following your example counterfeit trezor could bypass this check by pretending that its T1 model.

I think suite in the first onboarding step does FW version check (and asks you to update) then in the next step you create a seed.

We were discussing this version check with @andrewkozlik

}
// Entropy check workflow:
// https://github.com/trezor/trezor-firmware/blob/andrewkozlik/display_random/docs/common/message-workflows.md#entropy-check-workflow
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: update link

// 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
Loading