Skip to content

Commit

Permalink
fix: prevent OTR in E2EE (#32459)
Browse files Browse the repository at this point in the history
  • Loading branch information
hugocostadev authored Jun 13, 2024
1 parent 4afd41c commit 1056f22
Show file tree
Hide file tree
Showing 10 changed files with 293 additions and 18 deletions.
6 changes: 6 additions & 0 deletions .changeset/famous-scissors-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/i18n": patch
---

Prevent usage of OTR messages with End-to-end Encryption, both feature shouldn't and can't work together.
122 changes: 122 additions & 0 deletions apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts';
import { act, renderHook } from '@testing-library/react-hooks';

import { E2EEState } from '../../../app/e2e/client/E2EEState';
import { e2e } from '../../../app/e2e/client/rocketchat.e2e';
import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState';
import { dispatchToastMessage } from '../../lib/toast';
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
import { useE2EEState } from '../../views/room/hooks/useE2EEState';
import { useOTR } from '../useOTR';
import { useE2EERoomAction } from './useE2EERoomAction';

jest.mock('@rocket.chat/ui-contexts', () => ({
useSetting: jest.fn(),
usePermission: jest.fn(),
useEndpoint: jest.fn(),
}));

jest.mock('../../lib/toast', () => ({
dispatchToastMessage: jest.fn(),
}));

jest.mock('../../views/room/contexts/RoomContext', () => ({
useRoom: jest.fn(),
useRoomSubscription: jest.fn(),
}));

jest.mock('../useOTR', () => ({
useOTR: jest.fn(),
}));

jest.mock('../../../app/e2e/client/rocketchat.e2e', () => ({
e2e: {
isReady: jest.fn(),
},
}));

jest.mock('../../views/room/hooks/useE2EEState', () => ({
useE2EEState: jest.fn(),
}));

jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

jest.mock('meteor/tracker', () => ({
Tracker: {
autorun: jest.fn(),
},
}));

describe('useE2EERoomAction', () => {
const mockRoom = { _id: 'roomId', encrypted: false, t: 'd', name: 'Test Room' };
const mockSubscription = { autoTranslate: false };

beforeEach(() => {
(useSetting as jest.Mock).mockReturnValue(true);
(useRoom as jest.Mock).mockReturnValue(mockRoom);
(useRoomSubscription as jest.Mock).mockReturnValue(mockSubscription);
(useE2EEState as jest.Mock).mockReturnValue(E2EEState.READY);
(usePermission as jest.Mock).mockReturnValue(true);
(useEndpoint as jest.Mock).mockReturnValue(jest.fn().mockResolvedValue({ success: true }));
(e2e.isReady as jest.Mock).mockReturnValue(true);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should dispatch error toast message when otrState is ESTABLISHED', async () => {
(useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.ESTABLISHED });

const { result } = renderHook(() => useE2EERoomAction());

await act(async () => {
await result?.current?.action?.();
});

expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' });
});

it('should dispatch error toast message when otrState is ESTABLISHING', async () => {
(useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.ESTABLISHING });

const { result } = renderHook(() => useE2EERoomAction());

await act(async () => {
await result?.current?.action?.();
});

expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' });
});

it('should dispatch error toast message when otrState is REQUESTED', async () => {
(useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.REQUESTED });

const { result } = renderHook(() => useE2EERoomAction());

await act(async () => {
await result?.current?.action?.();
});

expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'E2EE_not_available_OTR' });
});

it('should dispatch success toast message when encryption is enabled', async () => {
(useOTR as jest.Mock).mockReturnValue({ otrState: OtrRoomState.NOT_STARTED });

const { result } = renderHook(() => useE2EERoomAction());

await act(async () => {
await result?.current?.action?.();
});

expect(dispatchToastMessage).toHaveBeenCalledWith({
type: 'success',
message: 'E2E_Encryption_enabled_for_room',
});
});
});
13 changes: 11 additions & 2 deletions apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { E2EEState } from '../../../app/e2e/client/E2EEState';
import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState';
import { dispatchToastMessage } from '../../lib/toast';
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext';
import { useE2EEState } from '../../views/room/hooks/useE2EEState';
import { useOTR } from '../useOTR';

