Device manage - handle sessions that don't support encryption (#9717)

* add handling for unverifiable sessions

* test

* update types for filtervariation

* strict fixes

* avoid setting up cross signing in device man tests
pull/28788/head^2
Kerry 2022-12-09 10:52:00 +13:00 committed by GitHub
parent 6150b86421
commit 888e69f39a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 200 additions and 105 deletions

View File

@ -49,7 +49,6 @@ describe("Device manager", () => {
cy.get('[data-testid="current-session-section"]').within(() => {
cy.contains('Unverified session').should('exist');
cy.get('.mx_DeviceSecurityCard_actions [role="button"]').should('exist');
});
// current session details opened

View File

@ -32,6 +32,7 @@ const VariationIcon: Record<DeviceSecurityVariation, React.FC<React.SVGProps<SVG
[DeviceSecurityVariation.Inactive]: InactiveIcon,
[DeviceSecurityVariation.Verified]: VerifiedIcon,
[DeviceSecurityVariation.Unverified]: UnverifiedIcon,
[DeviceSecurityVariation.Unverifiable]: UnverifiedIcon,
};
const DeviceSecurityIcon: React.FC<{ variation: DeviceSecurityVariation }> = ({ variation }) => {

View File

@ -56,6 +56,26 @@ const securityCardContent: Record<DeviceSecurityVariation, {
</p>
</>,
},
// unverifiable uses single-session case
// because it is only ever displayed on a single session detail
[DeviceSecurityVariation.Unverifiable]: {
title: _t('Unverified session'),
description: <>
<p>{ _t(`This session doesn't support encryption, so it can't be verified.`) }
</p>
<p>
{ _t(
`You won't be able to participate in rooms where encryption is enabled when using this session.`,
)
}
</p><p>
{ _t(
`For best security and privacy, it is recommended to use Matrix clients that support encryption.`,
)
}
</p>
</>,
},
[DeviceSecurityVariation.Inactive]: {
title: _t('Inactive sessions'),
description: <>

View File

@ -30,18 +30,33 @@ interface Props {
onVerifyDevice?: () => void;
}
export const DeviceVerificationStatusCard: React.FC<Props> = ({
device,
onVerifyDevice,
}) => {
const securityCardProps = device.isVerified ? {
variation: DeviceSecurityVariation.Verified,
heading: _t('Verified session'),
description: <>
{ _t('This session is ready for secure messaging.') }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
</>,
} : {
const getCardProps = (device: ExtendedDevice): {
variation: DeviceSecurityVariation;
heading: string;
description: React.ReactNode;
} => {
if (device.isVerified) {
return {
variation: DeviceSecurityVariation.Verified,
heading: _t('Verified session'),
description: <>
{ _t('This session is ready for secure messaging.') }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
</>,
};
}
if (device.isVerified === null) {
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t('Unverified session'),
description: <>
{ _t(`This session doesn't support encryption and thus can't be verified.`) }
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverifiable} />
</>,
};
}
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t('Unverified session'),
description: <>
@ -49,10 +64,19 @@ export const DeviceVerificationStatusCard: React.FC<Props> = ({
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>,
};
};
export const DeviceVerificationStatusCard: React.FC<Props> = ({
device,
onVerifyDevice,
}) => {
const securityCardProps = getCardProps(device);
return <DeviceSecurityCard
{...securityCardProps}
>
{ !device.isVerified && !!onVerifyDevice &&
{ /* check for explicit false to exclude unverifiable devices */ }
{ device.isVerified === false && !!onVerifyDevice &&
<AccessibleButton
kind='primary'
onClick={onVerifyDevice}

View File

@ -27,6 +27,7 @@ import { DeviceExpandDetailsButton } from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard';
import {
filterDevicesBySecurityRecommendation,
FilterVariation,
INACTIVE_DEVICE_AGE_DAYS,
} from './filter';
import SelectableDeviceTile from './SelectableDeviceTile';
@ -47,8 +48,8 @@ interface Props {
expandedDeviceIds: ExtendedDevice['device_id'][];
signingOutDeviceIds: ExtendedDevice['device_id'][];
selectedDeviceIds: ExtendedDevice['device_id'][];
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
filter?: FilterVariation;
onFilterChange: (filter: FilterVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void;
onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
@ -68,12 +69,12 @@ const sortDevicesByLatestActivityThenDisplayName = (left: ExtendedDevice, right:
(right.last_seen_ts || 0) - (left.last_seen_ts || 0)
|| ((left.display_name || left.device_id).localeCompare(right.display_name || right.device_id));
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: FilterVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivityThenDisplayName);
const ALL_FILTER_ID = 'ALL';
type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;
type DeviceFilterKey = FilterVariation | typeof ALL_FILTER_ID;
const securityCardContent: Record<DeviceSecurityVariation, {
title: string;
@ -90,6 +91,12 @@ const securityCardContent: Record<DeviceSecurityVariation, {
`sign out from those you don't recognize or use anymore.`,
),
},
[DeviceSecurityVariation.Unverifiable]: {
title: _t('Unverified session'),
description: _t(
`This session doesn't support encryption and thus can't be verified.`,
),
},
[DeviceSecurityVariation.Inactive]: {
title: _t('Inactive sessions'),
description: _t(
@ -100,8 +107,12 @@ const securityCardContent: Record<DeviceSecurityVariation, {
},
};
const isSecurityVariation = (filter?: DeviceFilterKey): filter is DeviceSecurityVariation =>
Object.values<string>(DeviceSecurityVariation).includes(filter);
const isSecurityVariation = (filter?: DeviceFilterKey): filter is FilterVariation =>
!!filter && ([
DeviceSecurityVariation.Inactive,
DeviceSecurityVariation.Unverified,
DeviceSecurityVariation.Verified,
] as string[]).includes(filter);
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
if (isSecurityVariation(filter)) {
@ -124,7 +135,7 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter })
return null;
};
const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
const getNoResultsMessage = (filter?: FilterVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.');
@ -136,7 +147,7 @@ const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
return _t('No sessions found.');
}
};
interface NoResultsProps { filter?: DeviceSecurityVariation, clearFilter: () => void}
interface NoResultsProps { filter?: FilterVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) }
@ -273,7 +284,7 @@ export const FilteredDeviceList =
];
const onFilterOptionChange = (filterId: DeviceFilterKey) => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as FilterVariation);
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;

View File

@ -21,7 +21,7 @@ import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection';
import DeviceSecurityCard from './DeviceSecurityCard';
import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
ExtendedDevice,
@ -31,7 +31,7 @@ import {
interface Props {
devices: DevicesDictionary;
currentDeviceId: ExtendedDevice['device_id'];
goToFilteredList: (filter: DeviceSecurityVariation) => void;
goToFilteredList: (filter: FilterVariation) => void;
}
const SecurityRecommendations: React.FC<Props> = ({

View File

@ -22,10 +22,14 @@ const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
export type FilterVariation = DeviceSecurityVariation.Verified
| DeviceSecurityVariation.Inactive
| DeviceSecurityVariation.Unverified;
export const isDeviceInactive: DeviceFilterCondition = device =>
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
const filters: Record<FilterVariation, DeviceFilterCondition> = {
[DeviceSecurityVariation.Verified]: device => !!device.isVerified,
[DeviceSecurityVariation.Unverified]: device => !device.isVerified,
[DeviceSecurityVariation.Inactive]: isDeviceInactive,
@ -33,7 +37,7 @@ const filters: Record<DeviceSecurityVariation, DeviceFilterCondition> = {
export const filterDevicesBySecurityRecommendation = (
devices: ExtendedDevice[],
securityVariations: DeviceSecurityVariation[],
securityVariations: FilterVariation[],
) => {
const activeFilters = securityVariations.map(variation => filters[variation]);
if (!activeFilters.length) {

View File

@ -32,4 +32,7 @@ export enum DeviceSecurityVariation {
Verified = 'Verified',
Unverified = 'Unverified',
Inactive = 'Inactive',
// sessions that do not support encryption
// eg a session that logged in via api to get an access token
Unverifiable = 'Unverifiable'
}

View File

@ -29,7 +29,7 @@ import { useOwnDevices } from '../../devices/useOwnDevices';
import { FilteredDeviceList } from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types';
import { ExtendedDevice } from '../../devices/types';
import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices';
import SettingsTab from '../SettingsTab';
import LoginWithQRSection from '../../devices/LoginWithQRSection';
@ -37,6 +37,7 @@ import LoginWithQR, { Mode } from '../../../auth/LoginWithQR';
import SettingsStore from '../../../../../settings/SettingsStore';
import { useAsyncMemo } from '../../../../../hooks/useAsyncMemo';
import QuestionDialog from '../../../dialogs/QuestionDialog';
import { FilterVariation } from '../../devices/filter';
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
const { finished } = Modal.createDialog(QuestionDialog, {
@ -123,7 +124,7 @@ const SessionManagerTab: React.FC = () => {
setPushNotifications,
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const [filter, setFilter] = useState<FilterVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<ExtendedDevice['device_id'][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
@ -142,7 +143,7 @@ const SessionManagerTab: React.FC = () => {
}
};
const onGoToFilteredList = (filter: DeviceSecurityVariation) => {
const onGoToFilteredList = (filter: FilterVariation) => {
setFilter(filter);
clearTimeout(scrollIntoViewTimeoutRef.current);
// wait a tick for the filtered section to rerender with different height

View File

@ -1801,6 +1801,10 @@
"Unverified sessions": "Unverified sessions",
"Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.",
"You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.",
"Unverified session": "Unverified session",
"This session doesn't support encryption, so it can't be verified.": "This session doesn't support encryption, so it can't be verified.",
"You won't be able to participate in rooms where encryption is enabled when using this session.": "You won't be able to participate in rooms where encryption is enabled when using this session.",
"For best security and privacy, it is recommended to use Matrix clients that support encryption.": "For best security and privacy, it is recommended to use Matrix clients that support encryption.",
"Inactive sessions": "Inactive sessions",
"Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.",
"Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.",
@ -1813,7 +1817,7 @@
"Unknown session type": "Unknown session type",
"Verified session": "Verified session",
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
"Unverified session": "Unverified session",
"This session doesn't support encryption and thus can't be verified.": "This session doesn't support encryption and thus can't be verified.",
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
"Verify session": "Verify session",
"For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",

View File

@ -255,12 +255,23 @@ describe('<SessionManagerTab />', () => {
});
it('sets device verification status correctly', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
mockClient.getDevices.mockResolvedValue({ devices:
[alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
});
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
// alices device is trusted
.mockReturnValueOnce(new DeviceTrustLevel(true, true, false, false))
// alices mobile device is not
.mockReturnValueOnce(new DeviceTrustLevel(false, false, false, false));
.mockImplementation((_userId, { deviceId }) => {
// alices device is trusted
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
// alices mobile device is not
if (deviceId === alicesMobileDevice.device_id) {
return new DeviceTrustLevel(false, false, false, false);
}
// alicesOlderMobileDevice does not support encryption
throw new Error('encryption not supported');
});
const { getByTestId } = render(getComponent());
@ -268,8 +279,20 @@ describe('<SessionManagerTab />', () => {
await flushPromises();
});
expect(mockCrossSigningInfo.checkDeviceTrust).toHaveBeenCalledTimes(2);
expect(getByTestId(`device-tile-${alicesDevice.device_id}`)).toMatchSnapshot();
expect(mockCrossSigningInfo.checkDeviceTrust).toHaveBeenCalledTimes(3);
expect(
getByTestId(`device-tile-${alicesDevice.device_id}`)
.querySelector('[aria-label="Verified"]'),
).toBeTruthy();
expect(
getByTestId(`device-tile-${alicesMobileDevice.device_id}`)
.querySelector('[aria-label="Unverified"]'),
).toBeTruthy();
// sessions that dont support encryption use unverified badge
expect(
getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`)
.querySelector('[aria-label="Unverified"]'),
).toBeTruthy();
});
it('extends device with client information when available', async () => {
@ -489,7 +512,7 @@ describe('<SessionManagerTab />', () => {
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
throw new Error('everything else unverified');
return new DeviceTrustLevel(false, false, false, false);
});
const { getByTestId } = render(getComponent());
@ -507,6 +530,38 @@ describe('<SessionManagerTab />', () => {
expect(modalSpy).toHaveBeenCalled();
});
it('does not allow device verification on session that do not support encryption', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
.mockImplementation((_userId, { deviceId }) => {
// current session verified = able to verify other sessions
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
// but alicesMobileDevice doesn't support encryption
throw new Error('encryption not supported');
});
const {
getByTestId,
queryByTestId,
} = render(getComponent());
await act(async () => {
await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
// no verify button
expect(queryByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)).toBeFalsy();
expect(
getByTestId(`device-detail-${alicesMobileDevice.device_id}`)
.getElementsByClassName('mx_DeviceSecurityCard'),
).toMatchSnapshot();
});
it('refreshes devices after verifying other device', async () => {
const modalSpy = jest.spyOn(Modal, 'createDialog');
@ -518,7 +573,7 @@ describe('<SessionManagerTab />', () => {
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
throw new Error('everything else unverified');
return new DeviceTrustLevel(false, false, false, false);
});
const { getByTestId } = render(getComponent());

View File

@ -1,5 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SessionManagerTab /> Device verification does not allow device verification on session that do not support encryption 1`] = `
HTMLCollection [
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified session
</p>
<p
class="mx_DeviceSecurityCard_description"
>
This session doesn't support encryption and thus can't be verified.
<div
class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Learn more
</div>
</p>
</div>
</div>,
]
`;
exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = `
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_inline"
@ -345,68 +383,3 @@ exports[`<SessionManagerTab /> goes to filtered list from security recommendatio
</div>
</div>
`;
exports[`<SessionManagerTab /> sets device verification status correctly 1`] = `
<div
class="mx_DeviceTile mx_DeviceTile_interactive"
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceTypeIcon"
>
<div
class="mx_DeviceTypeIcon_deviceIconWrapper"
>
<div
aria-label="Unknown session type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
</div>
<div
aria-label="Verified"
class="mx_DeviceTypeIcon_verificationIcon verified"
role="img"
/>
</div>
<div
class="mx_DeviceTile_info"
>
<h4
class="mx_Heading_h4"
>
Alices device
</h4>
<div
class="mx_DeviceTile_metadata"
>
<span
data-testid="device-metadata-isVerified"
>
Verified
</span>
·
<span
data-testid="device-metadata-deviceId"
>
alices_device
</span>
</div>
</div>
<div
class="mx_DeviceTile_actions"
>
<div
aria-label="Show details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details"
role="button"
tabindex="0"
>
<div
class="mx_DeviceExpandDetailsButton_icon"
/>
</div>
</div>
</div>
`;