-
-
Notifications
You must be signed in to change notification settings - Fork 267
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
}); | ||
}); |
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 }; | ||
} | ||
}; |
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() { | ||
|
@@ -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<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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO: return specific error to suite |
||
} | ||
|
||
return currentData; | ||
} | ||
|
||
async run() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }; | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.