Skip to content

Commit 688786a

Browse files
authored
feat: e2ee security hardening (RocketChat#36942)
1 parent 5c7e8ec commit 688786a

48 files changed

Lines changed: 3918 additions & 2099 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/meteor/app/lib/server/functions/notifications/desktop.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export async function notifyDesktopUser({
5757
...('t' in message && {
5858
t: message.t,
5959
}),
60+
...('content' in message && {
61+
content: message.content,
62+
}),
6063
},
6164
name,
6265
audioNotificationValue,

apps/meteor/app/message-pin/server/pinMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D
119119
text: originalMessage.msg,
120120
author_name: originalMessage.u.username,
121121
author_icon: getUserAvatarURL(originalMessage.u.username),
122+
content: originalMessage.content,
122123
ts: originalMessage.ts,
123124
attachments: attachments.map(recursiveRemove),
124125
},

apps/meteor/client/hooks/notification/useDesktopNotification.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ export const useDesktopNotification = () => {
2222
return;
2323
}
2424

25-
if (notification.payload.message?.t === 'e2e') {
25+
const { message } = notification.payload;
26+
27+
if (message.t === 'e2e' && message.content) {
2628
const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid);
2729
if (e2eRoom) {
28-
notification.text = (await e2eRoom.decrypt(notification.payload.message.msg)).text;
30+
const decrypted = await e2eRoom.decrypt(message.content);
31+
// TODO(@cardoso): review backward compatibility
32+
notification.text = decrypted.msg ?? '';
2933
}
3034
}
3135

apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts
33
import { act, renderHook, waitFor } from '@testing-library/react';
44

55
import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState';
6-
import { E2EEState } from '../../lib/e2ee/E2EEState';
76
import { e2e } from '../../lib/e2ee/rocketchat.e2e';
87
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
98
import { useE2EEState } from '../../views/room/hooks/useE2EEState';
@@ -70,7 +69,7 @@ describe('useE2EERoomAction', () => {
7069
(useSetting as jest.Mock).mockReturnValue(true);
7170
(useRoom as jest.Mock).mockReturnValue(mockRoom);
7271
(useRoomSubscription as jest.Mock).mockReturnValue(mockSubscription);
73-
(useE2EEState as jest.Mock).mockReturnValue(E2EEState.READY);
72+
(useE2EEState as jest.Mock).mockReturnValue('READY');
7473
(usePermission as jest.Mock).mockReturnValue(true);
7574
(useEndpoint as jest.Mock).mockReturnValue(jest.fn().mockResolvedValue({ success: true }));
7675
(e2e.isReady as jest.Mock).mockReturnValue(true);

apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import { useMemo } from 'react';
66
import { useTranslation } from 'react-i18next';
77

88
import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState';
9-
import { E2EEState } from '../../lib/e2ee/E2EEState';
10-
import { E2ERoomState } from '../../lib/e2ee/E2ERoomState';
119
import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation';
1210
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
1311
import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext';
@@ -23,7 +21,7 @@ export const useE2EERoomAction = () => {
2321
const subscription = useRoomSubscription();
2422
const e2eeState = useE2EEState();
2523
const e2eeRoomState = useE2EERoomState(room._id);
26-
const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD;
24+
const isE2EEReady = e2eeState === 'READY' || e2eeState === 'SAVE_PASSWORD';
2725
const readyToEncrypt = isE2EEReady || room.encrypted;
2826
const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id);
2927
const permittedToEditRoom = usePermission('edit-room', room._id);
@@ -34,13 +32,7 @@ export const useE2EERoomAction = () => {
3432
const { otrState } = useOTR();
3533

3634
const isE2EERoomNotReady = () => {
37-
if (
38-
e2eeRoomState === E2ERoomState.NO_PASSWORD_SET ||
39-
e2eeRoomState === E2ERoomState.NOT_STARTED ||
40-
e2eeRoomState === E2ERoomState.DISABLED ||
41-
e2eeRoomState === E2ERoomState.ERROR ||
42-
e2eeRoomState === E2ERoomState.WAITING_KEYS
43-
) {
35+
if (e2eeRoomState === 'NOT_STARTED' || e2eeRoomState === 'DISABLED' || e2eeRoomState === 'ERROR' || e2eeRoomState === 'WAITING_KEYS') {
4436
return true;
4537
}
4638

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1 @@
1-
export enum E2EEState {
2-
NOT_STARTED = 'NOT_STARTED',
3-
DISABLED = 'DISABLED',
4-
LOADING_KEYS = 'LOADING_KEYS',
5-
READY = 'READY',
6-
SAVE_PASSWORD = 'SAVE_PASSWORD',
7-
ENTER_PASSWORD = 'ENTER_PASSWORD',
8-
ERROR = 'ERROR',
9-
}
1+
export type E2EEState = 'NOT_STARTED' | 'DISABLED' | 'LOADING_KEYS' | 'READY' | 'SAVE_PASSWORD' | 'ENTER_PASSWORD' | 'ERROR';
Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
export enum E2ERoomState {
2-
NO_PASSWORD_SET = 'NO_PASSWORD_SET',
3-
NOT_STARTED = 'NOT_STARTED',
4-
DISABLED = 'DISABLED',
5-
HANDSHAKE = 'HANDSHAKE',
6-
ESTABLISHING = 'ESTABLISHING',
7-
CREATING_KEYS = 'CREATING_KEYS',
8-
WAITING_KEYS = 'WAITING_KEYS',
9-
KEYS_RECEIVED = 'KEYS_RECEIVED',
10-
READY = 'READY',
11-
ERROR = 'ERROR',
12-
}
1+
export type E2ERoomState =
2+
| 'NOT_STARTED'
3+
| 'DISABLED'
4+
| 'ESTABLISHING'
5+
| 'CREATING_KEYS'
6+
| 'WAITING_KEYS'
7+
| 'KEYS_RECEIVED'
8+
| 'READY'
9+
| 'ERROR';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Binary } from './binary';
2+
3+
describe('Binary', () => {
4+
describe('toString', () => {
5+
it('should convert ArrayBuffer to string', () => {
6+
const array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
7+
const result = Binary.encode(array.buffer);
8+
expect(result).toBe('Hello');
9+
});
10+
11+
it('should handle empty ArrayBuffer', () => {
12+
const buffer = new ArrayBuffer(0);
13+
const result = Binary.encode(buffer);
14+
expect(result).toBe('');
15+
});
16+
});
17+
18+
describe('toArrayBuffer', () => {
19+
it('should convert string to ArrayBuffer', () => {
20+
const str = 'Hello';
21+
const buffer = Binary.decode(str);
22+
const uint8 = new Uint8Array(buffer);
23+
expect(Array.from(uint8)).toEqual([72, 101, 108, 108, 111]);
24+
});
25+
26+
it('should handle empty string', () => {
27+
const str = '';
28+
const buffer = Binary.decode(str);
29+
expect(buffer.byteLength).toBe(0);
30+
});
31+
32+
it('should throw RangeError for illegal char code', () => {
33+
const str = 'Hello\u0100'; // Character with char code 256
34+
expect(() => Binary.decode(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`);
35+
});
36+
});
37+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { ICodec } from './codec';
2+
3+
export const Binary: ICodec<string, ArrayBuffer> = {
4+
encode(buffer: ArrayBuffer): string {
5+
const uint8 = new Uint8Array(buffer);
6+
const CHUNK_SIZE = 8192; // Process in chunks for performance
7+
let result = '';
8+
for (let i = 0; i < uint8.length; i += CHUNK_SIZE) {
9+
const chunk = uint8.subarray(i, i + CHUNK_SIZE);
10+
result += String.fromCharCode(...chunk);
11+
}
12+
return result;
13+
},
14+
decode(str: string): ArrayBuffer {
15+
// Create a Uint8Array of the same length as the string.
16+
// This will be a view on the new ArrayBuffer.
17+
const buffer = new ArrayBuffer(str.length);
18+
const uint8 = new Uint8Array(buffer);
19+
20+
// Iterate through the string, getting the character code for each
21+
// character and setting it as the value for the corresponding byte.
22+
for (let i = 0; i < str.length; i++) {
23+
const charCode = str.charCodeAt(i);
24+
if (charCode > 0xff) {
25+
throw new RangeError(`illegal char code: ${charCode}`);
26+
}
27+
uint8[i] = charCode;
28+
}
29+
30+
return buffer;
31+
},
32+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ICodec<TIn, TOut, TEnc = TIn> {
2+
decode: (data: TIn) => TOut;
3+
encode: (data: TOut) => TEnc;
4+
}

0 commit comments

Comments
 (0)