feat(suite): device invariability check UI

This commit is contained in:
Jiri Zbytovsky
2026-02-05 18:57:42 +01:00
committed by Jiri Zbytovsky
parent f5ed5a79ae
commit 0056141b1c
6 changed files with 105 additions and 44 deletions

View File

@@ -3,6 +3,7 @@ import '@suite-common/test-utils/src/globalOverrides';
import { fireEvent } from '@testing-library/react';
import { AnalyticsState } from '@suite-common/analytics-redux';
import { mockSuiteDevice } from '@suite-common/suite-types/mocks';
import { TransportInfo } from '@trezor/connect';
import * as envUtils from '@trezor/env-utils';
import { DeepPartial } from '@trezor/type-utils';
@@ -415,11 +416,7 @@ describe(`${Preloader.name} component`, () => {
it('Seedless device', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
mode: 'seedless',
features: {},
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice({ mode: 'seedless' }),
};
const store = initStore(
@@ -445,10 +442,7 @@ describe(`${Preloader.name} component`, () => {
it('Recovery mode device', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
features: { recovery_status: 'Recovery' },
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice({}, { recovery_status: 'Recovery' }),
};
const store = initStore(
@@ -474,11 +468,7 @@ describe(`${Preloader.name} component`, () => {
it('Not initialized device', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
mode: 'initialize',
features: {},
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice({ mode: 'initialize' }),
};
const store = initStore(
@@ -504,11 +494,10 @@ describe(`${Preloader.name} component`, () => {
it('Bootloader device with installed firmware', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
mode: 'bootloader',
features: { firmware_present: true },
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice(
{ mode: 'bootloader' },
{ firmware_present: true, bootloader_mode: true },
),
};
const store = initStore(
@@ -535,11 +524,10 @@ describe(`${Preloader.name} component`, () => {
it('Bootloader device without firmware', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
mode: 'bootloader',
features: { firmware_present: false },
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice(
{ mode: 'bootloader' },
{ firmware_present: false, bootloader_mode: true },
),
};
const store = initStore(
@@ -585,11 +573,7 @@ describe(`${Preloader.name} component`, () => {
it('Required FW update device', () => {
const device: DeepPartial<AppState['device']> = {
selectedDevice: {
firmware: 'required',
features: {},
authenticityChecks: {},
},
selectedDevice: mockSuiteDevice({ firmware: 'required' }),
};
const store = initStore(

View File

@@ -1,7 +1,7 @@
import { messageSystemInitialState } from '@suite-common/message-system';
import { mockSuiteDevice } from '@suite-common/suite-types/mocks';
import * as deviceUtils from '@suite-common/suite-utils';
import { defaultDevicePersistentData } from '@suite-common/wallet-core/src/support/deviceMocks';
import { DeviceModelInternal } from '@trezor/device-utils';
import { initialAppState } from 'src/support/tests/__fixtures__/defaultAppState';
import { AcquiredDevice, AppState } from 'src/types/suite';
@@ -26,15 +26,21 @@ const authenticityChecksFail: AcquiredDevice['authenticityChecks'] = {
const defaultDevice = mockSuiteDevice();
if (!deviceUtils.isDeviceAcquired(defaultDevice)) {
throw 'mockSuiteDevice() must return an AcquiredDevice here.';
throw `${mockSuiteDevice.name}() must return an AcquiredDevice here.`;
}
// derived from this device
const matchingDevicePersistentData = {
...defaultDevicePersistentData,
device_id: defaultDevice.features.device_id,
unit_color: defaultDevice.features.unit_color,
internal_model: defaultDevice.features.internal_model,
};
const fixtures: Fixture[] = [
{
description: 'returns false if all checks pass',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
selectedDevice: {
@@ -58,7 +64,6 @@ const fixtures: Fixture[] = [
app: 'settings',
},
},
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
selectedDevice: {
@@ -73,7 +78,6 @@ const fixtures: Fixture[] = [
description: 'returns true if firmware check errored and not dismissed',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
selectedDevice: {
@@ -88,7 +92,6 @@ const fixtures: Fixture[] = [
description: 'returns false if firmware check errored and dismissed',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
dismissedSecurityChecks: { firmwareAuthenticity: ['device-id'] },
@@ -104,7 +107,6 @@ const fixtures: Fixture[] = [
description: 'returns false if a firmware check errored but is disabled',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
selectedDevice: {
@@ -132,12 +134,11 @@ const fixtures: Fixture[] = [
description: 'returns true if entropy check errored',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
persistentDeviceData: [
{
...defaultDevicePersistentData,
...matchingDevicePersistentData,
lastEntropyCheckResult: { success: false },
},
],
@@ -153,12 +154,11 @@ const fixtures: Fixture[] = [
description: 'returns false if entropy check errored but is disabled',
state: {
...initialAppState,
messageSystem: messageSystemInitialState,
device: {
...initialAppState.device,
persistentDeviceData: [
{
...defaultDevicePersistentData,
...matchingDevicePersistentData,
lastEntropyCheckResult: { success: false },
},
],
@@ -180,6 +180,33 @@ const fixtures: Fixture[] = [
},
result: false,
},
{
description: 'returns true for a device with an invalid id',
state: {
...initialAppState,
device: { ...initialAppState.device, selectedDevice: { ...defaultDevice, id: null } },
},
result: true,
},
{
description: 'returns true for a device with mismatch against its persistent data',
state: {
...initialAppState,
device: {
...initialAppState.device,
persistentDeviceData: [matchingDevicePersistentData],
selectedDevice: {
...defaultDevice,
features: {
...defaultDevice.features,
internal_model: DeviceModelInternal.T1B1,
unit_color: 333,
},
},
},
},
result: true,
},
];
describe(selectShouldDisplayDeviceCompromisedOnRoute.name, () => {

View File

@@ -2,6 +2,7 @@ import { TranslationKey } from '@suite/intl';
import { SkippedHashCheckError } from '@suite-common/firmware-authenticity';
import {
getIsDeviceIdValid,
selectIsDeviceInvariabilityCheckSuccess,
selectSelectedDevice,
selectWasFwHashCheckOtherErrorLastTime,
} from '@suite-common/wallet-core';
@@ -36,6 +37,7 @@ const hashCheckSubtitleMap: Record<
const DeviceCompromisedContent = () => {
const isValidId = getIsDeviceIdValid(useSelector(selectSelectedDevice));
const isDeviceInvariabilityCheckSuccess = useSelector(selectIsDeviceInvariabilityCheckSuccess);
const revisionCheckError = useSelector(selectFirmwareRevisionCheckErrorIfEnabled);
const hashCheckError = useSelector(selectFirmwareHashCheckErrorIfEnabled);
const isEntropyCheckFailed = useSelector(selectIsEntropyCheckEnabledAndFailed);
@@ -52,6 +54,17 @@ const DeviceCompromisedContent = () => {
/>
);
}
// this check is only a precaution, not expected to be seen often
if (!isDeviceInvariabilityCheckSuccess) {
return (
<SecurityCheckFail
ctaSection={<FwAuthenticityCheckSupportButton />}
heading="TR_DEVICE_COMPROMISED_HEADING"
text="TR_DEVICE_COMPROMISED_INVARIABILITY_CHECK_FAILED_TEXT"
checklistItems={hardFailureChecklistItems}
/>
);
}
if (isEntropyCheckFailed) {
return (
<SecurityCheckFail

View File

@@ -5,6 +5,7 @@ import { mockSuiteDevice } from '@suite-common/suite-types/mocks';
import * as deviceUtils from '@suite-common/suite-utils';
import { DeviceReducerState, deviceInitialState } from '@suite-common/wallet-core';
import { defaultDevicePersistentData } from '@suite-common/wallet-core/src/support/deviceMocks';
import { DeviceModelInternal } from '@trezor/device-utils';
import { AppState } from 'src/reducers/store';
import { initialAppState } from 'src/support/tests/__fixtures__/defaultAppState';
@@ -50,6 +51,13 @@ const defaultDevice = mockSuiteDevice();
if (!deviceUtils.isDeviceAcquired(defaultDevice)) {
throw 'mockSuiteDevice() must return an AcquiredDevice here.';
}
// derived from this device
const matchingDevicePersistentData = {
...defaultDevicePersistentData,
device_id: defaultDevice.features.device_id,
unit_color: defaultDevice.features.unit_color,
internal_model: defaultDevice.features.internal_model,
};
const deviceCompromisedFixtures: Array<{
description: string;
@@ -57,12 +65,12 @@ const deviceCompromisedFixtures: Array<{
result: TranslationKey;
}> = [
{
description: 'Errored entropy check',
description: 'Entropy check error',
device: {
...deviceInitialState,
persistentDeviceData: [
{
...defaultDevicePersistentData,
...matchingDevicePersistentData,
lastEntropyCheckResult: { success: false },
},
],
@@ -71,7 +79,7 @@ const deviceCompromisedFixtures: Array<{
result: 'TR_DEVICE_COMPROMISED_ENTROPY_CHECK_TEXT',
},
{
description: 'Errored firmware hash check',
description: 'Firmware hash check error',
device: {
...deviceInitialState,
selectedDevice: {
@@ -131,7 +139,7 @@ const deviceCompromisedFixtures: Array<{
result: 'TR_FAILED_VERIFY_DEVICE_AGAIN_TEXT',
},
{
description: 'Errored firmware revision check',
description: 'Firmware revision check error',
device: {
...deviceInitialState,
selectedDevice: {
@@ -147,6 +155,27 @@ const deviceCompromisedFixtures: Array<{
},
result: 'TR_DEVICE_COMPROMISED_FW_REVISION_CHECK_TEXT',
},
{
description: 'Device Id check error',
device: { ...initialAppState.device, selectedDevice: { ...defaultDevice, id: null } },
result: 'TR_DEVICE_COMPROMISED_INVALID_ID_TEXT',
},
{
description: 'Device invariability check error',
device: {
...initialAppState.device,
persistentDeviceData: [matchingDevicePersistentData],
selectedDevice: {
...defaultDevice,
features: {
...defaultDevice.features,
internal_model: DeviceModelInternal.T1B1,
unit_color: 333,
},
},
},
result: 'TR_DEVICE_COMPROMISED_INVARIABILITY_CHECK_FAILED_TEXT',
},
];
describe(`${DeviceCompromised.name} component`, () => {

View File

@@ -9,6 +9,7 @@ import {
getIsDeviceIdValid,
selectFirmwareHashCheckError,
selectFirmwareRevisionCheckError,
selectIsDeviceInvariabilityCheckSuccess,
selectIsEntropyCheckFailed,
selectIsFirmwareAuthenticityCheckDismissed,
selectSelectedDevice,
@@ -97,6 +98,8 @@ export const selectIsEntropyCheckEnabledAndFailed = (state: AppState) => {
export const selectShouldDisplayDeviceCompromised = (state: AppState): boolean => {
const isDeviceIdValid = getIsDeviceIdValid(selectSelectedDevice(state));
const deviceInvariabilitySuccess = selectIsDeviceInvariabilityCheckSuccess(state);
const isFirmwareCheckEnabledAndFailed =
selectIsFirmwareAuthenticityCheckEnabledAndHardFailed(state);
const isFirmwareAuthenticityCheckDismissed = selectIsFirmwareAuthenticityCheckDismissed(state);
@@ -106,6 +109,7 @@ export const selectShouldDisplayDeviceCompromised = (state: AppState): boolean =
return (
!isDeviceIdValid ||
!deviceInvariabilitySuccess ||
(!isFirmwareAuthenticityCheckDismissed && isFirmwareCheckEnabledAndFailed) ||
isEntropyCheckEnabledAndFailed
);

View File

@@ -7255,6 +7255,10 @@ export const messages = defineMessages({
id: 'TR_DEVICE_COMPROMISED_INVALID_ID_TEXT',
defaultMessage: 'Security check (id validity check) has failed.',
},
TR_DEVICE_COMPROMISED_INVARIABILITY_CHECK_FAILED_TEXT: {
id: 'TR_DEVICE_COMPROMISED_INVARIABILITY_CHECK_FAILED_TEXT',
defaultMessage: 'Your device manipulates its model or color.',
},
TR_DEVICE_COMPROMISED_FW_HASH_CHECK_TEXT: {
id: 'TR_DEVICE_COMPROMISED_FW_HASH_CHECK_TEXT',
defaultMessage: 'Your device firmware hash check has failed.',