export const useE2EERoomAction = () => {
const enabled = useSetting('E2E_Enable', false);
Expand All @@ -22,10 +24,17 @@ export const useE2EERoomAction = () => {
const permitted = (room.t === 'd' || (permittedToEditRoom && permittedToToggleEncryption)) && readyToEncrypt;
const federated = isRoomFederated(room);
const { t } = useTranslation();
const { otrState } = useOTR();

const toggleE2E = useEndpoint('POST', '/v1/rooms.saveRoomSettings');

const action = useMutableCallback(async () => {
const action = useEffectEvent(async () => {
if (otrState === OtrRoomState.ESTABLISHED || otrState === OtrRoomState.ESTABLISHING || otrState === OtrRoomState.REQUESTED) {
dispatchToastMessage({ type: 'error', message: t('E2EE_not_available_OTR') });

return;
}

const { success } = await toggleE2E({ rid: room._id, encrypted: !room.encrypted });
if (!success) {
return;
Expand Down
70 changes: 70 additions & 0 deletions apps/meteor/client/hooks/useOTR.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useUserId } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react-hooks';

import OTR from '../../app/otr/client/OTR';
import { OtrRoomState } from '../../app/otr/lib/OtrRoomState';
import { useRoom } from '../views/room/contexts/RoomContext';
import { useOTR } from './useOTR';

jest.mock('@rocket.chat/ui-contexts', () => ({
useUserId: jest.fn(),
}));

jest.mock('../views/room/contexts/RoomContext', () => ({
useRoom: jest.fn(),
}));

jest.mock('../../app/otr/client/OTR', () => ({
getInstanceByRoomId: jest.fn(),
}));

jest.mock('./useReactiveValue', () => ({
useReactiveValue: jest.fn((fn) => fn()),
}));

describe('useOTR', () => {
it('should return error state when user ID is not available', () => {
(useUserId as jest.Mock).mockReturnValue(undefined);
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' });

const { result } = renderHook(() => useOTR());

expect(result.current.otr).toBeUndefined();
expect(result.current.otrState).toBe(OtrRoomState.ERROR);
});

it('should return error state when room ID is not available', () => {
(useUserId as jest.Mock).mockReturnValue('userId');
(useRoom as jest.Mock).mockReturnValue(undefined);

const { result } = renderHook(() => useOTR());

expect(result.current.otr).toBeUndefined();
expect(result.current.otrState).toBe(OtrRoomState.ERROR);
});

it('should return error state when OTR instance is not available', () => {
(useUserId as jest.Mock).mockReturnValue('userId');
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' });
(OTR.getInstanceByRoomId as jest.Mock).mockReturnValue(undefined);

const { result } = renderHook(() => useOTR());

expect(result.current.otr).toBeUndefined();
expect(result.current.otrState).toBe(OtrRoomState.ERROR);
});

it('should return the correct OTR instance and state when available', () => {
const mockOtrInstance = {
getState: jest.fn().mockReturnValue(OtrRoomState.NOT_STARTED),
};
(useUserId as jest.Mock).mockReturnValue('userId');
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId' });
(OTR.getInstanceByRoomId as jest.Mock).mockReturnValue(mockOtrInstance);

const { result } = renderHook(() => useOTR());

expect(result.current.otr).toBe(mockOtrInstance);
expect(result.current.otrState).toBe(OtrRoomState.NOT_STARTED);
});
});
28 changes: 28 additions & 0 deletions apps/meteor/client/hooks/useOTR.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useUserId } from '@rocket.chat/ui-contexts';
import { useMemo, useCallback } from 'react';

import OTR from '../../app/otr/client/OTR';
import type { OTRRoom } from '../../app/otr/client/OTRRoom';
import { OtrRoomState } from '../../app/otr/lib/OtrRoomState';
import { useRoom } from '../views/room/contexts/RoomContext';
import { useReactiveValue } from './useReactiveValue';

export const useOTR = (): { otr: OTRRoom | undefined; otrState: OtrRoomState } => {
const uid = useUserId();
const room = useRoom();

const otr = useMemo(() => {
if (!uid || !room) {
return;
}

return OTR.getInstanceByRoomId(uid, room._id);
}, [uid, room]);

const otrState = useReactiveValue(useCallback(() => (otr ? otr.getState() : OtrRoomState.ERROR), [otr]));

return {
otr,
otrState,
};
};
22 changes: 20 additions & 2 deletions apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Box, Button, Throbber } from '@rocket.chat/fuselage';
import { Box, Button, Callout, Throbber } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { MouseEventHandler, ReactElement } from 'react';
import React from 'react';
Expand All @@ -12,6 +12,7 @@ import {
ContextualbarClose,
ContextualbarScrollableContent,
} from '../../../../components/Contextualbar';
import { useRoom } from '../../contexts/RoomContext';
import OTREstablished from './components/OTREstablished';
import OTRStates from './components/OTRStates';

Expand All @@ -27,6 +28,7 @@ type OTRProps = {

const OTR = ({ isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh, otrState, peerUsername }: OTRProps): ReactElement => {
const t = useTranslation();
const room = useRoom();

const renderOTRState = (): ReactElement => {
switch (otrState) {
Expand Down Expand Up @@ -77,6 +79,22 @@ const OTR = ({ isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh,
}
};

const renderOTRBody = (): ReactElement => {
if (room.encrypted) {
return (
<Callout title={t('OTR_not_available')} type='warning'>
{t('OTR_not_available_e2ee')}
</Callout>
);
}

if (!isOnline) {
return <Box fontScale='p2m'>{t('OTR_is_only_available_when_both_users_are_online')}</Box>;
}

return renderOTRState();
};

return (
<>
<ContextualbarHeader>
Expand All @@ -86,7 +104,7 @@ const OTR = ({ isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh,
</ContextualbarHeader>
<ContextualbarScrollableContent p={24} color='default'>
<Box fontScale='h4'>{t('Off_the_record_conversation')}</Box>
{isOnline ? renderOTRState() : <Box fontScale='p2m'>{t('OTR_is_only_available_when_both_users_are_online')}</Box>}
{renderOTRBody()}
</ContextualbarScrollableContent>
</>
);
Expand Down
17 changes: 3 additions & 14 deletions apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
import { useUserId } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';

import OTR from '../../../../../app/otr/client/OTR';
import { OtrRoomState } from '../../../../../app/otr/lib/OtrRoomState';
import { useOTR } from '../../../../hooks/useOTR';
import { usePresence } from '../../../../hooks/usePresence';
import { useReactiveValue } from '../../../../hooks/useReactiveValue';
import { useRoom } from '../../contexts/RoomContext';
import { useRoomToolbox } from '../../contexts/RoomToolboxContext';
import OTRComponent from './OTR';

const OTRWithData = (): ReactElement => {
const uid = useUserId();
const room = useRoom();
const { otr, otrState } = useOTR();
const { closeTab } = useRoomToolbox();
const otr = useMemo(() => {
if (!uid) {
return;
}

return OTR.getInstanceByRoomId(uid, room._id);
}, [uid, room._id]);
const otrState = useReactiveValue(useCallback(() => (otr ? otr.getState() : OtrRoomState.ERROR), [otr]));
const peerUserPresence = usePresence(otr?.getPeerId());
const userStatus = peerUserPresence?.status;
const peerUsername = peerUserPresence?.username;
Expand Down
26 changes: 26 additions & 0 deletions apps/meteor/tests/e2e/e2e-encryption.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ test.describe.serial('e2e-encryption', () => {
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});

test('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page }) => {
await poHomeChannel.sidenav.openNewByLabel('Direct message');
await poHomeChannel.sidenav.inputDirectUsername.click();
await page.keyboard.type('user2');
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
await poHomeChannel.sidenav.btnCreate.click();

await expect(page).toHaveURL(`/direct/rocketchat.internal.admin.testuser2`);

await poHomeChannel.tabs.kebab.click({ force: true });
await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible();
await poHomeChannel.tabs.btnEnableE2E.click({ force: true });
await page.waitForTimeout(1000);

await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();

await poHomeChannel.dismissToast();

await poHomeChannel.tabs.kebab.click({ force: true });
await expect(poHomeChannel.tabs.btnEnableOTR).toBeVisible();
await poHomeChannel.tabs.btnEnableOTR.click({ force: true });

await expect(page.getByText('OTR not available')).toBeVisible();
});

test('expect placeholder text in place of encrypted message, when E2EE is not setup', async ({ page }) => {
const channelName = faker.string.uuid();

Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export class HomeFlextab {
return this.page.locator('role=menuitem[name="Enable E2E"]');
}

get btnEnableOTR(): Locator {
return this.page.locator('role=menuitem[name="OTR"]');
}

get flexTabViewThreadMessage(): Locator {
return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="message-body"]');
}
Expand Down
Loading

0 comments on commit 1056f22

Please sign in to comment.