diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index f3dddd3fcf2..9b49f535820 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -57,6 +57,9 @@ export async function notifyDesktopUser({ ...('t' in message && { t: message.t, }), + ...('content' in message && { + content: message.content, + }), }, name, audioNotificationValue, diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 012c9bb2a23..002c790fb15 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -119,6 +119,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D text: originalMessage.msg, author_name: originalMessage.u.username, author_icon: getUserAvatarURL(originalMessage.u.username), + content: originalMessage.content, ts: originalMessage.ts, attachments: attachments.map(recursiveRemove), }, diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index 6d7a24317e4..45b30baaeb7 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -22,10 +22,14 @@ export const useDesktopNotification = () => { return; } - if (notification.payload.message?.t === 'e2e') { + const { message } = notification.payload; + + if (message.t === 'e2e' && message.content) { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { - notification.text = (await e2eRoom.decrypt(notification.payload.message.msg)).text; + const decrypted = await e2eRoom.decrypt(message.content); + // TODO(@cardoso): review backward compatibility + notification.text = decrypted.msg ?? ''; } } diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts index 533d9c1112b..616897c6217 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts @@ -3,7 +3,6 @@ import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts import { act, renderHook, waitFor } from '@testing-library/react'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { E2EEState } from '../../lib/e2ee/E2EEState'; import { e2e } from '../../lib/e2ee/rocketchat.e2e'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import { useE2EEState } from '../../views/room/hooks/useE2EEState'; @@ -70,7 +69,7 @@ describe('useE2EERoomAction', () => { (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); + (useE2EEState as jest.Mock).mockReturnValue('READY'); (usePermission as jest.Mock).mockReturnValue(true); (useEndpoint as jest.Mock).mockReturnValue(jest.fn().mockResolvedValue({ success: true })); (e2e.isReady as jest.Mock).mockReturnValue(true); diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index e7af15b2f46..42d15434737 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -6,8 +6,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { E2EEState } from '../../lib/e2ee/E2EEState'; -import { E2ERoomState } from '../../lib/e2ee/E2ERoomState'; import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; @@ -23,7 +21,7 @@ export const useE2EERoomAction = () => { const subscription = useRoomSubscription(); const e2eeState = useE2EEState(); const e2eeRoomState = useE2EERoomState(room._id); - const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD; + const isE2EEReady = e2eeState === 'READY' || e2eeState === 'SAVE_PASSWORD'; const readyToEncrypt = isE2EEReady || room.encrypted; const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id); const permittedToEditRoom = usePermission('edit-room', room._id); @@ -34,13 +32,7 @@ export const useE2EERoomAction = () => { const { otrState } = useOTR(); const isE2EERoomNotReady = () => { - if ( - e2eeRoomState === E2ERoomState.NO_PASSWORD_SET || - e2eeRoomState === E2ERoomState.NOT_STARTED || - e2eeRoomState === E2ERoomState.DISABLED || - e2eeRoomState === E2ERoomState.ERROR || - e2eeRoomState === E2ERoomState.WAITING_KEYS - ) { + if (e2eeRoomState === 'NOT_STARTED' || e2eeRoomState === 'DISABLED' || e2eeRoomState === 'ERROR' || e2eeRoomState === 'WAITING_KEYS') { return true; } diff --git a/apps/meteor/client/lib/e2ee/E2EEState.ts b/apps/meteor/client/lib/e2ee/E2EEState.ts index 0e505ec4a1b..68a5222939a 100644 --- a/apps/meteor/client/lib/e2ee/E2EEState.ts +++ b/apps/meteor/client/lib/e2ee/E2EEState.ts @@ -1,9 +1 @@ -export enum E2EEState { - NOT_STARTED = 'NOT_STARTED', - DISABLED = 'DISABLED', - LOADING_KEYS = 'LOADING_KEYS', - READY = 'READY', - SAVE_PASSWORD = 'SAVE_PASSWORD', - ENTER_PASSWORD = 'ENTER_PASSWORD', - ERROR = 'ERROR', -} +export type E2EEState = 'NOT_STARTED' | 'DISABLED' | 'LOADING_KEYS' | 'READY' | 'SAVE_PASSWORD' | 'ENTER_PASSWORD' | 'ERROR'; diff --git a/apps/meteor/client/lib/e2ee/E2ERoomState.ts b/apps/meteor/client/lib/e2ee/E2ERoomState.ts index 5e060816436..bb2d36ff321 100644 --- a/apps/meteor/client/lib/e2ee/E2ERoomState.ts +++ b/apps/meteor/client/lib/e2ee/E2ERoomState.ts @@ -1,12 +1,9 @@ -export enum E2ERoomState { - NO_PASSWORD_SET = 'NO_PASSWORD_SET', - NOT_STARTED = 'NOT_STARTED', - DISABLED = 'DISABLED', - HANDSHAKE = 'HANDSHAKE', - ESTABLISHING = 'ESTABLISHING', - CREATING_KEYS = 'CREATING_KEYS', - WAITING_KEYS = 'WAITING_KEYS', - KEYS_RECEIVED = 'KEYS_RECEIVED', - READY = 'READY', - ERROR = 'ERROR', -} +export type E2ERoomState = + | 'NOT_STARTED' + | 'DISABLED' + | 'ESTABLISHING' + | 'CREATING_KEYS' + | 'WAITING_KEYS' + | 'KEYS_RECEIVED' + | 'READY' + | 'ERROR'; diff --git a/apps/meteor/client/lib/e2ee/binary.spec.ts b/apps/meteor/client/lib/e2ee/binary.spec.ts new file mode 100644 index 00000000000..0b4c0ef668f --- /dev/null +++ b/apps/meteor/client/lib/e2ee/binary.spec.ts @@ -0,0 +1,37 @@ +import { Binary } from './binary'; + +describe('Binary', () => { + describe('toString', () => { + it('should convert ArrayBuffer to string', () => { + const array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = Binary.encode(array.buffer); + expect(result).toBe('Hello'); + }); + + it('should handle empty ArrayBuffer', () => { + const buffer = new ArrayBuffer(0); + const result = Binary.encode(buffer); + expect(result).toBe(''); + }); + }); + + describe('toArrayBuffer', () => { + it('should convert string to ArrayBuffer', () => { + const str = 'Hello'; + const buffer = Binary.decode(str); + const uint8 = new Uint8Array(buffer); + expect(Array.from(uint8)).toEqual([72, 101, 108, 108, 111]); + }); + + it('should handle empty string', () => { + const str = ''; + const buffer = Binary.decode(str); + expect(buffer.byteLength).toBe(0); + }); + + it('should throw RangeError for illegal char code', () => { + const str = 'Hello\u0100'; // Character with char code 256 + expect(() => Binary.decode(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`); + }); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/binary.ts b/apps/meteor/client/lib/e2ee/binary.ts new file mode 100644 index 00000000000..00dbdeab7c8 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/binary.ts @@ -0,0 +1,32 @@ +import type { ICodec } from './codec'; + +export const Binary: ICodec = { + encode(buffer: ArrayBuffer): string { + const uint8 = new Uint8Array(buffer); + const CHUNK_SIZE = 8192; // Process in chunks for performance + let result = ''; + for (let i = 0; i < uint8.length; i += CHUNK_SIZE) { + const chunk = uint8.subarray(i, i + CHUNK_SIZE); + result += String.fromCharCode(...chunk); + } + return result; + }, + decode(str: string): ArrayBuffer { + // Create a Uint8Array of the same length as the string. + // This will be a view on the new ArrayBuffer. + const buffer = new ArrayBuffer(str.length); + const uint8 = new Uint8Array(buffer); + + // Iterate through the string, getting the character code for each + // character and setting it as the value for the corresponding byte. + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i); + if (charCode > 0xff) { + throw new RangeError(`illegal char code: ${charCode}`); + } + uint8[i] = charCode; + } + + return buffer; + }, +}; diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts new file mode 100644 index 00000000000..1ce36508804 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -0,0 +1,4 @@ +export interface ICodec { + decode: (data: TIn) => TOut; + encode: (data: TOut) => TEnc; +} diff --git a/apps/meteor/client/lib/e2ee/content.spec.ts b/apps/meteor/client/lib/e2ee/content.spec.ts new file mode 100644 index 00000000000..fc354f46906 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/content.spec.ts @@ -0,0 +1,144 @@ +import { decodeEncryptedContent } from './content'; +import { importKey, decrypt, type Key } from './crypto/aes'; + +describe('content', () => { + const msgv1web = Object.freeze({ + _id: 'JfAxN6Ncsw2XS9eiY', + rid: '68dad82a10815056615446aa', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: '32c9e7917b78LHjHfqLMeDn+2UK1PhD/soFe8CVwvFdLkslcfxNHby4=', + }), + t: 'e2e', + ts: { + $date: '2025-09-29T19:07:07.908Z', + }, + u: { + _id: 'bm4cAAcN92jgXe2jN', + username: 'alice', + name: 'alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-29T19:07:07.929Z', + }, + urls: [], + mentions: [], + channels: [], + }); + + const msgv1mob = Object.freeze({ + _id: 'AZF8Myj605B3f7ZPL', + rid: '68dad82a10815056615446aa', + msg: '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + t: 'e2e', + e2e: 'pending', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: + '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + }), + ts: { + $date: '2025-09-29T19:28:35.261Z', + }, + u: { + _id: 'RQTYT5RJoDKZFwDhk', + username: 'bob', + name: 'bob', + }, + _updatedAt: { + $date: '2025-09-29T19:28:35.274Z', + }, + urls: [], + mentions: [], + channels: [], + }); + + describe('v1 messages', () => { + let key: Key<{ name: 'AES-CBC'; length: 128 }>; + + beforeAll(async () => { + key = await importKey({ + alg: 'A128CBC', + ext: true, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }); + }); + + test('parse v1 web message', async () => { + const parsed = decodeEncryptedContent(msgv1web.content); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); + }); + + test('parse v1 mobile message', async () => { + const parsed = decodeEncryptedContent(msgv1mob.content); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); + }); + + test('parse v1 mobile message from msg field', async () => { + const parsed = decodeEncryptedContent(msgv1mob.msg); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); + }); + }); + + const msgv2web = Object.freeze({ + _id: 'h6sXWTiKcWfcgkhgo', + rid: '68c9da1b0427bc33b429207e', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v2.aes-sha2', + kid: 'f46d2864-0384-4a87-8815-51fba2cad216', + iv: 'wXbYQ8q9sYRCHtNp', + ciphertext: 'cIDO9mXzCCrrl/wORP0Jf6oWeusqzSCXVGGvY7CHrA==', + }), + t: 'e2e', + ts: { + $date: '2025-09-30T18:05:30.876Z', + }, + u: { + _id: 'Ctk47kkuzJihnmvZE', + username: 'alice', + name: 'Alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-30T18:05:30.887Z', + }, + urls: [], + mentions: [], + channels: [], + }); + + test('parse v2 web message', async () => { + const parsed = decodeEncryptedContent(msgv2web.content); + const key = await importKey({ + alg: 'A256GCM', + ext: true, + k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts new file mode 100644 index 00000000000..07c38df8210 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -0,0 +1,92 @@ +import { Base64 } from '@rocket.chat/base64'; +import type { EncryptedContent } from '@rocket.chat/core-typings'; + +type DecodedContent = { + kid: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +interface ISlice { + slice(start: number, end?: number): this; +} + +/** + * Splits a slice of data into two parts at the specified index. + * @param data The data to split. + * @param index The index at which to split the data. + * @returns A tuple containing the two parts of the split data. + */ +const split = (data: T, index: number): [T, T] => [data.slice(0, index), data.slice(index)]; + +/** + * Decodes an encrypted content string in the format "kid + base64(iv + ciphertext)". + * @param payload The encrypted content string. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + */ +const decodeV1EncryptedContent = ( + payload: Omit, 'algorithm'>, +): DecodedContent => { + if (payload.ciphertext.length < 12) { + throw new Error('Invalid v1 ciphertext: too short for kid extraction'); + } + + const [kid, base64] = split(payload.ciphertext, 12); + const decoded = Base64.decode(base64); + + if (decoded.length < 16) { + throw new Error('Invalid v1 ciphertext: too short for iv extraction'); + } + + const [iv, ciphertext] = split(decoded, 16); + + return { + kid, + iv, + ciphertext, + }; +}; + +/** + * Decodes an encrypted content object with separate fields for kid, iv, and ciphertext. + * @param payload The encrypted content object. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + */ +const decodeV2EncryptedContent = (payload: Extract): DecodedContent => { + if (!payload.kid || !payload.iv || !payload.ciphertext) { + throw new Error('Invalid v2 payload: kid, iv, and ciphertext must be non-empty'); + } + + const iv = Base64.decode(payload.iv); + const ciphertext = Base64.decode(payload.ciphertext); + return { kid: payload.kid, iv, ciphertext }; +}; + +const normalizePayload = (payload: string | EncryptedContent): EncryptedContent => { + if (typeof payload === 'string') { + return { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const; + } + + return payload; +}; + +/** + * Parses encrypted content from either a string or an object and decodes it into its components. + * @param payload The encrypted content, either as a string or an object. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + * @throws Will throw an error if the encryption algorithm is unsupported. + */ +export const decodeEncryptedContent = (payload: string | EncryptedContent): DecodedContent => { + payload = normalizePayload(payload); + + const { algorithm } = payload; + + switch (algorithm) { + case 'rc.v1.aes-sha2': + return decodeV1EncryptedContent(payload); + case 'rc.v2.aes-sha2': + return decodeV2EncryptedContent(payload); + default: + throw new Error(`Unsupported encryption algorithm: ${algorithm}`); + } +}; diff --git a/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts new file mode 100644 index 00000000000..d212b469623 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts @@ -0,0 +1,97 @@ +import { generate, decrypt, encrypt, exportJwk, importKey, type Key } from './aes'; + +describe('aes', () => { + describe('256-gcm', () => { + let key: Key<{ name: 'AES-GCM'; length: 256 }>; + + beforeAll(async () => { + key = await generate(); + }); + + it('generate a key with correct properties', async () => { + expect<256>(key.algorithm.length).toBe(256); + expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, world!'); + const ciphertext = await encrypt(key, plaintext); + const decrypted = await decrypt(key, ciphertext); + expect(decrypted).toBe('Hello, world!'); + }); + + it('should export and re-import the key correctly', async () => { + const jwk = await exportJwk(key); + const importedKey = await importKey(jwk); + expect<256>(importedKey.algorithm.length).toBe(256); + expect<'AES-GCM'>(importedKey.algorithm.name).toBe('AES-GCM'); + expect(importedKey.extractable).toBe(true); + expect<'secret'>(importedKey.type).toBe('secret'); + expect<2>(importedKey.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']); + }); + }); + + describe('128-cbc', () => { + let key: Key<{ name: 'AES-CBC'; length: 128 }>; + + beforeAll(async () => { + key = await importKey({ alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_ops: ['encrypt', 'decrypt'], kty: 'oct' }); + }); + + it('import a key with correct properties', async () => { + expect<128>(key.algorithm.length).toBe(128); + expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, AES-CBC!'); + const ciphertext = await encrypt(key, plaintext); + const decrypted = await decrypt(key, ciphertext); + expect(decrypted).toBe('Hello, AES-CBC!'); + }); + + it('should export and re-import the key correctly', async () => { + const jwk = await exportJwk(key); + const importedKey = await importKey(jwk); + expect<128>(importedKey.algorithm.length).toBe(128); + expect<'AES-CBC'>(importedKey.algorithm.name).toBe('AES-CBC'); + expect(importedKey.extractable).toBe(true); + expect<'secret'>(importedKey.type).toBe('secret'); + expect<2>(importedKey.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']); + }); + }); + + it('should throw on unsupported JWK alg', async () => { + await expect( + importKey({ + // @ts-expect-error testing invalid alg + alg: 'A128GCM', + ext: true, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }), + ).rejects.toThrow('Unrecognized algorithm name'); + + await expect( + importKey({ + // @ts-expect-error testing invalid alg + alg: 'A256CBC', + ext: true, + k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }), + ).rejects.toThrow('Unrecognized algorithm name'); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/crypto/aes.ts b/apps/meteor/client/lib/e2ee/crypto/aes.ts new file mode 100644 index 00000000000..591c9e64650 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/aes.ts @@ -0,0 +1,62 @@ +import { importJwk, exportKey, getRandomValues, generateKey, type IKey, type Exported, encryptBuffer, decryptBuffer } from './shared'; + +type AlgorithmMap = { + A256GCM: { name: 'AES-GCM'; length: 256 }; + A128CBC: { name: 'AES-CBC'; length: 128 }; +}; + +const ALGORITHM_MAP: AlgorithmMap = { + A256GCM: { name: 'AES-GCM', length: 256 }, + A128CBC: { name: 'AES-CBC', length: 128 }, +}; + +type Jwa = keyof AlgorithmMap; +type Algorithms = AlgorithmMap[Jwa]; + +export type Key = IKey< + TAlgorithm, + TExtractable, + 'secret', + ['encrypt', 'decrypt'] +>; + +export type Jwk = { + kty: 'oct'; + k: string; + key_ops: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; + ext: true; + alg: TJwa; +}; + +type AesEncryptedContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +export const importKey = (jwk: Jwk): Promise> => { + return importJwk(jwk, ALGORITHM_MAP[jwk.alg], true, ['encrypt', 'decrypt']); +}; + +export const exportJwk = (key: Key): Promise>> => { + return exportKey('jwk', key); +}; + +export const generate = (): Promise> => { + return generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +}; + +export const decrypt = async (key: Key, content: AesEncryptedContent): Promise => { + const decrypted = await decryptBuffer( + key, + { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, + content.ciphertext, + ); + return new TextDecoder().decode(decrypted); +}; + +export const encrypt = async (key: Key, plaintext: Uint8Array): Promise => { + const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16; + const iv = getRandomValues(new Uint8Array(ivLength)); + const ciphertext = await encryptBuffer(key, { name: key.algorithm.name, iv }, plaintext); + return { iv, ciphertext: new Uint8Array(ciphertext) }; +}; diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts new file mode 100644 index 00000000000..01b66c27526 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts @@ -0,0 +1,49 @@ +import { importBaseKey, derive, importKey } from './pbkdf2'; + +describe('pbkdf2', () => { + it('should import a base key', async () => { + const keyData = new Uint8Array([1, 2, 3, 4, 5]); + const baseKey = await importBaseKey(keyData); + expect<'PBKDF2'>(baseKey.algorithm.name).toBe('PBKDF2'); + expect(baseKey.extractable).toBe(false); + expect<'secret'>(baseKey.type).toBe('secret'); + expect<1>(baseKey.usages.length).toBe(1); + expect<'deriveBits'>(baseKey.usages[0]).toBe('deriveBits'); + }); + + it('should derive bits from a base key', async () => { + const keyData = new Uint8Array([1, 2, 3, 4, 5]); + const salt = new Uint8Array([5, 4, 3, 2, 1]); + const iterations = 1000; + const baseKey = await importBaseKey(keyData); + const derivedBits = await derive(baseKey, { salt, iterations }); + expect(derivedBits.detached).toBe(false); + expect(derivedBits.resizable).toBe(false); + expect<32>(derivedBits.byteLength).toBe(32); + expect<32>(derivedBits.maxByteLength).toBe(32); + expect<() => never>(() => derivedBits.resize(32)).toThrow(); + }); + + it('should import a derived key for AES-CBC', async () => { + const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); + const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const derivedKey = await importKey(derivedBits, { name: 'AES-CBC', length: 256 }); + expect<256>(derivedKey.algorithm.length).toBe(256); + expect<'AES-CBC'>(derivedKey.algorithm.name).toBe('AES-CBC'); + expect(derivedKey.extractable).toBe(false); + expect<'secret'>(derivedKey.type).toBe('secret'); + expect<1>(derivedKey.usages.length).toBe(1); + expect<['decrypt']>(derivedKey.usages).toEqual(['decrypt']); + }); + + it('should import a derived key for AES-GCM', async () => { + const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); + const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const derivedKey = await importKey(derivedBits, { name: 'AES-GCM', length: 256 }); + expect<256>(derivedKey.algorithm.length).toBe(256); + expect<'AES-GCM'>(derivedKey.algorithm.name).toBe('AES-GCM'); + expect(derivedKey.extractable).toBe(false); + expect<'secret'>(derivedKey.type).toBe('secret'); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(derivedKey.usages).toEqual(['encrypt', 'decrypt']); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts new file mode 100644 index 00000000000..721163862dd --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts @@ -0,0 +1,87 @@ +import { importRaw, getRandomValues, decryptBuffer, encryptBuffer, deriveBits, type IKey } from './shared'; + +type Algorithms = { name: 'AES-GCM'; length: 256 } | { name: 'AES-CBC'; length: 256 }; + +export type DerivedKey = IKey< + TAlgorithm, + false, + 'secret', + TAlgorithm['name'] extends 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt'] +>; + +export type Options = { + salt: Uint8Array; + iterations: number; +}; + +export type EncryptedContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +type Narrow = { + [P in keyof T]: P extends keyof U ? U[P] : T[P]; +}; + +export type BaseKey = IKey< + { + readonly name: 'PBKDF2'; + }, + false, + 'secret', + ['deriveBits'] +>; + +export const importBaseKey = async (keyData: Uint8Array): Promise => { + const baseKey = await importRaw(keyData, { name: 'PBKDF2' }, false, ['deriveBits']); + return baseKey; +}; + +type Throws = F extends (...args: infer TArgs) => infer TRet ? (...args: TArgs) => TRet & never : never; + +type FixedSizeArrayBuffer = Narrow< + ArrayBuffer, + { + resize: Throws; + readonly byteLength: N; + get maxByteLength(): N; + get resizable(): false; + get detached(): false; + } +>; + +export type DerivedBits = FixedSizeArrayBuffer<32>; + +export const derive = async (key: BaseKey, options: Options): Promise => { + const bits = await deriveBits( + { name: key.algorithm.name, hash: 'SHA-256', salt: options.salt, iterations: options.iterations }, + key, + 256, + ); + return bits as DerivedBits; +}; + +export const importKey = async (derivedBits: DerivedBits, algorithm: T): Promise> => { + const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm.name === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; + const key = await importRaw(derivedBits, algorithm satisfies AesKeyGenParams, false, usages); + return key as DerivedKey; +}; + +export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise> => { + const decrypted = await decryptBuffer( + key, + { name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams, + content.ciphertext, + ); + return new Uint8Array(decrypted); +}; + +export const encrypt = async ( + key: DerivedKey<{ name: 'AES-GCM'; length: 256 }>, + data: Uint8Array, +): Promise => { + // Always use AES-GCM for new data + const iv = getRandomValues(new Uint8Array(12)); + const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, data); + return { iv, ciphertext: new Uint8Array(ciphertext) }; +}; diff --git a/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts new file mode 100644 index 00000000000..ec7024c503f --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts @@ -0,0 +1,65 @@ +import { generate, decrypt, encrypt, exportPublicKey, exportPrivateKey, importPrivateKey, importPublicKey, type KeyPair } from './rsa'; + +describe('rsa', () => { + let keyPair: KeyPair; + + beforeAll(async () => { + keyPair = await generate(); + }); + + it('generate a key pair with correct properties', async () => { + expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.publicKey.extractable).toBe(true); + expect<'public'>(keyPair.publicKey.type).toBe('public'); + expect<1>(keyPair.publicKey.usages.length).toBe(1); + expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']); + + expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.privateKey.extractable).toBe(true); + expect<'private'>(keyPair.privateKey.type).toBe('private'); + expect<1>(keyPair.privateKey.usages.length).toBe(1); + expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, RSA-OAEP!'); + const ciphertext = await encrypt(keyPair.publicKey, plaintext); + const decrypted = await decrypt(keyPair.privateKey, ciphertext); + expect(new TextDecoder().decode(decrypted)).toBe('Hello, RSA-OAEP!'); + }); + + it('should export and re-import the keys correctly', async () => { + const publicJwk = await exportPublicKey(keyPair.publicKey); + const privateJwk = await exportPrivateKey(keyPair.privateKey); + + expect<'RSA-OAEP-256'>(publicJwk.alg).toBe('RSA-OAEP-256'); + expect<'RSA'>(publicJwk.kty).toBe('RSA'); + expect(publicJwk.ext).toBe(true); + expect<'AQAB'>(publicJwk.e).toBe('AQAB'); + + expect<'RSA-OAEP-256'>(privateJwk.alg).toBe('RSA-OAEP-256'); + expect<'RSA'>(privateJwk.kty).toBe('RSA'); + expect(privateJwk.ext).toBe(true); + expect<'AQAB'>(privateJwk.e).toBe('AQAB'); + + expect(privateJwk.n).toEqual(publicJwk.n); + + const importedPublicKey = await importPublicKey(publicJwk); + expect<2048>(importedPublicKey.algorithm.modulusLength).toBe(2048); + expect<'RSA-OAEP'>(importedPublicKey.algorithm.name).toBe('RSA-OAEP'); + expect(importedPublicKey.extractable).toBe(true); + expect<'public'>(importedPublicKey.type).toBe('public'); + expect<1>(importedPublicKey.usages.length).toBe(1); + expect<['encrypt']>(importedPublicKey.usages).toEqual(['encrypt']); + + const importedPrivateKey = await importPrivateKey(privateJwk); + expect<2048>(importedPrivateKey.algorithm.modulusLength).toBe(2048); + expect<'RSA-OAEP'>(importedPrivateKey.algorithm.name).toBe('RSA-OAEP'); + expect(importedPrivateKey.extractable).toBe(true); + expect<'private'>(importedPrivateKey.type).toBe('private'); + expect<1>(importedPrivateKey.usages.length).toBe(1); + expect<['decrypt']>(importedPrivateKey.usages).toEqual(['decrypt']); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/crypto/rsa.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.ts new file mode 100644 index 00000000000..6c15154152c --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.ts @@ -0,0 +1,109 @@ +import { generateKeyPair, exportKey, importJwk, type IKeyPair, encryptBuffer, decryptBuffer } from './shared'; + +export type KeyPair = IKeyPair< + { + readonly name: 'RSA-OAEP'; + readonly modulusLength: 2048; + readonly publicExponent: Uint8Array; + readonly hash: { + readonly name: 'SHA-256'; + }; + }, + true, + ['encrypt', 'decrypt'] +>; + +export type PublicKey = KeyPair['publicKey']; + +export type PrivateKey = KeyPair['privateKey']; + +export const generate = async (): Promise => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + + return keyPair; +}; + +type Base64Url = string; + +export interface IPublicJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + key_ops: ['encrypt']; + n: Base64Url; +} + +export interface IPrivateJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + d: Base64Url; + dp: Base64Url; + dq: Base64Url; + key_ops: ['decrypt']; + n: Base64Url; + p: Base64Url; + q: Base64Url; + qi: Base64Url; +} + +export const exportPublicKey = async (key: PublicKey): Promise => { + const jwk = await exportKey('jwk', key); + return jwk as IPublicJwk; +}; + +export const exportPrivateKey = async (key: PrivateKey): Promise => { + const jwk = await exportKey('jwk', key); + return jwk as IPrivateJwk; +}; + +export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { + const key = await importJwk( + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }, + true, + ['decrypt'], + ); + return key as PrivateKey; +}; + +export const importPublicKey = async (keyData: IPublicJwk): Promise => { + const key = await importJwk( + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }, + true, + ['encrypt'], + ); + return key as PublicKey; +}; + +export const encrypt = async (key: PublicKey, data: BufferSource): Promise> => { + const encrypted = await encryptBuffer(key, { name: key.algorithm.name }, data); + return new Uint8Array(encrypted); +}; + +export const decrypt = async (key: PrivateKey, data: BufferSource): Promise> => { + const decrypted = await decryptBuffer(key, { name: key.algorithm.name }, data); + return new Uint8Array(decrypted); +}; diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts new file mode 100644 index 00000000000..6b992b83963 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts @@ -0,0 +1,141 @@ +import { generateKey, generateKeyPair, exportKey, importJwk, encryptBuffer, decryptBuffer, getRandomValues } from './shared'; + +describe('Shared Crypto Functions', () => { + describe('generateKey', () => { + it('should generate an AES key with correct properties', async () => { + const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM'); + expect<256>(key.algorithm.length).toBe(256); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt'])); + }); + + it('should export a key to JWK format', async () => { + const key = await generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt']); + const exportedKey = await exportKey('jwk', key); + expect(Object.keys(exportedKey)).toHaveLength(5); + expect(exportedKey.ext).toBe(true); + expect<'oct'>(exportedKey.kty).toBe('oct'); + expect(exportedKey.k).toHaveLength(22); // 128 bits in base64url + expect<`A128CBC`>(exportedKey.alg).toBe('A128CBC'); + expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']); + }); + }); + + describe('generateKeyPair', () => { + it('should generate an RSA key pair with correct properties', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + + expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.publicKey.extractable).toBe(true); + expect<'public'>(keyPair.publicKey.type).toBe('public'); + expect<1>(keyPair.publicKey.usages.length).toBe(1); + expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']); + + expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.privateKey.extractable).toBe(true); + expect<'private'>(keyPair.privateKey.type).toBe('private'); + expect<1>(keyPair.privateKey.usages.length).toBe(1); + expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']); + }); + }); + + describe('importKey', () => { + it('should import an AES key from JWK format', async () => { + const key = await importJwk( + { + kty: 'oct' as const, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'] as const, + ext: true, + alg: 'A128CBC' as const, + }, + { name: 'AES-CBC', length: 128 }, + true, + ['encrypt', 'decrypt'], + ); + expect<128>(key.algorithm.length).toBe(128); + expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt'])); + }); + }); + + describe('exportKey', () => { + it('should export an RSA public key to JWK format', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + const exportedKey = await exportKey('jwk', keyPair.publicKey); + expect(Object.keys(exportedKey).toSorted()).toEqual(['alg', 'e', 'ext', 'key_ops', 'kty', 'n'].toSorted()); + expect(exportedKey.ext).toBe(true); + expect<'RSA'>(exportedKey.kty).toBe('RSA'); + expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256'); + expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']); + expect(exportedKey.n).toBeDefined(); + expect(exportedKey.e).toBeDefined(); + }); + + it('should export an RSA private key to JWK format', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + const exportedKey = await exportKey('jwk', keyPair.privateKey); + expect(Object.keys(exportedKey).toSorted()).toEqual( + ['alg', 'd', 'dp', 'dq', 'e', 'ext', 'key_ops', 'kty', 'n', 'p', 'q', 'qi'].toSorted(), + ); + expect(exportedKey.ext).toBe(true); + expect<'RSA'>(exportedKey.kty).toBe('RSA'); + expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256'); + expect<['decrypt']>(exportedKey.key_ops).toEqual(['decrypt']); + expect(exportedKey.n).toBeDefined(); + expect(exportedKey.e).toBeDefined(); + expect(exportedKey.d).toBeDefined(); + expect(exportedKey.p).toBeDefined(); + expect(exportedKey.q).toBeDefined(); + expect(exportedKey.dp).toBeDefined(); + expect(exportedKey.dq).toBeDefined(); + expect(exportedKey.qi).toBeDefined(); + }); + }); + + describe('encryptBuffer', () => { + it('should encrypt and decrypt data correctly', async () => { + const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const plaintext = new TextEncoder().encode('Test encryption'); + const iv = getRandomValues(new Uint8Array(12)); + const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, plaintext); + const decrypted = await decryptBuffer(key, { name: 'AES-GCM', iv }, ciphertext); + expect(new TextDecoder().decode(decrypted)).toBe('Test encryption'); + }); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.ts b/apps/meteor/client/lib/e2ee/crypto/shared.ts new file mode 100644 index 00000000000..ea95b651c6f --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/shared.ts @@ -0,0 +1,198 @@ +const { subtle } = crypto; +export const randomUUID = crypto.randomUUID.bind(crypto); +export const getRandomValues = crypto.getRandomValues.bind(crypto); + +interface IAesGcmParams extends AesGcmParams { + name: 'AES-GCM'; +} + +interface IAesCbcParams extends AesCbcParams { + name: 'AES-CBC'; +} + +interface IAesCtrParams extends AesCtrParams { + name: 'AES-CTR'; +} + +interface IRsaOaepParams extends RsaOaepParams { + name: 'RSA-OAEP'; +} + +type ParamsMap = { + 'AES-GCM': IAesGcmParams; + 'AES-CTR': IAesCtrParams; + 'AES-CBC': IAesCbcParams; + 'RSA-OAEP': IRsaOaepParams; +}; + +type ParamsOf = TKey['algorithm'] extends { name: infer TName extends keyof ParamsMap } ? ParamsMap[TName] : never; +type HasUsage = TUsage extends TKey['usages'][number] + ? TKey + : TKey & `The provided key cannot be used for ${TUsage}`; + +export const encryptBuffer = >( + key: HasUsage, + params: TParams, + data: BufferSource, +): Promise => subtle.encrypt(params, key, data) as Promise; +export const decryptBuffer = ( + key: HasUsage, + params: ParamsOf, + data: BufferSource, +): Promise => subtle.decrypt(params, key, data) as Promise; +export const deriveBits = subtle.deriveBits.bind(subtle); + +type AesParams = { + name: 'AES-CBC' | 'AES-GCM' | 'AES-CTR'; + length: 128 | 256; +}; + +type ModeOf = T extends `AES-${infer Mode}` ? Mode : never; + +type Permutations = T extends [infer First] + ? [First] + : T extends [infer First, infer Second] + ? [First, Second] | [Second, First] + : never; + +export interface IKey< + TAlgorithm extends CryptoKey['algorithm'] = CryptoKey['algorithm'], + TExtractable extends CryptoKey['extractable'] = CryptoKey['extractable'], + TType extends CryptoKey['type'] = CryptoKey['type'], + TUsages extends CryptoKey['usages'] = CryptoKey['usages'], +> extends CryptoKey { + readonly algorithm: TAlgorithm; + readonly extractable: TExtractable; + readonly type: TType; + readonly usages: Permutations; +} + +export async function generateKey< + const T extends AesParams, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise> { + const key = await subtle.generateKey(algorithm, extractable, keyUsages); + return key as IKey; +} + +type Filter = TArray extends [ + infer First extends KeyUsage, + ...infer Rest extends KeyUsage[], +] + ? First extends TItem + ? Filter + : Filter + : Out; + +export interface IKeyPair< + T extends KeyAlgorithm = KeyAlgorithm, + TExtractable extends boolean = boolean, + TUsages extends KeyUsage[] = KeyUsage[], +> extends CryptoKeyPair { + publicKey: IKey>; + privateKey: IKey>; +} + +type RsaParams = { + name: 'RSA-OAEP'; + modulusLength: 2048; + publicExponent: Uint8Array; + hash: { name: 'SHA-256' }; +}; + +export async function generateKeyPair< + const T extends RsaParams, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise> { + const keyPair = await subtle.generateKey(algorithm, extractable, keyUsages); + return keyPair as IKeyPair; +} + +type KeyToJwk = T['extractable'] extends false + ? never + : T['algorithm'] extends AesParams + ? { + kty: 'oct'; + alg: `A${T['algorithm']['length']}${ModeOf}`; + ext: true; + k: string; + key_ops: T['usages']; + } + : [T['algorithm'], T['type']] extends [RsaParams, 'public'] + ? { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: string; + ext: true; + key_ops: T['usages']; + n: string; + } + : [T['algorithm'], T['type']] extends [RsaParams, 'private'] + ? { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: string; + ext: true; + d: string; + dp: string; + dq: string; + key_ops: T['usages']; + n: string; + p: string; + q: string; + qi: string; + } + : never; + +export type Exported = TFormat extends 'jwk' ? KeyToJwk : ArrayBuffer; + +export async function exportKey( + format: TFormat, + key: TKey, +): Promise> { + const exportedKey = await subtle.exportKey(format, key); + return exportedKey as Exported; +} + +type KtyParams = { + oct: AesParams; + RSA: RsaParams; +}; + +export async function importJwk< + const TKeyData extends JsonWebKey, + const TAlgorithm extends TKeyData extends { kty: infer Kty extends keyof KtyParams } ? KtyParams[Kty] : KeyAlgorithm, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], + const TKeyType extends KeyType = TKeyData extends { kty: 'oct' } + ? 'secret' + : TKeyData extends { kty: 'RSA'; key_ops: [infer Op] } + ? Op extends 'encrypt' + ? 'public' + : 'private' + : KeyType, +>( + jwk: TKeyData, + algorithm: TAlgorithm, + extractable: TExtractable, + keyUsages: TUsages, +): Promise> { + const key = await subtle.importKey('jwk', jwk, algorithm, extractable, keyUsages); + return key as IKey; +} + +export async function importRaw< + const TAlgorithm extends KeyAlgorithm = KeyAlgorithm, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>( + rawKey: BufferSource, + algorithm: TAlgorithm, + extractable: TExtractable, + keyUsages: TUsages, +): Promise> { + const key = await subtle.importKey('raw', rawKey, algorithm, extractable, keyUsages); + return key as IKey; +} diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index ff4f87e50dc..83ae663e233 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -1,156 +1,53 @@ -import { Random } from '@rocket.chat/random'; -import ByteBuffer from 'bytebuffer'; - -export function toString(thing: any) { - if (typeof thing === 'string') { - return thing; - } - - return ByteBuffer.wrap(thing).toString('binary'); +export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { + return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } -export function toArrayBuffer(thing: any) { - if (thing === undefined) { - return undefined; - } - if (typeof thing === 'object') { - if (Object.getPrototypeOf(thing) === ArrayBuffer.prototype) { - return thing; - } - } - - if (typeof thing !== 'string') { - throw new Error(`Tried to convert a non-string of type ${typeof thing} to an array buffer`); - } - - return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); -} - -export function joinVectorAndEcryptedData(vector: any, encryptedData: any) { - const cipherText = new Uint8Array(encryptedData); - const output = new Uint8Array(vector.length + cipherText.length); - output.set(vector, 0); - output.set(cipherText, vector.length); - return output; -} - -export function splitVectorAndEcryptedData(cipherText: any) { - const vector = cipherText.slice(0, 16); - const encryptedData = cipherText.slice(16); - - return [vector, encryptedData]; -} - -export async function encryptRSA(key: any, data: any) { - return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); -} - -export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { - return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); -} - -export async function encryptAESCTR(vector: any, key: any, data: any) { - return crypto.subtle.encrypt({ name: 'AES-CTR', counter: vector, length: 64 }, key, data); -} - -export async function decryptRSA(key: CryptoKey, data: Uint8Array) { - return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); -} - -export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { - return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); -} - -export async function generateAESKey() { - return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); -} - -export async function generateAESCTRKey() { +export function generateAESCTRKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } -export async function generateRSAKey() { - return crypto.subtle.generateKey( - { - name: 'RSA-OAEP', - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: { name: 'SHA-256' }, - }, - true, - ['encrypt', 'decrypt'], - ); -} +/** + * Generates 12 uniformly random words from the word list. + * + * @remarks + * Uses {@link https://en.wikipedia.org/wiki/Rejection_sampling | rejection sampling} to ensure uniform distribution. + * + * @returns A space-separated passphrase. + */ +export async function generatePassphrase() { + const { wordlist } = await import('./wordList'); -export async function exportJWKKey(key: any) { - return crypto.subtle.exportKey('jwk', key); -} + // Number of words in the passphrase + const WORD_COUNT = 12; + // We use 32-bit random numbers, so the maximum value is 2^32 - 1 + const MAX_UINT32 = 0xffffffff; -export async function importRSAKey(keyData: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - return crypto.subtle.importKey( - 'jwk' as any, - keyData, - { - name: 'RSA-OAEP', - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: { name: 'SHA-256' }, - } as any, - true, - keyUsages, - ); -} + const range = wordlist.length; + const rejectionThreshold = Math.floor(MAX_UINT32 / range) * range; -export async function importAESKey(keyData: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages); -} + const words: string[] = []; + const buf = new Uint32Array(1); -export async function importRawKey(keyData: any, keyUsages: ReadonlyArray = ['deriveKey']) { - return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); -} - -export async function deriveKey(salt: any, baseKey: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - const iterations = 1000; - const hash = 'SHA-256'; - - return crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations, hash }, baseKey, { name: 'AES-CBC', length: 256 }, true, keyUsages); -} - -export async function readFileAsArrayBuffer(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (evt) => { - resolve(evt.target?.result); - }; - reader.onerror = (evt) => { - reject(evt); - }; - reader.readAsArrayBuffer(file); - }); -} - -export async function generateMnemonicPhrase(n: any, sep = ' ') { - const { default: wordList } = await import('./wordList'); - const result = new Array(n); - let len = wordList.length; - const taken = new Array(len); - - while (n--) { - const x = Math.floor(Random.fraction() * len); - result[n] = wordList[x in taken ? taken[x] : x]; - taken[x] = --len in taken ? taken[len] : len; + for (let i = 0; i < WORD_COUNT; i++) { + let v: number; + do { + crypto.getRandomValues(buf); + v = buf[0]; + } while (v >= rejectionThreshold); + words.push(wordlist[v % range]); } - return result.join(sep); + + return words.join(' '); } -export async function createSha256HashFromText(data: any) { +export async function createSha256HashFromText(data: string) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } -export async function sha256HashFromArrayBuffer(arrayBuffer: any) { +export async function sha256HashFromArrayBuffer(arrayBuffer: ArrayBuffer) { const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer))); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts new file mode 100644 index 00000000000..4bc9753290b --- /dev/null +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -0,0 +1,57 @@ +import { Keychain } from './keychain'; + +describe('Keychain', () => { + /** + * Calculates the length of a base64-encoded string given the length of the original byte array. + * + * @param byteLength The length of the byte array to be encoded. + * @returns The length of the resulting base64-encoded string. + */ + const base64Length = (byteLength: number) => { + return ((4 * byteLength) / 3 + 3) & ~3; + }; + + const pwdv1web = { + userId: 'userE2EE', + persisted: + '{"$binary":"JtJhD4JHyNS5dnf5+qj1IxbEGa6bmxlgvX6r0+LhvbnD15KmyTN/HBi0qip4GYJaolVhk7Y5YuB5qT/cRxKv6+2Qe+4Rc6DorInx1+V2tdI2JWkQoVi91GpN7ezy8ghOJ5nbsPn4xkv8jVZU2itAIAuqurKvL1hPUMsGsSnxazC6zEsInv0R/9Y2sdtm5L6ISSjo6iIqUaUEPw0gqt6y54ZSKOdTtI7xlpzJXrNdirrERiXDBj8mbzH2JG9OqfXHZWxm+NtaYLnDuwhN8M9TFj72blBU4c6z4TSb2Gc5aoAkQ9t0tlGZXbjxoa0Y0OT5UqQYVWuqDHPNyzl9ZBz6ZQ735laQZofp5roaH1g2tD9AuBO6iTvbuhwuBGdsbFj79FE5dXXKfcH44enNqL0EYGB81z1+4wuWjhyjnEuC9KXJQNqiCmftO0JQzzEqrrnYpOcFItsJPJ2TNbP2yUuITJzewjkqq4k2tpra/vblNpOnU1SkfT4PzPRk4oarUOQ9w6x8eHpXGXkck22sdPmAKNJTv7YjLSL2yLdGWKV8uxFLDHfagMPQf8e9yK3BknsJfYAU2g3PvwZIBQcrEGPkG75RvTbzFVyjPZPjDcNjvM5X1LiRTEtP9ygkGOhO+Gm+I44l9B/9Lu/mWEtonKhq5xl/aF02vutON1NKVDfmNa2mQ7HwxysKsjRAIyMBfwmVC+ave5V153iQH/3zGSx1HWaNps4IMS6/G7PsOFExTvN8ebSrQBbAdMRWoroOorW8pCIacIII5r42gCJm0ph2hcbQOL8GHbwVhc0pHhuoO7O3575YO4AsmsFueurqqqcVQY9Hbj6L5rL92nU5gphHEhb9kRJN70j6rv0mdlgXOvrXnZ4ZjIiTY+WS3kN9ecKOjVy/E7k9I3BJvPw3E6xt4cEWZsvLOdRD39ENNmUjGdRZHlU6TLleOx/cpzAhCnacyow/mG9Oijumhr8JY9PnnHJiTLBjxbtGbVoOTc4BFqYloZr2i22Luvi7p6Qf3XHtkMo02u3aaYL8u2GniIo4k3swsJzQfuYzC5SJ61i7+tzq9t/LxB1t8JDVUlRhMI20KGPauz/JJOo5ss8gkDi2y/egV3aV1/LUTzg3NWL9sXNtBaVvc2RrAOVn4pjW4BWoy0rKbT2ELfm9jpi0deUt30YwJO4g/zqutWehjj0htJ3Bbha1nzN3JZmRDONawDhao3QzYxwHHmc2VoHtUYWr150FTV7DCYVZ9uhmSxaCBvihe3Q8YSzLi+YS5EF3CgXmAUoE+hKsQk0C0drvO6nmxJTiIplxpOiscNSY0TL6crTzRGCmSLnvHOa/mcnm+8XBB/k1I1tCKjntQsjJMwlZVKP1rhvFNyIJrrnBTRZdrax9O2oBvmSH4drfqgLDmKh+mkO+9NQt3pCTCLZGWFvB3jdk27gSVNC/12uFfZof84uzlPWLN5zyzFZzIn6M5goAL+O910Jk2u7scNSHXejFC0LtYQepFMvpzKF9BY2i1We8hj3f8iDxamQpgA7/Ohl73tJfLZ1ByLlfdCLSdbO2gBYYFxOtt/GzAxfrNMa8nOKV5Vb3FBdA4IkfhQ2QrDKkJL+aBcssOmqNwUcL3C2VA11EWRenKEpwY/daxh8Kfw6nBnV6SWa/HnwKHxoNOYJVYmagh++Y/7ZYkv2uRKBP+DtVa697Yft5nYr82YjlrQA7nBGR5SUtsD/uIjDxRpMrXuFnGg3hsPo1HvTLFFUWeJH50JmAuDCA9tYmOrUVMkP+9g/lprkeHMaIsdrAXMvADeHtSIZzqqvIWUIuyLMbiMJalmsjhObbwo8gt9MNEosQT8dsExxQ4rkf2LhmOtYU7EH6wAwjPWgGUlJ/VDzmhxXQTfdO2yPOV5FfkvqAm3xJsqnf2INuPfwrOn4RWrw2qR36rNfcOGwRI8sloUpV54gkh7pszWZCxYqsbZsnnqeUREIJBqsMxP0tx7cRzC3Rrjc7+9Y5gmiRdPjHIZbTFie/vx8kR9X77y+p3Dzirq9qjACAW+08Z5MdQ2UVxL2lg6niGuq9wIa/TnMVbMPXY76Og8waBjZf6GcI+1DATPRdm9aQLK4Bb/divsOzzW1jyw7toQSfgk0cDpYqIetuixQf5+7f3WX3GXTistduUkSihA6+9HpkEnCykT4uGq1Ek+Tm1K0IkH8pOYc/kwqLqgl5AwrhoAsYwSVOgT2UxM+/pbBpRo9MfJv64OjuBXSKchv+NMcyssMZB674jdGceA=="}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}', + passphrase: 'minus mobile dexter forest elvis', + }; + + test('decrypt v1 private key', async () => { + const keychain = new Keychain(pwdv1web.userId); + const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase); + expect(decrypted).toBe(pwdv1web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase); + expect(encrypted.iv).toHaveLength(base64Length(12)); + }); + + const pwdv2web: typeof pwdv1web = { + userId: 'BFqA4FAHKX4qEywfJ', + persisted: + '{"iv":"vO0oF7jr9jGCuBqJ","ciphertext":"b6e8YDZWn+aHUTJOvs7HSPGv9TvXB/s5Dc8fVzJYGdqaoSDKC5s6cIi6TNeSPblJXYQ2dkdLjgv/VzF6q6/E3/y5px90uHzYXDw92QGwjAHW9jsekSKoxnszMD64iB8O2RCFQPepmx15Upuo8NdnyGno+sbp9G4DkKcB2XrAw53V9BVpJixGc+MNWuRe+TyW49CkDL9kdukPPFYzi5p0qtgUoQ1IiqTUYWU1vVbYia76xEKuHrG+xjdXpXeLLu6FewO1xCWUli32ydQHoyty2WDV3ITfddUPIiPSNjspJXnp7cBKuabap4WuwLWfonv25PjZWICYF4l10Q1gESOmR7sOUBrKVBTQvZfPpZmSHhfMfqeDzuODyNTDAvBHavlIcR11xzIHiy/dofTUL8O+ctiQC+P0i3cN7/hrDXfb+mJHtrKGEO9beSbpYsar4nwaWI4irwFgbg6Ltd2spf7W00KFoQEhTfpZum86wcgT0vxaQRbeH5zHvWgPH8aWvc4NmXV/Tcnf9P5rd/mbSl8F6jk2O7ygkZdkDfmUAxOQ9tjfz16KV9zm+Tlx5V276/a2wfiZ73LpNwWA6DS8+3ncN2nhFsjn0Ma+I4gyrRm1jnr7NUsLg+E+A7cEaN60JEf5IOcFmJ94emh945qv3gpQyUYsCnNR/wBSc3bgr/qC9vK3jKObfYKygt5KaMoLE9YBXqyaZ5Sht63d6sIq8GJis01eUx3+90QyOYKulL0nCiSLREZyyv5LPDbVrcJIV63CuRibbVtCyrQAKRqxm8lyw3gCKNRgNDxTWMnPJd4BN4orq1rI1PDM2zADoSjQEEkf9wo/qaHey1sNufOT8pDu/J9+hKlrwyVFc+x/ApGBxQjLK6NPKDBCJvuqwDu17EHxRlfber/KyyJjK/ymo0xLsXgvjxci4tCrtnaxuqeLXP8W5Bm7akvRKjJj1mY9pztP6vIdgwSRNnPQMRrz36s8czd0e0ba/CpUnWfTUfVE3/MgmWry11KmnyAPDs/jekgmKRKVzkyxQxl+vPjgA3rAlEof2UMqmMaIFQOEbIdFRiy9PddPAVDwDZUHpPo8bFWa22P8g9f0F8d9rLwxUoWfIRB2jqwBea553lvwjt+HXYx1sAUqG2cXagXypi4nT2dRrooDoyeZxrOmtEX4oAIs9yeeQzPRdUlJMpYLnhAv9cjuouEN6RXUuyILUb6fTn2EbE/B147SojFwCMUfp4IC6cPjpwi/O7v0cC9OjcE9/YzWYTUGq2SC5CYZ6TQ9SVwmidcAq8lD4O0gLeYd1lKis3W42q9mIScxKf9l49g6v94kecvoWQ2p5WiUItwnHoTGNBZHH0h9fQbDVorZ7gS6/ZdhoDWUHOjDwN/yP8y3BV2OBGgw0RXhvM13W+G9g/Zj4aQ44T0alCiA34miELY5paznFdjzkk7wo7RIVRllESLf1xvbPRHDlGG5RYs1z1ueGBVGtkC826yTsN6nFdaxRd2Ntvgmbmpn3AiEoLxrAK4YbJ23X30W6iz5gen8j9O2N79vE+jvTcgBtP/38J0Lyy0OPQfH/VbrcEzrMdsPbyU87tORrhsUFg+NX1H0aVfTgLRM0h1kCYAtr2HpfIo4QCRHT8ar12aOKb69q8gNCdMf74S0krKIxiRAB8VgVMc4xCJQzjizOsosEj+sI6hMtrepjecf0guoGR5+qeYZ/WfREHLK9RRRzOXQntKdREt7dMJtYYlRZ50hT++tWoA6w4S0AyqCV7Q37e/YQtadkOkqwPo0F/TE2vuqmeRa14ZiZRh7ilMWv2cZ4AUGAWEtPkVs8lWlS5WvuRz0Jr3Qy5QnllPzSkzCotPA1+Ecap05SCnAgwG/RUt3UaUIU7+FDL/UHcmMX0AK4ec1MG98C6s31fmCgSTilEsKOj9zCMCGYfmp+MnX9YSqqrFdA8E2OLjscOkh17/FliGzx16QcAglTbU51DDhU8e0n/Kc6AHkylvmydfEm/PP23jGCO8hjaafWLcK6NRNxmK2EsHIut6sb0eUHwDBGYTdJPXvN961j41OIG/Piy0qj+329V9wkrjxGqklcHCVYHCyJX4/PLo3ieBPb4gf2xApFuaxGjsnufe1QY9jitSo8SPGBmcFfZOWdNgtEHK3HfrrSH30H4aMI9wRcbni36RjeRo9LjRgcPFeTljLo44323EjWk1UJ4P+h+zz5LLjXEFDKLPUyFe+AA/blisN1XRbwpvmAWBPVrZuZ4s=","salt":"v2:BFqA4FAHKX4qEywfJ:5b36c4cc-4e7a-497d-b4b0-a1df863c65c4","iterations":100000}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"BfaLapcFxwaKxDjeSCR4wb6H5xF5QCw36QvqX8JvqUX_NA4xFtn6RqgdVhMXep3DW641cA1xb8oIxRGt2YL1kb0gN97fdINtOMXSumebZODtYf5EZ-inJtIdDXcy2MjsSMZIqUwuQ5gRq90SxSpKE9eRrEfuuh5nxF5BWwNp65ZW4rCsEiInmnrFW4ERtyTHJ1catbJ_lj71lcVzC-1St6jeXcdinG2FtH0hJ4_ijzp6sAqm1xC9XMhF2g4tZeyvVg9dGBzyLyxq78zNAPJ93ungjc0ITJ27g3IaP7EUX7SxjHasU37j7KOIOGmTswkxIEoVQrlep6xU1RFkPphJNQ","dp":"sJhWI8YfVUr1N5vTr255xJ3Bo84320NWAl9MUhd87XoV3soGO0lmC1bYdrNvIU7wjJ3PxdpSrJ2HQDY5dR088RcmD4J1i3PFJUXVW0A_YkTIt2k8x5m1yF6npS0AgxgwauFxGcE7KgO2vtBn5SHMUOB-gmgUYhDRVB8NdQ3Z550","dq":"jXuFkyialQd264s-RW5adF637uYgh1pnutcQ1wI8HzVAcr3L36xrjQxDYMY6n5uNJJE0I3LyIe9Ez4j_83wiV3nsjFhSj-i7Doiy2k2zHRBqS9ajg933KVZVWD6fN7nr31jF_cdbkzzldkpgxXi3EeM2_0TN7kt1s1kvSeuEoME","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ","p":"4yd_HHhnSAh-eGnF31n0ihyr2M7UBc3m2IDxbQBTohi3qUGD1x5YRlPtx1zpr0vZgoTkDUZ6DPbdXp6QBRku6SOKDrHA0jFiVdqmmVEPGKa1fLVtRIl_kDI-02-F4lvYlTfIQ4qchT64VSvwJnnxUlsuodfbohZi4zYaIMvzXZs","q":"xYTnIC-3Cy31zs0ZC5YTCTYp9KWD__jtcZjBmoqpc_dyhPa-Xzz2ty-6ytP_23mGi2vOB4rZjE9v2gh18l4AzhIrqR9ns4pxYZG4S2O3Si8oHavMJE4mx7VN6Lmgqs3DD45tA7zHbj8hnBg1It2aftWkvzxGx2yQe_r0JomXA7M","qi":"JF4tOm05ianqiNk2jtArg9O4kL8ZjiNgBA97Yu05b_f7k4MMKZqmRihCvzSMpk6w4pqkIdmnSSw19feNCXDv4QHFv4eSsTvBD8g_zmwIkMFyJzeC34J5bgafteZ1RpXsxN5CAAjyhJObEll3CY9B9zZTCLuZDr_616RWDvK1_w4"}', + passphrase: 'prize fluid crystal small jaguar lunar bonus absent destroy settle carbon ignore', + }; + + test('decrypt v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); + }); + + test('roundtrip v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase); + expect(encrypted.iv).toHaveLength(base64Length(12)); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts new file mode 100644 index 00000000000..00918cebe5d --- /dev/null +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -0,0 +1,163 @@ +import { Base64 } from '@rocket.chat/base64'; + +import { Binary } from './binary'; +import type { ICodec } from './codec'; +import * as Pbkdf2 from './crypto/pbkdf2'; +import { randomUUID } from './crypto/shared'; + +/** + * Version 1 format: + * ``` + * json({ $binary: base64(iv[16] + ciphertext) }) + * ``` + */ +interface IStoredKeyV1 { + /** + * Base64-encoded binary data + * - first 16 bytes are the IV + * - remaining bytes are the ciphertext + */ + $binary: string; +} +/** + * Version 2 format: + * ```typescript + * json({ iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() }) + * ``` + */ +interface IStoredKeyV2 { + iv: string; + ciphertext: string; + salt: string; + iterations: number; +} + +type StoredKey = IStoredKeyV1 | IStoredKeyV2; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +const StoredKey: ICodec = { + decode: (data) => { + const json: unknown = JSON.parse(data); + + if (typeof json !== 'object' || json === null) { + throw new TypeError('Invalid private key format'); + } + + if ('$binary' in json && typeof json.$binary === 'string') { + return { $binary: json.$binary } satisfies IStoredKeyV1; + } + + if ( + 'iv' in json && + typeof json.iv === 'string' && + 'ciphertext' in json && + typeof json.ciphertext === 'string' && + 'salt' in json && + typeof json.salt === 'string' && + 'iterations' in json && + typeof json.iterations === 'number' + ) { + return { iv: json.iv, ciphertext: json.ciphertext, salt: json.salt, iterations: json.iterations } satisfies IStoredKeyV2; + } + + throw new TypeError('Invalid private key format'); + }, + encode: (data) => JSON.stringify(data), +}; + +type EncryptedKeyContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +type EncryptedKeyOptions = { + salt: string; + iterations: number; +}; + +type EncryptedKey = { + content: EncryptedKeyContent; + options: EncryptedKeyOptions; +}; + +class EncryptedKeyCodec implements ICodec { + userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + encode(encryptedKey: EncryptedKey): IStoredKeyV2 { + return { + iv: Base64.encode(encryptedKey.content.iv), + ciphertext: Base64.encode(encryptedKey.content.ciphertext), + salt: encryptedKey.options.salt, + iterations: encryptedKey.options.iterations, + }; + } + + decode(storedKey: string): EncryptedKey { + const storedKeyObj = StoredKey.decode(storedKey); + if ('$binary' in storedKeyObj) { + // v1 + const binary = Base64.decode(storedKeyObj.$binary); + + return { + content: { iv: binary.slice(0, 16), ciphertext: binary.slice(16) }, + options: { + salt: this.userId, + iterations: 1000, + }, + }; + } + + // v2 + const { iv, ciphertext, salt, iterations } = storedKeyObj; + return { + content: { + iv: Base64.decode(iv), + ciphertext: Base64.decode(ciphertext), + }, + options: { + salt, + iterations, + }, + }; + } +} + +export class Keychain { + private readonly userId: string; + + private readonly codec: EncryptedKeyCodec; + + constructor(userId: string) { + this.userId = userId; + this.codec = new EncryptedKeyCodec(userId); + } + + async decryptKey(privateKey: string, password: string): Promise { + const { content, options } = this.codec.decode(privateKey); + const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM'; + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); + const derivedBits = await Pbkdf2.derive(baseKey, { + salt: new Uint8Array(Binary.decode(options.salt)), + iterations: options.iterations, + }); + const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 }); + const decrypted = await Pbkdf2.decrypt(key, content); + return Binary.encode(decrypted.buffer); + } + + async encryptKey(privateKey: string, password: string): Promise { + const salt = `v2:${this.userId}:${randomUUID()}`; + const iterations = 100_000; + const algorithm = 'AES-GCM'; + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); + const derivedBits = await Pbkdf2.derive(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations }); + const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 }); + const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.decode(privateKey))); + + return this.codec.encode({ content, options: { salt, iterations } }); + } +} diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 5ef4e4b02bb..59190b5340b 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -10,10 +10,91 @@ const isDebugEnabled = (): boolean => { return debug; }; -export const log = (context: string, ...msg: unknown[]): void => { - isDebugEnabled() && console.log(`[${context}]`, ...msg); +const noopSpan: ISpan = { + set(_key: string, _value: unknown) { + return this; + }, + info(_message: string) { + /**/ + }, + warn(_message: string) { + /**/ + }, + error(_message: string, _error?: unknown) { + /**/ + }, }; -export const logError = (context: string, ...msg: unknown[]): void => { - isDebugEnabled() && console.error(`[${context}]`, ...msg); +class Logger { + title: string; + + constructor(title: string) { + this.title = title; + } + + span(label: string): ISpan { + return isDebugEnabled() ? new Span(new WeakRef(this), label, console) : noopSpan; + } +} + +interface ISpan { + set(key: string, value: unknown): this; + info(message: string): void; + warn(message: string): void; + error(message: string, error?: unknown): void; +} + +type LogLevel = 'info' | 'warn' | 'error'; + +const styles: Record = { + info: 'font-weight: bold;', + warn: 'color: black; background-color: yellow; font-weight: bold;', + error: 'color: white; background-color: red; font-weight: bold;', +}; + +class Span { + private logger: WeakRef; + + private label: string; + + private attributes = new Map(); + + private console: Console; + + constructor(logger: WeakRef, label: string, console: Console) { + this.logger = logger; + this.label = label; + this.console = console; + } + + private log(level: LogLevel, message: string) { + this.console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'font-weight: normal;'); + this.console.dir(Object.fromEntries(this.attributes.entries()), {}); + this.console.trace(); + this.console.groupEnd(); + } + + set(key: string, value: unknown) { + this.attributes.set(key, value); + return this; + } + + info(message: string) { + this.log('info', message); + } + + warn(message: string) { + this.log('warn', message); + } + + error(message: string, error?: unknown) { + if (error) { + this.set('error', error); + } + this.log('error', message); + } +} + +export const createLogger = (title: string) => { + return new Logger(title); }; diff --git a/apps/meteor/client/lib/e2ee/prefixed.spec.ts b/apps/meteor/client/lib/e2ee/prefixed.spec.ts new file mode 100644 index 00000000000..89c3bfcd257 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/prefixed.spec.ts @@ -0,0 +1,39 @@ +import { Base64 } from '@rocket.chat/base64'; + +import { PrefixedBase64 } from './prefixed'; + +const prefix = '32c9e7917b78'; +const base64 = + 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; +const prefixed = `${prefix}${base64}`; + +describe('PrefixedBase64', () => { + it('should roundtrip', () => { + const [kid, decodedKey] = PrefixedBase64.decode(prefixed); + expect(kid).toBe(prefix); + const reencoded = PrefixedBase64.encode([kid, decodedKey]); + expect(reencoded).toBe(prefixed); + }); + + it('should throw on invalid decode input length', () => { + expect(() => PrefixedBase64.decode('too-short')).toThrow(RangeError); + }); + + it('should throw on invalid decoded data length', () => { + const invalidPrefixed = `32c9e7917b78${'A'.repeat(343)}`; // One character short + expect(() => PrefixedBase64.decode(invalidPrefixed)).toThrow(RangeError); + }); + + it('should throw on invalid encode input data length', () => { + const invalidData = new Uint8Array(255); // One byte short + expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError); + }); + + it('should throw on invalid encoded Base64 length', () => { + // This is a bit contrived since our encode implementation always produces valid length, + // but we include it for completeness. + const invalidData = new Uint8Array(256); + jest.spyOn(Base64, 'encode').mockReturnValue('A'.repeat(343)); // One character short + expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/prefixed.ts b/apps/meteor/client/lib/e2ee/prefixed.ts new file mode 100644 index 00000000000..b80d5d8dfe1 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/prefixed.ts @@ -0,0 +1,55 @@ +import { Base64 } from '@rocket.chat/base64'; + +import type { ICodec } from './codec'; + +// A 256-byte array always encodes to 344 characters in Base64. +const DECODED_LENGTH = 256; +// ((4 * 256 / 3) + 3) & ~3 = 344 +const ENCODED_LENGTH = 344; + +/** + * A codec for strings formatted as "prefix + base64(data)", where: + * - `prefix` is an arbitrary-length string + * - `data` is a 256-byte Uint8Array encoded in Base64 + * - the total length of the Base64-encoded data is always 344 characters + * This is used for encoding/decoding E2EE keys with a key ID prefix. + */ +export const PrefixedBase64: ICodec]> = { + decode: (input) => { + // 1. Validate the input string length + if (input.length < ENCODED_LENGTH) { + throw new RangeError('Invalid input length.'); + } + + // 2. Split the string into its two parts + const prefix = input.slice(0, -ENCODED_LENGTH); + const base64Data = input.slice(-ENCODED_LENGTH); + + // 3. Decode the Base64 string. atob() decodes to a "binary string". + const bytes = Base64.decode(base64Data); + + if (bytes.length !== DECODED_LENGTH) { + // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. + throw new RangeError('Decoded data length is too short.'); + } + + return [prefix, bytes]; + }, + encode: ([prefix, data]) => { + // 1. Validate the input data length + if (data.length !== DECODED_LENGTH) { + throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); + } + + // 2. Convert the byte array (Uint8Array) into a Base64 string + const base64 = Base64.encode(data); + + if (base64.length !== ENCODED_LENGTH) { + // This is a sanity check in case something went wrong during encoding. + throw new RangeError(`Encoded Base64 length is ${base64.length}, but expected ${ENCODED_LENGTH} characters.`); + } + + // 3. Concatenate the prefix and the Base64 string + return prefix + base64; + }, +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index ac97caba139..f5350573839 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,57 +1,47 @@ import { Base64 } from '@rocket.chat/base64'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, AtLeast, EncryptedMessageContent } from '@rocket.chat/core-typings'; +import type { + IE2EEMessage, + IMessage, + IRoom, + ISubscription, + IUser, + AtLeast, + EncryptedMessageContent, + EncryptedContent, +} from '@rocket.chat/core-typings'; import { isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; -import { E2ERoomState } from './E2ERoomState'; -import { - toString, - toArrayBuffer, - joinVectorAndEcryptedData, - splitVectorAndEcryptedData, - encryptRSA, - encryptAES, - decryptRSA, - decryptAES, - generateAESKey, - exportJWKKey, - importAESKey, - importRSAKey, - readFileAsArrayBuffer, - encryptAESCTR, - generateAESCTRKey, - sha256HashFromArrayBuffer, - createSha256HashFromText, -} from './helper'; -import { log, logError } from './logger'; +import type { E2ERoomState } from './E2ERoomState'; +import { Binary } from './binary'; +import { decodeEncryptedContent } from './content'; +import * as Aes from './crypto/aes'; +import * as Rsa from './crypto/rsa'; +import { encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText } from './helper'; +import { createLogger } from './logger'; +import { PrefixedBase64 } from './prefixed'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { Messages, Rooms, Subscriptions } from '../../stores'; -import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; +const log = createLogger('E2E:Room'); + const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { [k in keyof typeof E2ERoomState]?: (keyof typeof E2ERoomState)[] }; +type Mutations = { [k in E2ERoomState]?: E2ERoomState[] }; const permitedMutations: Mutations = { - [E2ERoomState.NOT_STARTED]: [E2ERoomState.ESTABLISHING, E2ERoomState.DISABLED, E2ERoomState.KEYS_RECEIVED], - [E2ERoomState.READY]: [E2ERoomState.DISABLED, E2ERoomState.CREATING_KEYS, E2ERoomState.WAITING_KEYS], - [E2ERoomState.ERROR]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.NOT_STARTED], - [E2ERoomState.WAITING_KEYS]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.ERROR, E2ERoomState.DISABLED], - [E2ERoomState.ESTABLISHING]: [ - E2ERoomState.READY, - E2ERoomState.KEYS_RECEIVED, - E2ERoomState.ERROR, - E2ERoomState.DISABLED, - E2ERoomState.WAITING_KEYS, - E2ERoomState.CREATING_KEYS, - ], + NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'], + READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], + ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'], + WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], + ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], }; const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { @@ -61,7 +51,7 @@ const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERo } if (currentState === nextState) { - return nextState === E2ERoomState.ERROR ? E2ERoomState.ERROR : false; + return nextState === 'ERROR' ? 'ERROR' : false; } if (!(currentState in permitedMutations)) { @@ -90,13 +80,13 @@ export class E2ERoom extends Emitter { roomKeyId: string | undefined; - groupSessionKey: CryptoKey | undefined; + groupSessionKey: Aes.Key | null = null; - oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; + oldKeys: { E2EKey: Aes.Key | null; ts: Date; e2eKeyId: string }[] | undefined; sessionKeyExportedString: string | undefined; - sessionKeyExported: JsonWebKey | undefined; + sessionKeyExported: Aes.Jwk | undefined; constructor(userId: string, room: IRoom) { super(); @@ -106,27 +96,14 @@ export class E2ERoom extends Emitter { this.typeOfRoom = room.t; this.roomKeyId = room.e2eKeyId; - this.once(E2ERoomState.READY, async () => { + this.once('READY', async () => { await this.decryptOldRoomKeys(); return this.decryptPendingMessages(); }); - this.once(E2ERoomState.READY, () => this.decryptSubscription()); - this.on('STATE_CHANGED', (prev) => { - if (this.roomId === RoomManager.opened) { - this.log(`[PREV: ${prev}]`, 'State CHANGED'); - } - }); + this.once('READY', () => this.decryptSubscription()); this.on('STATE_CHANGED', () => this.handshake()); - this.setState(E2ERoomState.NOT_STARTED); - } - - log(...msg: unknown[]) { - log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); - } - - error(...msg: unknown[]) { - logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + this.setState('NOT_STARTED'); } hasSessionKey() { @@ -138,54 +115,53 @@ export class E2ERoom extends Emitter { } setState(requestedState: E2ERoomState) { + const span = log.span('setState'); const currentState = this.state; const nextState = filterMutation(currentState, requestedState); if (!nextState) { - this.error(`invalid state ${currentState} -> ${requestedState}`); + span.error(`${currentState} -> ${requestedState}`); return; } this.state = nextState; - this.log(currentState, '->', nextState); + span.info(`${currentState} -> ${nextState}`); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } isReady() { - return this.state === E2ERoomState.READY; + return this.state === 'READY'; } isDisabled() { - return this.state === E2ERoomState.DISABLED; + return this.state === 'DISABLED'; } enable() { - if (this.state === E2ERoomState.READY) { + if (this.state === 'READY') { return; } - this.setState(E2ERoomState.READY); + this.setState('READY'); } disable() { - this.setState(E2ERoomState.DISABLED); + this.setState('DISABLED'); } pause() { - this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - this.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } keyReceived() { - this.setState(E2ERoomState.KEYS_RECEIVED); + this.setState('KEYS_RECEIVED'); } async shouldConvertSentMessages(message: { msg: string }) { @@ -211,7 +187,7 @@ export class E2ERoom extends Emitter { } isWaitingKeys() { - return this.state === E2ERoomState.WAITING_KEYS; + return this.state === 'WAITING_KEYS'; } get keyID() { @@ -223,31 +199,45 @@ export class E2ERoom extends Emitter { } async decryptSubscription() { + const span = log.span('decryptSubscription'); const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); - if (subscription?.lastMessage?.t !== 'e2e') { - this.log('decryptSubscriptions nothing to do'); + if (!subscription) { + span.warn('no subscription found'); return; } - const message = await this.decryptMessage(subscription.lastMessage); + const { lastMessage } = subscription; - if (message !== subscription.lastMessage) { - this.log('decryptSubscriptions updating lastMessage'); + if (lastMessage === undefined) { + span.warn('no lastMessage found'); + return; + } + + if (lastMessage.t !== 'e2e') { + span.warn('nothing to do'); + return; + } + + const message = await this.decryptMessage(lastMessage); + + if (message.msg !== subscription.lastMessage?.msg) { + span.info('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - this.log('decryptSubscriptions Done'); + span.info('decryptSubscriptions Done'); } async decryptOldRoomKeys() { + const span = log.span('decryptOldRoomKeys'); const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - this.log('decryptOldRoomKeys nothing to do'); + span.info('Nothing to do'); return; } @@ -259,22 +249,22 @@ export class E2ERoom extends Emitter { ...key, E2EKey: k, }); - } catch (e) { - this.error( + } catch (error) { + span.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + error, ); keys.push({ ...key, E2EKey: null }); } } this.oldKeys = keys; - this.log('decryptOldRoomKeys Done'); } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - this.log('exportOldRoomKeys starting'); + const span = log.span('exportOldRoomKeys').set('oldKeys', oldKeys); if (!oldKeys || oldKeys.length === 0) { - this.log('exportOldRoomKeys nothing to do'); + span.info('Nothing to do'); return; } @@ -291,63 +281,68 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - this.error( - `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + span.set('error', e); + span.error( + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping.`, ); } } - this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + span.info(`Done: ${keys.length} keys exported`); return keys; } async decryptPendingMessages() { await Messages.state.updateAsync( (record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending', - (record) => this.decryptMessage(record), + (record) => this.decryptMessage(record as IE2EEMessage), ); } // Initiates E2E Encryption async handshake() { + const span = log.span('handshake'); if (!e2e.isReady()) { return; } - if (this.state !== E2ERoomState.KEYS_RECEIVED && this.state !== E2ERoomState.NOT_STARTED) { + if (this.state !== 'KEYS_RECEIVED' && this.state !== 'NOT_STARTED') { return; } - this.setState(E2ERoomState.ESTABLISHING); + this.setState('ESTABLISHING'); try { const groupKey = Subscriptions.state.find((record) => record.rid === this.roomId)?.E2EKey; if (groupKey) { await this.importGroupKey(groupKey); - this.setState(E2ERoomState.READY); + this.setState('READY'); + span.info('Group key imported'); return; } } catch (error) { - this.setState(E2ERoomState.ERROR); - this.error('Error fetching group key: ', error); + this.setState('ERROR'); + span.error('Error fetching group key', error); return; } try { const room = Rooms.state.get(this.roomId); + span.set('room', room); if (!room?.e2eKeyId) { - this.setState(E2ERoomState.CREATING_KEYS); + this.setState('CREATING_KEYS'); await this.createGroupKey(); - this.setState(E2ERoomState.READY); + this.setState('READY'); return; } - this.setState(E2ERoomState.WAITING_KEYS); - this.log('Requesting room key'); + this.setState('WAITING_KEYS'); + span.info('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - // this.error = error; - this.setState(E2ERoomState.ERROR); + span.set('error', error); + span.error('Error during handshake'); + this.setState('ERROR'); } } @@ -356,53 +351,62 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - return importAESKey(JSON.parse(await this.exportSessionKey(key))); + return Aes.importKey(JSON.parse(await this.exportSessionKey(key))); } async exportSessionKey(key: string) { - key = key.slice(12); - const decodedKey = Base64.decode(key); + const span = log.span('exportSessionKey'); + const [prefix, decodedKey] = PrefixedBase64.decode(key); + + span.set('prefix', prefix); if (!e2e.privateKey) { + span.error('Private key not found'); throw new Error('Private key not found'); } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); - return toString(decryptedKey); + const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); + span.info('Key decrypted'); + return Binary.encode(decryptedKey.buffer); } async importGroupKey(groupKey: string) { - this.log('Importing room key ->', this.roomId); - // Get existing group key - // const keyID = groupKey.slice(0, 12); - groupKey = groupKey.slice(12); - const decodedGroupKey = Base64.decode(groupKey); + const span = log.span('importGroupKey'); + const [kid, decodedKey] = PrefixedBase64.decode(groupKey); + + span.set('kid', kid); + + if (this.keyID === kid && this.groupSessionKey) { + span.info('Key already imported'); + return true; + } // Decrypt obtained encrypted session key try { if (!e2e.privateKey) { throw new Error('Private key not found'); } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); - this.sessionKeyExportedString = toString(decryptedKey); + const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); + this.sessionKeyExportedString = Binary.encode(decryptedKey.buffer); } catch (error) { - this.error('Error decrypting group key: ', error); + span.set('error', error).error('Error decrypting group key'); return false; } // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = this.roomKeyId || kid || crypto.randomUUID(); } // Import session key for use. try { - const key = await importAESKey(JSON.parse(this.sessionKeyExportedString!)); + const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString!)); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; + span.info('Group key imported'); } catch (error) { - this.error('Error importing group key: ', error); + span.set('error', error).error('Error importing group key'); return false; } @@ -410,68 +414,70 @@ export class E2ERoom extends Emitter { } async createNewGroupKey() { - this.groupSessionKey = await generateAESKey(); - - const sessionKeyExported = await exportJWKKey(this.groupSessionKey); + this.groupSessionKey = await Aes.generate(); + const sessionKeyExported = await Aes.exportJwk(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); - this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = crypto.randomUUID(); } async createGroupKey() { - this.log('Creating room key'); - try { - await this.createNewGroupKey(); + await this.createNewGroupKey(); - await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); - if (myKey) { - await sdk.rest.post('/v1/e2e.updateGroupKey', { - rid: this.roomId, - uid: this.userId, - key: myKey, - }); - await this.encryptKeyForOtherParticipants(); - } - } catch (error) { - this.error('Error exporting group key: ', error); - throw error; + await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); + const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); + if (myKey) { + await sdk.rest.post('/v1/e2e.updateGroupKey', { + rid: this.roomId, + uid: this.userId, + key: myKey, + }); + await this.encryptKeyForOtherParticipants(); } } async resetRoomKey() { - this.log('Resetting room key'); + const span = log.span('resetRoomKey'); + if (!e2e.publicKey) { - this.error('Cannot reset room key. No public key found.'); + span.error('No public key found'); return; } - this.setState(E2ERoomState.CREATING_KEYS); + this.setState('CREATING_KEYS'); try { await this.createNewGroupKey(); const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; - this.setState(E2ERoomState.READY); - this.log(`Room key reset done for room ${this.roomId}`); + this.setState('READY'); + span.set('kid', this.keyID).info('Room key reset successfully'); return e2eNewKeys; } catch (error) { - this.error('Error resetting group key: ', error); + span.error('Error resetting room key', error); throw error; } } onRoomKeyReset(keyID: string) { - this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); - this.setState(E2ERoomState.WAITING_KEYS); + const span = log.span('onRoomKeyReset').set('key_id', keyID); + + if (this.keyID === keyID) { + span.warn('Key ID matches current key, nothing to do'); + return; + } + + span.set('new_key_id', keyID).info('Room key has been reset'); + this.setState('WAITING_KEYS'); this.keyID = keyID; - this.groupSessionKey = undefined; + this.groupSessionKey = null; this.sessionKeyExportedString = undefined; this.sessionKeyExported = undefined; this.oldKeys = undefined; } async encryptKeyForOtherParticipants() { + const span = log.span('encryptKeyForOtherParticipants'); // Encrypt generated session key for every user in room and publish to subscription model. try { const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); @@ -479,6 +485,7 @@ export class E2ERoom extends Emitter { const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); if (!users.length) { + span.info('No users to encrypt the key for'); return; } @@ -493,6 +500,7 @@ export class E2ERoom extends Emitter { for await (const user of users) { const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); if (!encryptedGroupKey) { + span.warn(`Could not encrypt group key for user ${user._id}`); return; } if (decryptedOldGroupKeys) { @@ -507,21 +515,23 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return this.error('Error getting room users: ', error); + return span.set('error', error).error('Error getting room users'); } } async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { + const span = log.span('encryptOldKeysForParticipant'); if (!oldRoomKeys || oldRoomKeys.length === 0) { + span.info('Nothing to do'); return; } let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await Rsa.importPublicKey(JSON.parse(publicKey)); } catch (error) { - return this.error('Error importing user key: ', error); + return span.set('error', error).error('Error importing user key'); } try { @@ -530,45 +540,44 @@ export class E2ERoom extends Emitter { if (!oldRoomKey.E2EKey) { continue; } - const encryptedKey = await encryptRSA(userKey, toArrayBuffer(oldRoomKey.E2EKey)); - const encryptedKeyToString = oldRoomKey.e2eKeyId + Base64.encode(new Uint8Array(encryptedKey)); + const encryptedKey = await Rsa.encrypt(userKey, Binary.decode(oldRoomKey.E2EKey)); + const encryptedKeyToString = PrefixedBase64.encode([oldRoomKey.e2eKeyId, encryptedKey]); keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); } return keys; } catch (error) { - return this.error('Error encrypting user key: ', error); + return span.set('error', error).error('failed to encrypt old keys for participant'); } } async encryptGroupKeyForParticipant(publicKey: string) { + const span = log.span('encryptGroupKeyForParticipant'); let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await Rsa.importPublicKey(JSON.parse(publicKey)); } catch (error) { - return this.error('Error importing user key: ', error); + return span.set('error', error).error('Error importing user key'); } - // const vector = crypto.getRandomValues(new Uint8Array(16)); // Encrypt session key for this user with his/her public key try { - const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); - const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); + const encryptedUserKey = await Rsa.encrypt(userKey, Binary.decode(this.sessionKeyExportedString!)); + const encryptedUserKeyToString = PrefixedBase64.encode([this.keyID, encryptedUserKey]); + span.info('Group key encrypted for participant'); return encryptedUserKeyToString; } catch (error) { - return this.error('Error encrypting user key: ', error); + return span.error('Error encrypting user key', error); } } // Encrypts files before upload. I/O is in arraybuffers. async encryptFile(file: File) { - // if (!this.isSupportedRoomType(this.typeOfRoom)) { - // return; - // } + const span = log.span('encryptFile'); - const fileArrayBuffer = await readFileAsArrayBuffer(file); + const fileArrayBuffer = await file.arrayBuffer(); - const hash = await sha256HashFromArrayBuffer(new Uint8Array(fileArrayBuffer)); + const hash = await sha256HashFromArrayBuffer(fileArrayBuffer); const vector = crypto.getRandomValues(new Uint8Array(16)); const key = await generateAESCTRKey(); @@ -576,15 +585,14 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - console.log(error); - return this.error('Error encrypting group key: ', error); + return span.set('error', error).error('Error encrypting group key'); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); const fileName = await createSha256HashFromText(file.name); - const encryptedFile = new File([toArrayBuffer(result)], fileName); + const encryptedFile = new File([result], fileName); return { file: encryptedFile, @@ -595,26 +603,25 @@ export class E2ERoom extends Emitter { }; } - // Decrypt uploaded encrypted files. I/O is in arraybuffers. - async decryptFile(file: Uint8Array, key: JsonWebKey, iv: string) { - const ivArray = Base64.decode(iv); - const cryptoKey = await window.crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']); - - return window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file); - } - // Encrypts messages async encryptText(data: Uint8Array) { - const vector = crypto.getRandomValues(new Uint8Array(16)); + const span = log.span('encryptText'); try { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await encryptAES(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); + + const { iv, ciphertext } = await Aes.encrypt(this.groupSessionKey, data); + const encryptedData = { + kid: this.keyID, + iv: Base64.encode(iv), + ciphertext: Base64.encode(ciphertext), + }; + span.info('message encrypted'); + return encryptedData; } catch (error) { - this.error('Error encrypting message: ', error); + span.error('Error encrypting message', error); throw error; } } @@ -626,9 +633,9 @@ export class E2ERoom extends Emitter { const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted)); return { - algorithm: 'rc.v1.aes-sha2', - ciphertext: await this.encryptText(data), - }; + algorithm: 'rc.v2.aes-sha2', + ...(await this.encryptText(data)), + } as const; } // Helper function for encryption of content @@ -647,34 +654,9 @@ export class E2ERoom extends Emitter { } as IE2EEMessage; } - // Helper function for encryption of messages - encrypt(message: IMessage) { - if (!this.isSupportedRoomType(this.typeOfRoom)) { - return; - } - - if (!this.groupSessionKey) { - throw new Error(t('E2E_Invalid_Key')); - } - - const ts = new Date(); - - const data = new TextEncoder().encode( - EJSON.stringify({ - _id: message._id, - text: message.msg, - userId: this.userId, - ts, - }), - ); - - return this.encryptText(data); - } - async decryptContent(data: T) { - const content = await this.decrypt(data.content.ciphertext); + const content = await this.decrypt(data.content); Object.assign(data, content); - return data; } @@ -684,11 +666,11 @@ export class E2ERoom extends Emitter { return message; } - if (message.msg) { + // TODO(@cardoso): review backward compatibility + if (message.msg && !isEncryptedMessageContent(message)) { const data = await this.decrypt(message.msg); - - if (data?.text) { - message.msg = data.text; + if (data.msg) { + message.msg = data.msg; } } @@ -700,60 +682,59 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { - const result = await decryptAES(vector, key, cipherText); - return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); - } + async decrypt(message: string | EncryptedContent): Promise, 'attachments' | 'files' | 'file' | 'msg'>> { + const span = log.span('decrypt').set('rid', this.roomId); + const payload = decodeEncryptedContent(message); + span.set('payload', payload); + const { kid, iv, ciphertext } = payload; - async decrypt(message: string) { - const keyID = message.slice(0, 12); - message = message.slice(12); + const key = this.retrieveDecryptionKey(kid); - const [vector, cipherText] = splitVectorAndEcryptedData(Base64.decode(message)); - - let oldKey = null; - if (keyID !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key: any) => key.e2eKeyId === keyID); - // Messages already contain a keyID stored with them - // That means that if we cannot find a keyID for the key the message has preppended to - // The message is indecipherable. - // In these cases, we'll give a last shot using the current session key, which may not work - // but will be enough to help with some mobile issues. - if (!oldRoomKey) { - try { - if (!this.groupSessionKey) { - throw new Error('No group session key found.'); - } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); - } catch (error) { - this.error('Error decrypting message: ', error, message); - return { msg: t('E2E_indecipherable') }; - } - } - oldKey = oldRoomKey.E2EKey; + if (!key) { + span.error('No decryption key found.'); + return { msg: t('E2E_indecipherable') }; } - + span.set('algorithm', key.algorithm.name); + span.set('extractable', key.extractable); + span.set('type', key.type); + span.set('usages', key.usages.toString()); try { - if (oldKey) { - return await this.doDecrypt(vector, oldKey, cipherText); + const result = await Aes.decrypt(key, { iv, ciphertext }); + const ret: unknown = EJSON.parse(result); + if (typeof ret !== 'object' || ret === null) { + span.error('Decrypted message is not an object'); + return { msg: t('E2E_indecipherable') }; } - if (!this.groupSessionKey) { - throw new Error('No group session key found.'); + + if ('text' in ret && typeof ret.text === 'string' && !('msg' in ret)) { + const { text, ...rest } = ret; + return { msg: text, ...rest }; } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); + + if ('msg' in ret && typeof ret.msg === 'string') { + const { msg, ...rest } = ret; + return { msg, ...rest }; + } + + return { ...ret }; } catch (error) { - this.error('Error decrypting message: ', error, message); + span.set('error', error).error('Error decrypting message'); return { msg: t('E2E_Key_Error') }; } } + private retrieveDecryptionKey(kid: string): Aes.Key | null { + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid); + return oldRoomKey?.E2EKey ?? this.groupSessionKey; + } + provideKeyToUser(keyId: string) { if (this.keyID !== keyId) { return; } void this.encryptKeyForOtherParticipants(); - this.setState(E2ERoomState.READY); + this.setState('READY'); } onStateChange(cb: () => void) { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index f22dd8fc35c..7632ee64e9e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,39 +1,19 @@ import QueryString from 'querystring'; import URL from 'url'; -import type { - IE2EEMessage, - IMessage, - IRoom, - ISubscription, - IUser, - IUploadWithUser, - MessageAttachment, - Serialized, -} from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, Serialized, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; -import EJSON from 'ejson'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; -import { E2EEState } from './E2EEState'; -import { - toString, - toArrayBuffer, - joinVectorAndEcryptedData, - splitVectorAndEcryptedData, - encryptAES, - decryptAES, - generateRSAKey, - exportJWKKey, - importRSAKey, - importRawKey, - deriveKey, - generateMnemonicPhrase, -} from './helper'; -import { log, logError } from './logger'; +import type { E2EEState } from './E2EEState'; +import * as Rsa from './crypto/rsa'; +import { generatePassphrase } from './helper'; +import { Keychain } from './keychain'; +import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; import { getUserAvatarURL } from '../../../app/utils/client'; @@ -42,18 +22,19 @@ import { t } from '../../../app/utils/lib/i18n'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; import { isTruthy } from '../../../lib/isTruthy'; -import { Messages, Rooms, Subscriptions } from '../../stores'; +import { Rooms, Subscriptions } from '../../stores'; import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal'; import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal'; import * as banners from '../banners'; import type { LegacyBannerPayload } from '../banners'; import { settings } from '../settings'; import { dispatchToastMessage } from '../toast'; -import { getUserId } from '../user'; import { mapMessageFromApi } from '../utils/mapMessageFromApi'; let failedToDecodeKey = false; +const log = createLogger('E2E'); + type KeyPair = { public_key: string | null; private_key: string | null; @@ -62,61 +43,48 @@ type KeyPair = { const ROOM_KEY_EXCHANGE_SIZE = 10; class E2E extends Emitter { - private started: boolean; + private userId: string | false = false; - private instancesByRoomId: Record; + private keychain: Keychain; + + private instancesByRoomId: Record = {}; private db_public_key: string | null | undefined; private db_private_key: string | null | undefined; - public privateKey: CryptoKey | undefined; + public privateKey: Rsa.PrivateKey | undefined; public publicKey: string | undefined; - private keyDistributionInterval: ReturnType | null; + private keyDistributionInterval: ReturnType | null = null; private state: E2EEState; constructor() { super(); - this.started = false; - this.instancesByRoomId = {}; - this.keyDistributionInterval = null; - this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { - this.log(`${prevState} -> ${nextState}`); - }); - - this.on(E2EEState.READY, async () => { + this.on('READY', async () => { await this.onE2EEReady(); }); - this.on(E2EEState.SAVE_PASSWORD, async () => { + this.on('SAVE_PASSWORD', async () => { await this.onE2EEReady(); }); - this.on(E2EEState.DISABLED, () => { + this.on('DISABLED', () => { this.unsubscribeFromSubscriptions?.(); }); - this.on(E2EEState.NOT_STARTED, () => { + this.on('NOT_STARTED', () => { this.unsubscribeFromSubscriptions?.(); }); - this.on(E2EEState.ERROR, () => { + this.on('ERROR', () => { this.unsubscribeFromSubscriptions?.(); }); - this.setState(E2EEState.NOT_STARTED); - } - - log(...msg: unknown[]) { - log('E2E', ...msg); - } - - error(...msg: unknown[]) { - logError('E2E', ...msg); + this.setState('NOT_STARTED'); } getState() { @@ -124,29 +92,24 @@ class E2E extends Emitter { } isEnabled(): boolean { - return this.state !== E2EEState.DISABLED; + return this.state !== 'DISABLED'; } isReady(): boolean { // Save_Password state is also a ready state for E2EE - return this.state === E2EEState.READY || this.state === E2EEState.SAVE_PASSWORD; + return this.state === 'READY' || this.state === 'SAVE_PASSWORD'; } async onE2EEReady() { - this.log('startClient -> Done'); this.initiateHandshake(); await this.handleAsyncE2EESuggestedKey(); - this.log('decryptSubscriptions'); await this.decryptSubscriptions(); - this.log('decryptSubscriptions -> Done'); await this.initiateKeyDistribution(); - this.log('initiateKeyDistribution -> Done'); this.observeSubscriptions(); - this.log('observing subscriptions'); } - async onSubscriptionChanged(sub: ISubscription) { - this.log('Subscription changed', sub); + async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise { + const span = log.span('onSubscriptionChanged').set('subscription_id', sub._id).set('room_id', sub.rid).set('encrypted', sub.encrypted); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -154,6 +117,7 @@ class E2E extends Emitter { const e2eRoom = await this.getInstanceByRoomId(sub.rid); if (!e2eRoom) { + span.warn('no e2eRoom found'); return; } @@ -162,12 +126,16 @@ class E2E extends Emitter { await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + span.warn('rejected'); await this.rejectSuggestedKey(sub.rid); } } - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + if (sub.encrypted) { + e2eRoom.resume(); + } else { + e2eRoom.pause(); + } // Cover private groups and direct messages if (!e2eRoom.isSupportedRoomType(sub.t)) { @@ -184,12 +152,15 @@ class E2E extends Emitter { return; } - await e2eRoom.decryptSubscription(); + if (sub.lastMessage?.e2e !== 'done') { + await e2eRoom.decryptSubscription(); + } } private unsubscribeFromSubscriptions: (() => void) | undefined; observeSubscriptions() { + const span = log.span('observeSubscriptions'); this.unsubscribeFromSubscriptions?.(); this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => { @@ -200,7 +171,7 @@ class E2E extends Emitter { const excess = instatiated.difference(subscribed); if (excess.size) { - this.log('Unsubscribing from excess instances', excess); + span.info('Unsubscribing from excess instances'); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -210,45 +181,49 @@ class E2E extends Emitter { }); } - shouldAskForE2EEPassword() { - const { private_key } = this.getKeysFromLocalStorage(); - return this.db_private_key && !private_key; - } - setState(nextState: E2EEState) { + const span = log.span('setState').set('prevState', this.state).set('nextState', nextState); const prevState = this.state; this.state = nextState; + span.info(`${prevState} -> ${nextState}`); this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); } async handleAsyncE2EESuggestedKey() { + const span = log.span('handleAsyncE2EESuggestedKey'); const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( subs .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) .map(async (sub) => { const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); + span.set('subscription_id', sub._id).set('room_id', sub.rid); if (!e2eRoom) { return; } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - this.log('Imported valid E2E suggested key'); + span.info('importedE2ESuggestedKey'); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - this.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + span.error('invalidE2ESuggestedKey'); await e2e.rejectSuggestedKey(sub.rid); } - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + if (sub.encrypted) { + e2eRoom.resume(); + } else { + e2eRoom.pause(); + } }), ); + span.info('handledAsyncE2ESuggestedKey'); } private waitForRoom(rid: IRoom['_id']): Promise { @@ -268,6 +243,10 @@ class E2E extends Emitter { } async getInstanceByRoomId(rid: IRoom['_id']): Promise { + if (!this.userId) { + return null; + } + const room = await this.waitForRoom(rid); if (room.t !== 'd' && room.t !== 'p') { @@ -278,9 +257,8 @@ class E2E extends Emitter { return null; } - const userId = getUserId(); - if (!this.instancesByRoomId[rid] && userId) { - this.instancesByRoomId[rid] = new E2ERoom(userId, room); + if (!this.instancesByRoomId[rid] && this.userId) { + this.instancesByRoomId[rid] = new E2ERoom(this.userId, room); } // When the key was already set and is changed via an update, we update the room instance @@ -293,7 +271,7 @@ class E2E extends Emitter { this.instancesByRoomId[rid].onRoomKeyReset(room.e2eKeyId); } - return this.instancesByRoomId[rid]; + return this.instancesByRoomId[rid] ?? null; } removeInstanceByRoomId(rid: IRoom['_id']): void { @@ -309,7 +287,7 @@ class E2E extends Emitter { throw new Error('Failed to persist keys as they are not strings.'); } - const encodedPrivateKey = await this.encodePrivateKey(private_key, password); + const encodedPrivateKey = await this.keychain.encryptKey(private_key, password); if (!encodedPrivateKey) { throw new Error('Failed to encode private key with provided password.'); @@ -317,7 +295,7 @@ class E2E extends Emitter { await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { public_key, - private_key: encodedPrivateKey, + private_key: JSON.stringify(encodedPrivateKey), force, }); } @@ -357,7 +335,7 @@ class E2E extends Emitter { }, onConfirm: () => { Accounts.storageLocation.removeItem('e2e.randomPassword'); - this.setState(E2EEState.READY); + this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); this.closeAlert(); imperativeModal.close(); @@ -366,14 +344,16 @@ class E2E extends Emitter { }); } - async startClient(): Promise { - if (this.started) { + async startClient(userId: string): Promise { + const span = log.span('startClient'); + if (this.userId === userId) { return; } - this.log('startClient -> STARTED'); + span.info(this.state); - this.started = true; + this.userId = userId; + this.keychain = new Keychain(userId); let { public_key, private_key } = this.getKeysFromLocalStorage(); @@ -383,12 +363,12 @@ class E2E extends Emitter { public_key = this.db_public_key; } - if (this.shouldAskForE2EEPassword()) { + if (this.db_private_key && !private_key) { try { - this.setState(E2EEState.ENTER_PASSWORD); - private_key = await this.decodePrivateKey(this.db_private_key as string); + this.setState('ENTER_PASSWORD'); + private_key = await this.decodePrivateKey(this.db_private_key); } catch (error) { - this.started = false; + this.userId = false; failedToDecodeKey = true; this.openAlert({ title: "Wasn't possible to decode your encryption key to be imported.", // TODO: missing translation @@ -397,30 +377,30 @@ class E2E extends Emitter { closable: true, icon: 'key', action: async () => { - await this.startClient(); + await this.startClient(userId); this.closeAlert(); }, }); - return; + return span.error('E2E -> Error decoding private key: ', error); } } if (public_key && private_key) { await this.loadKeys({ public_key, private_key }); - this.setState(E2EEState.READY); + this.setState('READY'); } else { await this.createAndLoadKeys(); - this.setState(E2EEState.READY); + this.setState('READY'); } if (!this.db_public_key || !this.db_private_key) { - this.setState(E2EEState.LOADING_KEYS); + this.setState('LOADING_KEYS'); await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); if (randomPassword) { - this.setState(E2EEState.SAVE_PASSWORD); + this.setState('SAVE_PASSWORD'); this.openAlert({ title: () => t('Save_your_new_E2EE_password'), html: () => t('Click_here_to_view_and_save_your_new_E2EE_password'), @@ -433,7 +413,8 @@ class E2E extends Emitter { } async stopClient(): Promise { - this.log('-> Stop Client'); + const span = log.span('stopClient'); + span.info(this.state); this.closeAlert(); Accounts.storageLocation.removeItem('public_key'); @@ -441,10 +422,12 @@ class E2E extends Emitter { this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; - this.started = false; - this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); + this.userId = false; + if (this.keyDistributionInterval) { + clearInterval(this.keyDistributionInterval); + } this.keyDistributionInterval = null; - this.setState(E2EEState.DISABLED); + this.setState('DISABLED'); } async changePassword(newPassword: string): Promise { @@ -456,15 +439,18 @@ class E2E extends Emitter { } async loadKeysFromDB(): Promise { + const span = log.span('loadKeysFromDB'); try { - this.setState(E2EEState.LOADING_KEYS); + this.setState('LOADING_KEYS'); const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys'); this.db_public_key = public_key; this.db_private_key = private_key; + + span.info('fetched keys from db'); } catch (error) { - this.setState(E2EEState.ERROR); - this.error('Error fetching RSA keys: ', error); + this.setState('ERROR'); + span.error('Error fetching RSA keys: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation throw error; @@ -472,48 +458,50 @@ class E2E extends Emitter { } async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { + const span = log.span('loadKeys'); Accounts.storageLocation.setItem('public_key', public_key); this.publicKey = public_key; try { - this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); + this.privateKey = await Rsa.importPrivateKey(JSON.parse(private_key)); Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error importing private key: ', error); + this.setState('ERROR'); + return span.error('Error importing private key: ', error); } } async createAndLoadKeys(): Promise { + const span = log.span('createAndLoadKeys'); // Could not obtain public-private keypair from server. - this.setState(E2EEState.LOADING_KEYS); - let key; + this.setState('LOADING_KEYS'); + let keyPair; try { - key = await generateRSAKey(); - this.privateKey = key.privateKey; + keyPair = await Rsa.generate(); + this.privateKey = keyPair.privateKey; } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error generating key: ', error); + this.setState('ERROR'); + return span.set('error', error).error('Error generating key'); } try { - const publicKey = await exportJWKKey(key.publicKey); + const publicKey = await Rsa.exportPublicKey(keyPair.publicKey); this.publicKey = JSON.stringify(publicKey); Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error exporting public key: ', error); + this.setState('ERROR'); + return span.set('error', error).error('Error exporting public key'); } try { - const privateKey = await exportJWKKey(key.privateKey); + const privateKey = await Rsa.exportPrivateKey(keyPair.privateKey); Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error exporting private key: ', error); + this.setState('ERROR'); + return span.set('error', error).error('Error exporting private key'); } await this.requestSubscriptionKeys(); @@ -524,51 +512,11 @@ class E2E extends Emitter { } async createRandomPassword(): Promise { - const randomPassword = await generateMnemonicPhrase(5); + const randomPassword = await generatePassphrase(); Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); return randomPassword; } - async encodePrivateKey(privateKey: string, password: string): Promise { - const masterKey = await this.getMasterKey(password); - - const vector = crypto.getRandomValues(new Uint8Array(16)); - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const encodedPrivateKey = await encryptAES(vector, masterKey, toArrayBuffer(privateKey)); - - return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey)); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error encrypting encodedPrivateKey: ', error); - } - } - - async getMasterKey(password: string): Promise { - if (password == null) { - alert('You should provide a password'); - } - - // First, create a PBKDF2 "key" containing the password - let baseKey; - try { - baseKey = await importRawKey(toArrayBuffer(password)); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error creating a key based on user password: ', error); - } - - // Derive a key from the password - try { - return await deriveKey(toArrayBuffer(getUserId()), baseKey); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error deriving baseKey: ', error); - } - } - openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { imperativeModal.open({ component: EnterE2EPasswordModal, @@ -620,55 +568,41 @@ class E2E extends Emitter { async decodePrivateKeyFlow() { const password = await this.requestPasswordModal(); - const masterKey = await this.getMasterKey(password); if (!this.db_private_key) { return; } - const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(this.db_private_key)); - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await decryptAES(vector, masterKey, cipherText); - const privateKey = toString(privKey) as string; + const privateKey = await this.keychain.decryptKey(this.db_private_key, password); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); - this.setState(E2EEState.READY); + this.setState('READY'); } else { await this.createAndLoadKeys(); - this.setState(E2EEState.READY); + this.setState('READY'); } dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); } catch (error) { - this.setState(E2EEState.ENTER_PASSWORD); + this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key'); + throw new Error('E2E -> Error decrypting private key', { cause: error }); } } async decodePrivateKey(privateKey: string): Promise { + // const span = log.span('decodePrivateKey'); const password = await this.requestPasswordAlert(); - - const masterKey = await this.getMasterKey(password); - - const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(privateKey)); - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await decryptAES(vector, masterKey, cipherText); - return toString(privKey); + const privKey = await this.keychain.decryptKey(privateKey, password); + return privKey; } catch (error) { - this.setState(E2EEState.ENTER_PASSWORD); + this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key'); + throw new Error('E2E -> Error decrypting private key', { cause: error }); } } @@ -704,48 +638,46 @@ class E2E extends Emitter { return decryptedMessageWithQuote; } - async decryptPinnedMessage(message: IMessage) { - const pinnedMessage = message?.attachments?.[0]?.text; + async decryptPinnedMessage(message: IE2EEPinnedMessage) { + const span = log.span('decryptPinnedMessage'); + const [pinnedMessage] = message.attachments; if (!pinnedMessage) { + span.warn('No pinned message found'); return message; } const e2eRoom = await this.getInstanceByRoomId(message.rid); if (!e2eRoom) { + span.warn('No e2eRoom found'); return message; } - const data = await e2eRoom.decrypt(pinnedMessage); + const data = await e2eRoom.decrypt(pinnedMessage.content); - if (!data) { - return message; - } + // TODO(@cardoso): review backward compatibility + message.attachments[0].text = data.msg; - const decryptedPinnedMessage = { ...message } as IMessage & { attachments: MessageAttachment[] }; - decryptedPinnedMessage.attachments[0].text = data.text; - - return decryptedPinnedMessage; + span.info('pinned message decrypted'); + return message; } - async decryptPendingMessages(): Promise { - await Messages.state.updateAsync( - (record) => record.t === 'e2e' && record.e2e === 'pending', - (record) => this.decryptMessage(record), - ); - } - - async decryptSubscription(subscriptionId: ISubscription['_id']): Promise { - const e2eRoom = await this.getInstanceByRoomId(subscriptionId); - this.log('decryptSubscription ->', subscriptionId); + async decryptSubscription(subscription: SubscriptionWithRoom): Promise { + const span = log.span('decryptSubscription'); + const e2eRoom = await this.getInstanceByRoomId(subscription.rid); + span.info(subscription._id); await e2eRoom?.decryptSubscription(); } async decryptSubscriptions(): Promise { - Subscriptions.state - .filter((subscription) => Boolean(subscription.encrypted)) - .forEach((subscription) => this.decryptSubscription(subscription._id)); + const subscriptions = Subscriptions.state.filter((subscription) => !!subscription.encrypted); + + await Promise.all( + subscriptions.map(async (subscription) => { + await this.decryptSubscription(subscription); + }), + ); } openAlert(config: Omit): void { @@ -861,14 +793,22 @@ class E2E extends Emitter { return sampleIds; } + getUserId(): string { + if (!this.userId) { + throw new Error('No userId found'); + } + return this.userId; + } + async initiateKeyDistribution() { if (this.keyDistributionInterval) { return; } const predicate = (record: IRoom) => - Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== getUserId())); + Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== this.getUserId())); + const span = log.span('initiateKeyDistribution'); const keyDistribution = async () => { const roomIds = Rooms.state.filter(predicate).map((room) => room._id); @@ -898,7 +838,7 @@ class E2E extends Emitter { try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return this.error('Error providing group key to users: ', error); + return span.error('provideUsersSuggestedGroupKeys', error); } }; diff --git a/apps/meteor/client/lib/e2ee/wordList.ts b/apps/meteor/client/lib/e2ee/wordList.ts index f2930b6cb96..33bd63b0698 100644 --- a/apps/meteor/client/lib/e2ee/wordList.ts +++ b/apps/meteor/client/lib/e2ee/wordList.ts @@ -1,1635 +1,2054 @@ -export default [ - 'acrobat', - 'africa', - 'alaska', - 'albert', - 'albino', +/** + * The BIP-39 English word list. + * {@link https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt} + */ +export const wordlist = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', 'album', 'alcohol', - 'alex', - 'alpha', - 'amadeus', - 'amanda', - 'amazon', - 'america', - 'analog', - 'animal', - 'antenna', - 'antonio', - 'apollo', - 'april', - 'aroma', - 'artist', - 'aspirin', - 'athlete', - 'atlas', - 'banana', - 'bandit', - 'banjo', - 'bikini', - 'bingo', - 'bonus', - 'camera', - 'canada', - 'carbon', - 'casino', - 'catalog', - 'cinema', - 'citizen', - 'cobra', - 'comet', - 'compact', - 'complex', - 'context', - 'credit', - 'critic', - 'crystal', - 'culture', - 'david', - 'delta', - 'dialog', - 'diploma', - 'doctor', - 'domino', - 'dragon', - 'drama', - 'extra', - 'fabric', - 'final', - 'focus', - 'forum', - 'galaxy', - 'gallery', - 'global', - 'harmony', - 'hotel', - 'humor', - 'index', - 'japan', - 'kilo', - 'lemon', - 'liter', - 'lotus', - 'mango', - 'melon', - 'menu', - 'meter', - 'metro', - 'mineral', - 'model', - 'music', - 'object', - 'piano', - 'pirate', - 'plastic', - 'radio', - 'report', - 'signal', - 'sport', - 'studio', - 'subject', - 'super', - 'tango', - 'taxi', - 'tempo', - 'tennis', - 'textile', - 'tokyo', - 'total', - 'tourist', - 'video', - 'visa', - 'academy', - 'alfred', - 'atlanta', - 'atomic', - 'barbara', - 'bazaar', - 'brother', - 'budget', - 'cabaret', - 'cadet', - 'candle', - 'capsule', - 'caviar', - 'channel', - 'chapter', - 'circle', - 'cobalt', - 'comrade', - 'condor', - 'crimson', - 'cyclone', - 'darwin', - 'declare', - 'denver', - 'desert', - 'divide', - 'dolby', - 'domain', - 'double', - 'eagle', - 'echo', - 'eclipse', - 'editor', - 'educate', - 'edward', - 'effect', - 'electra', - 'emerald', - 'emotion', - 'empire', - 'eternal', - 'evening', - 'exhibit', - 'expand', - 'explore', - 'extreme', - 'ferrari', - 'forget', - 'freedom', - 'friday', - 'fuji', - 'galileo', - 'genesis', - 'gravity', - 'habitat', - 'hamlet', - 'harlem', - 'helium', - 'holiday', - 'hunter', - 'ibiza', - 'iceberg', - 'imagine', - 'infant', - 'isotope', - 'jackson', - 'jamaica', - 'jasmine', - 'java', - 'jessica', - 'kitchen', - 'lazarus', - 'letter', - 'license', - 'lithium', - 'loyal', - 'lucky', - 'magenta', - 'manual', - 'marble', - 'maxwell', - 'mayor', - 'monarch', - 'monday', - 'money', - 'morning', - 'mother', - 'mystery', - 'native', - 'nectar', - 'nelson', - 'network', - 'nikita', - 'nobel', - 'nobody', - 'nominal', - 'norway', - 'nothing', - 'number', - 'october', - 'office', - 'oliver', - 'opinion', - 'option', - 'order', - 'outside', - 'package', - 'pandora', - 'panther', - 'papa', - 'pattern', - 'pedro', - 'pencil', - 'people', - 'phantom', - 'philips', - 'pioneer', - 'pluto', - 'podium', - 'portal', - 'potato', - 'process', - 'proxy', - 'pupil', - 'python', - 'quality', - 'quarter', - 'quiet', - 'rabbit', - 'radical', - 'radius', - 'rainbow', - 'ramirez', - 'ravioli', - 'raymond', - 'respect', - 'respond', - 'result', - 'resume', - 'richard', - 'river', - 'roger', - 'roman', - 'rondo', - 'sabrina', - 'salary', - 'salsa', - 'sample', - 'samuel', - 'saturn', - 'savage', - 'scarlet', - 'scorpio', - 'sector', - 'serpent', - 'shampoo', - 'sharon', - 'silence', - 'simple', - 'society', - 'sonar', - 'sonata', - 'soprano', - 'sparta', - 'spider', - 'sponsor', - 'abraham', - 'action', - 'active', - 'actor', - 'adam', - 'address', - 'admiral', - 'adrian', - 'agenda', - 'agent', - 'airline', - 'airport', - 'alabama', - 'aladdin', - 'alarm', - 'algebra', - 'alibi', - 'alice', + 'alert', 'alien', - 'almond', - 'alpine', - 'amber', - 'amigo', - 'ammonia', - 'analyze', - 'anatomy', - 'angel', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', 'annual', + 'another', 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', 'apple', - 'archive', + 'approve', + 'april', + 'arch', 'arctic', + 'area', 'arena', - 'arizona', - 'armada', - 'arnold', - 'arsenal', - 'arthur', - 'asia', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', 'aspect', - 'athena', - 'audio', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', 'august', - 'austria', - 'avenue', + 'aunt', + 'author', + 'auto', + 'autumn', 'average', - 'axiom', - 'aztec', - 'bagel', - 'baker', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', 'balance', - 'ballad', - 'ballet', - 'bambino', + 'balcony', + 'ball', 'bamboo', - 'baron', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', 'basic', 'basket', - 'battery', - 'belgium', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', 'benefit', - 'berlin', - 'bermuda', - 'bernard', + 'best', + 'betray', + 'better', + 'between', + 'beyond', 'bicycle', - 'binary', + 'bid', + 'bike', + 'bind', 'biology', - 'bishop', - 'blitz', - 'block', - 'blonde', - 'bonjour', - 'boris', - 'boston', - 'bottle', - 'boxer', - 'brandy', - 'bravo', - 'brazil', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', 'bridge', - 'british', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', 'bronze', + 'broom', + 'brother', 'brown', - 'bruce', - 'bruno', 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', 'burger', - 'burma', - 'cabinet', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', 'cactus', - 'cafe', - 'cairo', - 'calypso', - 'camel', - 'campus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', 'canal', + 'cancel', + 'candy', 'cannon', 'canoe', - 'cantina', 'canvas', 'canyon', + 'capable', 'capital', - 'caramel', - 'caravan', - 'career', + 'captain', + 'car', + 'carbon', + 'card', 'cargo', - 'carlo', - 'carol', 'carpet', - 'cartel', - 'cartoon', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', 'castle', - 'castro', - 'cecilia', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', 'cement', - 'center', + 'census', 'century', - 'ceramic', - 'chamber', - 'chance', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', 'change', 'chaos', - 'charlie', - 'charm', - 'charter', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', 'cheese', 'chef', - 'chemist', 'cherry', - 'chess', - 'chicago', + 'chest', 'chicken', 'chief', - 'china', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', 'cigar', - 'circus', + 'cinnamon', + 'circle', + 'citizen', 'city', - 'clara', - 'classic', - 'claudia', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', 'clean', + 'clerk', + 'clever', + 'click', 'client', - 'climax', + 'cliff', + 'climb', 'clinic', + 'clip', 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', 'club', - 'cockpit', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', 'coconut', - 'cola', + 'code', + 'coffee', + 'coil', + 'coin', 'collect', - 'colombo', - 'colony', 'color', - 'combat', - 'comedy', - 'command', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', 'company', 'concert', + 'conduct', + 'confirm', + 'congress', 'connect', - 'consul', - 'contact', - 'contour', + 'consider', 'control', - 'convert', + 'convince', + 'cook', + 'cool', + 'copper', 'copy', - 'corner', - 'corona', + 'coral', + 'core', + 'corn', 'correct', - 'cosmos', + 'cost', + 'cotton', + 'couch', + 'country', 'couple', - 'courage', - 'cowboy', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', 'craft', + 'cram', + 'crane', 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', 'cricket', - 'crown', - 'cuba', - 'dallas', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', 'dance', - 'daniel', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', 'decade', - 'decimal', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', 'degree', - 'delete', + 'delay', 'deliver', - 'delphi', - 'deluxe', 'demand', - 'demo', - 'denmark', - 'derby', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', 'design', + 'desk', + 'despair', + 'destroy', + 'detail', 'detect', 'develop', + 'device', + 'devote', 'diagram', + 'dial', 'diamond', - 'diana', - 'diego', + 'diary', + 'dice', 'diesel', 'diet', + 'differ', 'digital', + 'dignity', 'dilemma', + 'dinner', + 'dinosaur', 'direct', - 'disco', - 'disney', - 'distant', - 'dollar', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', 'dolphin', - 'donald', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', 'drink', - 'driver', - 'dublin', - 'duet', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', 'earth', + 'easily', 'east', + 'easy', + 'echo', 'ecology', 'economy', - 'edgar', - 'egypt', - 'elastic', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', 'elegant', 'element', + 'elephant', + 'elevator', 'elite', - 'elvis', - 'email', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', 'energy', + 'enforce', + 'engage', 'engine', - 'english', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', 'episode', - 'equator', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', 'escape', - 'escort', - 'ethnic', - 'europe', - 'everest', - 'evident', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', 'exact', 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', 'exit', 'exotic', - 'export', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', 'express', - 'factor', - 'falcon', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', 'family', + 'famous', + 'fan', + 'fancy', 'fantasy', + 'farm', 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', 'fiber', 'fiction', - 'fidel', - 'fiesta', + 'field', 'figure', + 'file', 'film', 'filter', - 'finance', + 'final', + 'find', + 'fine', + 'finger', 'finish', - 'finland', + 'fire', + 'firm', 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', 'flag', + 'flame', 'flash', - 'florida', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', 'flower', 'fluid', - 'flute', - 'folio', - 'ford', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', 'forest', - 'formal', - 'formula', + 'forget', + 'fork', 'fortune', + 'forum', 'forward', + 'fossil', + 'foster', + 'found', + 'fox', 'fragile', - 'france', - 'frank', + 'frame', + 'frequent', 'fresh', 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', 'future', - 'gabriel', - 'gamma', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', 'garage', - 'garcia', + 'garbage', 'garden', 'garlic', - 'gemini', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', 'general', - 'genetic', 'genius', - 'germany', - 'gloria', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', 'gold', - 'golf', - 'gondola', - 'gong', 'good', - 'gordon', + 'goose', 'gorilla', - 'grand', - 'granite', - 'graph', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', 'green', + 'grid', + 'grief', + 'grit', + 'grocery', 'group', + 'grow', + 'grunt', + 'guard', + 'guess', 'guide', + 'guilt', 'guitar', - 'guru', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', 'hand', 'happy', 'harbor', - 'harvard', - 'havana', - 'hawaii', - 'helena', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', 'hello', - 'henry', - 'hilton', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', 'history', - 'horizon', - 'house', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', 'icon', 'idea', - 'igloo', - 'igor', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', 'image', + 'imitate', + 'immense', + 'immune', 'impact', - 'import', - 'india', - 'indigo', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', 'input', + 'inquiry', + 'insane', 'insect', - 'instant', - 'iris', - 'italian', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', 'jacket', - 'jacob', 'jaguar', - 'janet', - 'jargon', + 'jar', 'jazz', - 'jeep', - 'john', - 'joker', - 'jordan', - 'judo', - 'jumbo', - 'june', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', 'jungle', 'junior', - 'jupiter', - 'karate', - 'karma', - 'kayak', - 'kermit', - 'king', - 'koala', - 'korea', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', 'labor', + 'ladder', 'lady', - 'lagoon', + 'lake', + 'lamp', + 'language', 'laptop', - 'laser', + 'large', + 'later', 'latin', + 'laugh', + 'laundry', 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', 'lecture', 'left', + 'leg', 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', 'level', - 'lexicon', - 'liberal', - 'libra', - 'lily', - 'limbo', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', 'limit', - 'linda', - 'linear', + 'link', 'lion', 'liquid', + 'list', 'little', - 'llama', - 'lobby', + 'live', + 'lizard', + 'load', + 'loan', 'lobster', 'local', + 'lock', 'logic', - 'logo', - 'lola', - 'london', - 'lucas', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', 'lunar', + 'lunch', + 'luxury', + 'lyrics', 'machine', - 'macro', - 'madam', - 'madonna', - 'madrid', - 'maestro', + 'mad', 'magic', 'magnet', - 'magnum', - 'mailbox', + 'maid', + 'mail', + 'main', 'major', - 'mama', - 'mambo', - 'manager', - 'manila', - 'marco', - 'marina', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', 'market', - 'mars', - 'martin', - 'marvin', - 'mary', + 'marriage', + 'mask', + 'mass', 'master', + 'match', + 'material', + 'math', 'matrix', + 'matter', 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', 'media', - 'medical', - 'mega', 'melody', - 'memo', - 'mental', - 'mentor', - 'mercury', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', 'message', 'metal', - 'meteor', 'method', - 'mexico', - 'miami', - 'micro', + 'middle', + 'midnight', 'milk', 'million', + 'mimic', + 'mind', 'minimum', - 'minus', + 'minor', 'minute', 'miracle', - 'mirage', - 'miranda', - 'mister', - 'mixer', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', 'mobile', - 'modem', - 'modern', - 'modular', + 'model', + 'modify', + 'mom', 'moment', - 'monaco', - 'monica', 'monitor', - 'mono', + 'monkey', 'monster', - 'montana', - 'morgan', - 'motel', - 'motif', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', 'motor', - 'mozart', - 'multi', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', 'museum', - 'mustang', - 'natural', - 'neon', - 'nepal', - 'neptune', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', 'nerve', + 'nest', + 'net', + 'network', 'neutral', - 'nevada', + 'never', 'news', 'next', - 'ninja', - 'nirvana', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', 'normal', - 'nova', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', 'novel', + 'now', 'nuclear', - 'numeric', - 'nylon', - 'oasis', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', 'observe', + 'obtain', + 'obvious', + 'occur', 'ocean', - 'octopus', - 'olivia', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', 'olympic', - 'omega', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', 'opera', - 'optic', - 'optimal', + 'opinion', + 'oppose', + 'option', 'orange', 'orbit', - 'organic', + 'orchard', + 'order', + 'ordinary', + 'organ', 'orient', - 'origin', - 'orlando', - 'oscar', - 'oxford', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', 'oxygen', + 'oyster', 'ozone', - 'pablo', - 'pacific', - 'pagoda', + 'pact', + 'paddle', + 'page', + 'pair', 'palace', - 'pamela', - 'panama', - 'pancake', + 'palm', 'panda', 'panel', 'panic', - 'paradox', - 'pardon', - 'paris', - 'parker', - 'parking', - 'parody', - 'partner', - 'passage', - 'passive', - 'pasta', - 'pastel', - 'patent', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', 'patient', - 'patriot', 'patrol', - 'pegasus', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', 'pelican', - 'penguin', + 'pen', + 'penalty', + 'pencil', + 'people', 'pepper', - 'percent', 'perfect', - 'perfume', - 'period', 'permit', 'person', - 'peru', + 'pet', 'phone', 'photo', - 'picasso', + 'phrase', + 'physical', + 'piano', 'picnic', 'picture', - 'pigment', - 'pilgrim', + 'piece', + 'pig', + 'pigeon', + 'pill', 'pilot', - 'pixel', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', 'pizza', + 'place', 'planet', - 'plasma', - 'plaza', - 'pocket', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', 'poem', - 'poetic', - 'poker', - 'polaris', + 'poet', + 'point', + 'polar', + 'pole', 'police', - 'politic', - 'polo', - 'polygon', + 'pond', 'pony', - 'popcorn', + 'pool', 'popular', - 'postage', - 'precise', - 'prefix', - 'premium', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', 'present', + 'pretty', + 'prevent', 'price', - 'prince', - 'printer', - 'prism', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', 'private', 'prize', - 'product', - 'profile', + 'problem', + 'process', + 'produce', + 'profit', 'program', 'project', + 'promote', + 'proof', + 'property', + 'prosper', 'protect', - 'proton', + 'proud', + 'provide', 'public', + 'pudding', + 'pull', + 'pulp', 'pulse', - 'puma', - 'pump', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', 'pyramid', - 'queen', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', 'radar', - 'ralph', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', 'random', + 'range', 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', 'record', 'recycle', - 'reflex', + 'reduce', + 'reflect', 'reform', - 'regard', + 'refuse', + 'region', + 'regret', 'regular', + 'reject', 'relax', - 'reptile', - 'reverse', - 'ricardo', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', 'right', - 'ringo', + 'rigid', + 'ring', + 'riot', + 'ripple', 'risk', 'ritual', - 'robert', + 'rival', + 'river', + 'road', + 'roast', 'robot', + 'robust', 'rocket', - 'rodeo', - 'romeo', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', 'royal', - 'russian', - 'safari', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', 'salad', - 'salami', 'salmon', 'salon', + 'salt', 'salute', - 'samba', - 'sandra', - 'santana', - 'sardine', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', 'school', - 'scoop', - 'scratch', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', 'screen', 'script', - 'scroll', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', 'second', 'secret', 'section', + 'security', + 'seed', + 'seek', 'segment', 'select', + 'sell', 'seminar', - 'senator', 'senior', - 'sensor', - 'serial', + 'sense', + 'sentence', + 'series', 'service', + 'session', + 'settle', + 'setup', + 'seven', 'shadow', - 'sharp', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', 'shock', + 'shoe', + 'shoot', + 'shop', 'short', - 'shrink', - 'sierra', - 'silicon', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', 'silk', + 'silly', 'silver', 'similar', - 'simon', - 'single', + 'simple', + 'since', + 'sing', 'siren', - 'slang', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', 'slogan', + 'slot', + 'slow', + 'slush', + 'small', 'smart', + 'smile', 'smoke', + 'smooth', + 'snack', 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', 'social', + 'sock', 'soda', + 'soft', 'solar', + 'soldier', 'solid', - 'solo', - 'sonic', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', 'source', - 'soviet', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', 'special', 'speed', + 'spell', + 'spend', 'sphere', - 'spiral', + 'spice', + 'spider', + 'spike', + 'spin', 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', 'spring', - 'static', - 'status', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', 'stone', - 'stop', + 'stool', + 'story', + 'stove', + 'strategy', 'street', + 'strike', 'strong', + 'struggle', 'student', + 'stuff', + 'stumble', 'style', - 'sultan', - 'susan', - 'sushi', - 'suzuki', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', 'switch', + 'sword', 'symbol', + 'symptom', + 'syrup', 'system', - 'tactic', - 'tahiti', + 'table', + 'tackle', + 'tag', + 'tail', 'talent', - 'tarzan', - 'telex', - 'texas', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', 'theory', - 'thermos', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', 'tiger', - 'titanic', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', 'topic', + 'topple', + 'torch', 'tornado', - 'toronto', - 'torpedo', - 'totem', - 'tractor', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', 'traffic', - 'transit', - 'trapeze', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', 'travel', - 'tribal', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', 'trick', - 'trident', - 'trilogy', - 'tripod', - 'tropic', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', 'trumpet', - 'tulip', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', 'tuna', - 'turbo', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', 'twist', - 'ultra', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', 'uniform', - 'union', - 'uranium', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', 'vacuum', + 'vague', 'valid', - 'vampire', - 'vanilla', - 'vatican', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', 'velvet', - 'ventura', - 'venus', - 'vertigo', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', 'veteran', - 'victor', - 'vienna', - 'viking', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', 'village', - 'vincent', - 'violet', + 'vintage', 'violin', 'virtual', 'virus', - 'vision', - 'visitor', + 'visa', + 'visit', 'visual', - 'vitamin', - 'viva', + 'vital', + 'vivid', 'vocal', - 'vodka', + 'voice', + 'void', 'volcano', - 'voltage', 'volume', + 'vote', 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', 'weekend', + 'weird', 'welcome', - 'western', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', 'window', + 'wine', + 'wing', + 'wink', + 'winner', 'winter', - 'wizard', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', 'wolf', - 'world', - 'xray', - 'yankee', - 'yoga', - 'yogurt', - 'yoyo', - 'zebra', - 'zero', - 'zigzag', - 'zipper', - 'zodiac', - 'zoom', - 'acid', - 'adios', - 'agatha', - 'alamo', - 'alert', - 'almanac', - 'aloha', - 'andrea', - 'anita', - 'arcade', - 'aurora', - 'avalon', - 'baby', - 'baggage', - 'balloon', - 'bank', - 'basil', - 'begin', - 'biscuit', - 'blue', - 'bombay', - 'botanic', - 'brain', - 'brenda', - 'brigade', - 'cable', - 'calibre', - 'carmen', - 'cello', - 'celtic', - 'chariot', - 'chrome', - 'citrus', - 'civil', - 'cloud', - 'combine', - 'common', - 'cool', - 'copper', - 'coral', - 'crater', - 'cubic', - 'cupid', - 'cycle', - 'depend', - 'door', - 'dream', - 'dynasty', - 'edison', - 'edition', - 'enigma', - 'equal', - 'eric', - 'event', - 'evita', - 'exodus', - 'extend', - 'famous', - 'farmer', - 'food', - 'fossil', - 'frog', - 'fruit', - 'geneva', - 'gentle', - 'george', - 'giant', - 'gilbert', - 'gossip', - 'gram', - 'greek', - 'grille', - 'hammer', - 'harvest', - 'hazard', - 'heaven', - 'herbert', - 'heroic', - 'hexagon', - 'husband', - 'immune', - 'inca', - 'inch', - 'initial', - 'isabel', - 'ivory', - 'jason', - 'jerome', - 'joel', - 'joshua', - 'journal', - 'judge', - 'juliet', - 'jump', - 'justice', - 'kimono', - 'kinetic', - 'leonid', - 'leopard', - 'lima', - 'maze', - 'medusa', - 'member', - 'memphis', - 'michael', - 'miguel', - 'milan', - 'mile', - 'miller', - 'mimic', - 'mimosa', - 'mission', - 'monkey', - 'moral', - 'moses', - 'mouse', - 'nancy', - 'natasha', - 'nebula', - 'nickel', - 'nina', - 'noise', - 'orchid', - 'oregano', - 'origami', - 'orinoco', - 'orion', - 'othello', - 'paper', - 'paprika', - 'prelude', - 'prepare', - 'pretend', - 'promise', - 'prosper', - 'provide', - 'puzzle', - 'remote', - 'repair', - 'reply', - 'rival', - 'riviera', - 'robin', - 'rose', - 'rover', - 'rudolf', - 'saga', - 'sahara', - 'scholar', - 'shelter', - 'ship', - 'shoe', - 'sigma', - 'sister', - 'sleep', - 'smile', - 'spain', - 'spark', - 'split', - 'spray', - 'square', - 'stadium', - 'star', - 'storm', - 'story', - 'strange', - 'stretch', - 'stuart', - 'subway', - 'sugar', - 'sulfur', - 'summer', - 'survive', - 'sweet', - 'swim', - 'table', - 'taboo', - 'target', - 'teacher', - 'telecom', - 'temple', - 'tibet', - 'ticket', - 'tina', - 'today', - 'toga', - 'tommy', - 'tower', - 'trivial', - 'tunnel', - 'turtle', - 'twin', - 'uncle', - 'unicorn', - 'unique', - 'update', - 'valery', - 'vega', - 'version', - 'voodoo', - 'warning', - 'william', + 'woman', 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', 'year', 'yellow', + 'you', 'young', - 'absent', - 'absorb', - 'absurd', - 'accent', - 'alfonso', - 'alias', - 'ambient', - 'anagram', - 'andy', - 'anvil', - 'appear', - 'apropos', - 'archer', - 'ariel', - 'armor', - 'arrow', - 'austin', - 'avatar', - 'axis', - 'baboon', - 'bahama', - 'bali', - 'balsa', - 'barcode', - 'bazooka', - 'beach', - 'beast', - 'beatles', - 'beauty', - 'before', - 'benny', - 'betty', - 'between', - 'beyond', - 'billy', - 'bison', - 'blast', - 'bless', - 'bogart', - 'bonanza', - 'book', - 'border', - 'brave', - 'bread', - 'break', - 'broken', - 'bucket', - 'buenos', - 'buffalo', - 'bundle', - 'button', - 'buzzer', - 'byte', - 'caesar', - 'camilla', - 'canary', - 'candid', - 'carrot', - 'cave', - 'chant', - 'child', - 'choice', - 'chris', - 'cipher', - 'clarion', - 'clark', - 'clever', - 'cliff', - 'clone', - 'conan', - 'conduct', - 'congo', - 'costume', - 'cotton', - 'cover', - 'crack', - 'current', - 'danube', - 'data', - 'decide', - 'deposit', - 'desire', - 'detail', - 'dexter', - 'dinner', - 'donor', - 'druid', - 'drum', - 'easy', - 'eddie', - 'enjoy', - 'enrico', - 'epoxy', - 'erosion', - 'except', - 'exile', - 'explain', - 'fame', - 'fast', - 'father', - 'felix', - 'field', - 'fiona', - 'fire', - 'fish', - 'flame', - 'flex', - 'flipper', - 'float', - 'flood', - 'floor', - 'forbid', - 'forever', - 'fractal', - 'frame', - 'freddie', - 'front', - 'fuel', - 'gallop', - 'game', - 'garbo', - 'gate', - 'gelatin', - 'gibson', - 'ginger', - 'giraffe', - 'gizmo', - 'glass', - 'goblin', - 'gopher', - 'grace', - 'gray', - 'gregory', - 'grid', - 'griffin', - 'ground', - 'guest', - 'gustav', - 'gyro', - 'hair', - 'halt', - 'harris', - 'heart', - 'heavy', - 'herman', - 'hippie', - 'hobby', - 'honey', - 'hope', - 'horse', - 'hostel', - 'hydro', - 'imitate', - 'info', - 'ingrid', - 'inside', - 'invent', - 'invest', - 'invite', - 'ivan', - 'james', - 'jester', - 'jimmy', - 'join', - 'joseph', - 'juice', - 'julius', - 'july', - 'kansas', - 'karl', - 'kevin', - 'kiwi', - 'ladder', - 'lake', - 'laura', - 'learn', - 'legacy', - 'legend', - 'lesson', - 'life', - 'light', - 'list', - 'locate', - 'lopez', - 'lorenzo', - 'love', - 'lunch', - 'malta', - 'mammal', - 'margin', - 'margo', - 'marion', - 'mask', - 'match', - 'mayday', - 'meaning', - 'mercy', - 'middle', - 'mike', - 'mirror', - 'modest', - 'morph', - 'morris', - 'mystic', - 'nadia', - 'nato', - 'navy', - 'needle', - 'neuron', - 'never', - 'newton', - 'nice', - 'night', - 'nissan', - 'nitro', - 'nixon', - 'north', - 'oberon', - 'octavia', - 'ohio', - 'olga', - 'open', - 'opus', - 'orca', - 'oval', - 'owner', - 'page', - 'paint', - 'palma', - 'parent', - 'parlor', - 'parole', - 'paul', - 'peace', - 'pearl', - 'perform', - 'phoenix', - 'phrase', - 'pierre', - 'pinball', - 'place', - 'plate', - 'plato', - 'plume', - 'pogo', - 'point', - 'polka', - 'poncho', - 'powder', - 'prague', - 'press', - 'presto', - 'pretty', - 'prime', - 'promo', - 'quest', - 'quick', - 'quiz', - 'quota', - 'race', - 'rachel', - 'raja', - 'ranger', - 'region', - 'remark', - 'rent', - 'reward', - 'rhino', - 'ribbon', - 'rider', - 'road', - 'rodent', - 'round', - 'rubber', - 'ruby', - 'rufus', - 'sabine', - 'saddle', - 'sailor', - 'saint', - 'salt', - 'scale', - 'scuba', - 'season', - 'secure', - 'shake', - 'shallow', - 'shannon', - 'shave', - 'shelf', - 'sherman', - 'shine', - 'shirt', - 'side', - 'sinatra', - 'sincere', - 'size', - 'slalom', - 'slow', - 'small', - 'snow', - 'sofia', - 'song', - 'sound', - 'south', - 'speech', - 'spell', - 'spend', - 'spoon', - 'stage', - 'stamp', - 'stand', - 'state', - 'stella', - 'stick', - 'sting', - 'stock', - 'store', - 'sunday', - 'sunset', - 'support', - 'supreme', - 'sweden', - 'swing', - 'tape', - 'tavern', - 'think', - 'thomas', - 'tictac', - 'time', - 'toast', - 'tobacco', - 'tonight', - 'torch', - 'torso', - 'touch', - 'toyota', - 'trade', - 'tribune', - 'trinity', - 'triton', - 'truck', - 'trust', - 'type', - 'under', - 'unit', - 'urban', - 'urgent', - 'user', - 'value', - 'vendor', - 'venice', - 'verona', - 'vibrate', - 'virgo', - 'visible', - 'vista', - 'vital', - 'voice', - 'vortex', - 'waiter', - 'watch', - 'wave', - 'weather', - 'wedding', - 'wheel', - 'whiskey', - 'wisdom', - 'android', - 'annex', - 'armani', - 'cake', - 'confide', - 'deal', - 'define', - 'dispute', - 'genuine', - 'idiom', - 'impress', - 'include', - 'ironic', - 'null', - 'nurse', - 'obscure', - 'prefer', - 'prodigy', - 'ego', - 'fax', - 'jet', - 'job', - 'rio', - 'ski', - 'yes', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo', ]; diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index edfac2b0d05..d07abb40a36 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -5,8 +5,6 @@ import { useTranslation } from 'react-i18next'; import RoomE2EENotAllowed from './RoomE2EENotAllowed'; import { e2e } from '../../../lib/e2ee'; -import { E2EEState } from '../../../lib/e2ee/E2EEState'; -import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import RoomBody from '../body/RoomBody'; import RoomBodyV2 from '../body/RoomBodyV2'; import { useRoom } from '../contexts/RoomContext'; @@ -32,7 +30,7 @@ const RoomE2EESetup = () => { const onEnterE2EEPassword = useCallback(() => e2e.decodePrivateKeyFlow(), []); - if (e2eeState === E2EEState.SAVE_PASSWORD) { + if (e2eeState === 'SAVE_PASSWORD') { return ( { ); } - if (e2eeState === E2EEState.ENTER_PASSWORD) { + if (e2eeState === 'ENTER_PASSWORD') { return ( { ); } - if (e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eRoomState === 'WAITING_KEYS') { return ( { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') { return } />; } diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx index 20104d7625c..061f39f6aae 100644 --- a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx @@ -2,8 +2,6 @@ import { lazy } from 'react'; import RoomHeader from './RoomHeader'; import type { RoomHeaderProps } from './RoomHeader'; -import { E2EEState } from '../../../lib/e2ee/E2EEState'; -import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import { useE2EERoomState } from '../hooks/useE2EERoomState'; import { useE2EEState } from '../hooks/useE2EEState'; @@ -13,7 +11,7 @@ const RoomHeaderE2EESetup = ({ room }: RoomHeaderProps) => { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') { return } />; } diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx index cecb77c48a9..bcf9fb8d09c 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx @@ -2,7 +2,6 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { render, screen } from '@testing-library/react'; import MessageBoxHint from './MessageBoxHint'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useRoom } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -28,7 +27,7 @@ const renderOptions = { describe('MessageBoxHint', () => { beforeEach(() => { (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: false }); - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.WAITING_KEYS); + (useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS'); }); describe('Editing message', () => { @@ -81,14 +80,14 @@ describe('MessageBoxHint', () => { }); it('does not renders hint text when E2ERoomState is READY', () => { - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.READY); + (useE2EERoomState as jest.Mock).mockReturnValue('READY'); render(, renderOptions); expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); }); it('does not renders hint text when E2ERoomState is DISABLED', () => { - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.DISABLED); + (useE2EERoomState as jest.Mock).mockReturnValue('DISABLED'); render(, renderOptions); expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx index b357f755538..b08dc9c337f 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx @@ -3,7 +3,6 @@ import type { ReactElement } from 'react'; import { memo } from 'react'; import { useTranslation, Trans } from 'react-i18next'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useRoom } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -25,8 +24,8 @@ const MessageBoxHint = ({ isEditing, e2eEnabled, unencryptedMessagesAllowed, isM e2eEnabled && unencryptedMessagesAllowed && e2eRoomState && - e2eRoomState !== E2ERoomState.READY && - e2eRoomState !== E2ERoomState.DISABLED && + e2eRoomState !== 'READY' && + e2eRoomState !== 'DISABLED' && !isEditing && !isReadOnly; diff --git a/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts b/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts index c247c78de7a..66c178660db 100644 --- a/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts +++ b/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts @@ -6,7 +6,7 @@ import { renderHook } from '@testing-library/react'; import { useChatMessagesInstance } from './useChatMessagesInstance'; import { ChatMessages } from '../../../../../app/ui/client/lib/ChatMessages'; import { useEmojiPicker } from '../../../../contexts/EmojiPickerContext'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; +import type { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager'; import { useRoomSubscription } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -67,7 +67,7 @@ describe('useChatMessagesInstance', () => { rid: 'roomId', }; mockActionManager = undefined; - mockE2EERoomState = E2ERoomState.READY; + mockE2EERoomState = 'READY'; mockEmojiPicker = { open: jest.fn(), isOpen: false, @@ -162,7 +162,7 @@ describe('useChatMessagesInstance', () => { expect(ChatMessages).toHaveBeenCalledTimes(1); expect(updateSubscriptionMock).toHaveBeenCalledTimes(1); - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.WAITING_KEYS); + (useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS'); rerender(); diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 849abc9c5a2..695af7c53f8 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -4,7 +4,6 @@ import { useEffect, useRef } from 'react'; import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser'; import { e2e } from '../../../../lib/e2ee'; -import { E2EEState } from '../../../../lib/e2ee/E2EEState'; import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived'; import { Rooms } from '../../../../stores'; @@ -19,27 +18,19 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - e2e.log('Not logged in'); - return; - } - - if (!window.crypto) { - e2e.error('No crypto support'); return; } if (enabled && !adminEmbedded) { - e2e.log('E2E enabled starting client'); - e2e.startClient(); + e2e.startClient(userId); } else { - e2e.log('E2E disabled'); - e2e.setState(E2EEState.DISABLED); + e2e.setState('DISABLED'); e2e.closeAlert(); } }, [adminEmbedded, enabled, userId]); const state = useE2EEState(); - const ready = state === E2EEState.READY || state === E2EEState.SAVE_PASSWORD; + const ready = state === 'READY' || state === 'SAVE_PASSWORD'; const listenersAttachedRef = useRef(false); const mentionsEnabled = useSetting('E2E_Enabled_Mentions', true); @@ -49,12 +40,10 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - e2e.log('Not ready'); return; } if (listenersAttachedRef.current) { - e2e.log('Listeners already attached'); return; } @@ -117,14 +106,13 @@ export const useE2EEncryption = () => { } // Should encrypt this message. - return e2eRoom.encryptMessage(message); + const encryptedMessage = await e2eRoom.encryptMessage(message); + return encryptedMessage; }); listenersAttachedRef.current = true; - e2e.log('Listeners attached'); return () => { - e2e.log('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index d48b97c040e..4c8a39794f9 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -101,7 +101,7 @@ export class MessageService extends ServiceClassInternal implements IMessageServ federation_event_id: string; msg?: string; e2e_content?: { - algorithm: string; + algorithm: 'm.megolm.v1.aes-sha2'; ciphertext: string; }; file?: IMessage['file']; diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts index df46441bdc3..7720ed0cab9 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts @@ -143,7 +143,10 @@ test.describe('E2EE Encrypted Channels', () => { await poHomeChannel.dismissToast(); await poHomeChannel.tabs.kebab.click(); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await poHomeChannel.tabs.btnEnableE2E.isVisible())) { + await poHomeChannel.tabs.kebab.click(); + } await poHomeChannel.tabs.btnEnableE2E.click(); await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); await page.getByRole('button', { name: 'Enable encryption' }).click(); @@ -258,6 +261,10 @@ test.describe('E2EE Encrypted Channels', () => { // Delete last message await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(encriptedMessage2); await poHomeChannel.content.openLastMessageMenu(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await page.locator('role=menuitem[name="Delete"]').isVisible())) { + await poHomeChannel.content.openLastMessageMenu(); + } await page.locator('role=menuitem[name="Delete"]').click(); await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts index f82f7ba8a83..fcd3c11010f 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import type { APIRequestContext, Page } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { BASE_API_URL } from '../config/constants'; import injectInitialData from '../fixtures/inject-initial-data'; @@ -17,18 +17,6 @@ const settingsList = [ preserveSettings(settingsList); -const encryptLegacyMessage = async (page: Page, rid: string, messageText: string) => { - return page.evaluate( - async ({ rid, msg }: { rid: string; msg: string }) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); - const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg }); - }, - { rid, msg: messageText }, - ); -}; - const sendEncryptedMessage = async (request: APIRequestContext, rid: string, encryptedMsg: string) => { return request.post(`${BASE_API_URL}/chat.sendMessage`, { headers: { @@ -80,11 +68,25 @@ test.describe('E2EE Legacy Format', () => { const rid = (await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room')) || ''; expect(rid).toBeTruthy(); - const encryptedMessage = await encryptLegacyMessage(page, rid, 'Old format message'); + const kid = '32c9e7917b78'; + const encryptedKey = + 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; + const encryptedMessage = + '3JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s='; - await sendEncryptedMessage(request, rid, encryptedMessage); + await page.evaluate( + async ({ rid, kid, encryptedKey }) => { + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e'); + const room = await e2e.getInstanceByRoomId(rid); + await room?.importGroupKey(kid + encryptedKey); + }, + { rid, kid, encryptedKey }, + ); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); + await sendEncryptedMessage(request, rid, kid + encryptedMessage); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('world'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); }); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts index b5e55231bad..d018e4315ba 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -42,105 +42,132 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms }); }); - test.beforeEach(async ({ api, page }) => { - const loginPage = new LoginPage(page); + test.describe('Generate', () => { + test.beforeEach(async ({ page, api }) => { + const loginPage = new LoginPage(page); - await api.post('/method.call/e2e.resetOwnE2EKey', { - message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), + await api.post('/method.call/e2e.resetOwnE2EKey', { + message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), + }); + + await page.goto('/home'); + await loginPage.waitForIt(); + await loginPage.loginByUserState(Users.admin); }); - await page.goto('/home'); - await loginPage.waitForIt(); - await loginPage.loginByUserState(Users.admin); + test('expect the randomly generated password to work', async ({ page }) => { + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const sidenav = new HomeSidenav(page); + + const password = await setupE2EEPassword(page); + + // Log out + await sidenav.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(password); + + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + + test('expect to manually reset the password', async ({ page }) => { + const accountSecurityPage = new AccountSecurityPage(page); + const loginPage = new LoginPage(page); + + // Reset the E2EE key to start the flow from the beginning + await accountSecurityPage.goto(); + await accountSecurityPage.resetE2EEPassword(); + + await loginPage.loginByUserState(Users.admin); + }); + + test('should reset e2e password from the modal', async ({ page }) => { + const sidenav = new HomeSidenav(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); + + await setupE2EEPassword(page); + + // Logout + await sidenav.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Reset E2EE password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.forgotPassword(); + await resetE2EEPasswordModal.confirmReset(); + + // restore login + await loginPage.loginByUserState(Users.admin); + }); + + test('expect to manually set a new password', async ({ page }) => { + const accountSecurityPage = new AccountSecurityPage(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const sidenav = new HomeSidenav(page); + + const newPassword = faker.string.uuid(); + + await setupE2EEPassword(page); + + // Set a new password + await accountSecurityPage.goto(); + await accountSecurityPage.setE2EEPassword(newPassword); + await accountSecurityPage.close(); + + // Log out + await sidenav.logout(); + + // Login again + await loginPage.loginByUserState(Users.admin); + + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(newPassword); + + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); }); - test('expect the randomly generated password to work', async ({ page }) => { - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - const sidenav = new HomeSidenav(page); + test.describe('Recovery', () => { + test.use({ storageState: Users.userE2EE.state }); - const password = await setupE2EEPassword(page); + test('expect to recover the keys using the recovery key', async ({ page }) => { + await test.step('Recover the keys', async () => { + await page.goto('/home'); + await injectInitialData(); + await restoreState(page, Users.userE2EE); + const sidenav = new HomeSidenav(page); + await sidenav.logout(); - // Log out - await sidenav.logout(); + const loginPage = new LoginPage(page); + await loginPage.loginByUserState(Users.userE2EE, { except: ['private_key', 'public_key'] }); - // Login again - await loginPage.loginByUserState(Users.admin); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - // Enter the saved password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(password); - - // No error banner - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); - }); - - test('expect to manually reset the password', async ({ page }) => { - const accountSecurityPage = new AccountSecurityPage(page); - const loginPage = new LoginPage(page); - - // Reset the E2EE key to start the flow from the beginning - await accountSecurityPage.goto(); - await accountSecurityPage.resetE2EEPassword(); - - await loginPage.loginByUserState(Users.admin); - }); - - test('should reset e2e password from the modal', async ({ page }) => { - const sidenav = new HomeSidenav(page); - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); - - await setupE2EEPassword(page); - - // Logout - await sidenav.logout(); - - // Login again - await loginPage.loginByUserState(Users.admin); - - // Reset E2EE password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.forgotPassword(); - await resetE2EEPasswordModal.confirmReset(); - - // restore login - await loginPage.loginByUserState(Users.admin); - }); - - test('expect to manually set a new password', async ({ page }) => { - const accountSecurityPage = new AccountSecurityPage(page); - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - const sidenav = new HomeSidenav(page); - - const newPassword = faker.string.uuid(); - - await setupE2EEPassword(page); - - // Set a new password - await accountSecurityPage.goto(); - await accountSecurityPage.setE2EEPassword(newPassword); - await accountSecurityPage.close(); - - // Log out - await sidenav.logout(); - - // Login again - await loginPage.loginByUserState(Users.admin); - - // Enter the saved password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(newPassword); - - // No error banner - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword('minus mobile dexter forest elvis'); + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + }); }); }); diff --git a/apps/meteor/tests/e2e/fixtures/userStates.ts b/apps/meteor/tests/e2e/fixtures/userStates.ts index 91de7ac9c19..6890955cf0a 100644 --- a/apps/meteor/tests/e2e/fixtures/userStates.ts +++ b/apps/meteor/tests/e2e/fixtures/userStates.ts @@ -24,7 +24,7 @@ const e2eeData: Record options.except.indexOf(item.name) === -1); + // Injects the login token to the local storage await this.page.evaluate((items) => { items.forEach(({ name, value }) => { @@ -46,7 +48,7 @@ export class LoginPage { }); // eslint-disable-next-line @typescript-eslint/no-var-requires require('meteor/accounts-base').Accounts._pollStoredLoginToken(); - }, userState.state.origins[0].localStorage); + }, localStorageItems); await this.waitForLogin(); } diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 33adf890dd3..d0442adc148 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -135,6 +135,30 @@ export type MessageMention = { export interface IMessageCustomFields {} +interface IEncryptedContent { + algorithm: string; + ciphertext: string; +} + +interface IEncryptedContentV1 extends IEncryptedContent { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; +} + +interface IEncryptedContentV2 extends IEncryptedContent { + algorithm: 'rc.v2.aes-sha2'; + ciphertext: string; + iv: string; // Initialization Vector + kid: string; // ID of the key used to encrypt the message +} + +interface IEncryptedContentFederation extends IEncryptedContent { + algorithm: 'm.megolm.v1.aes-sha2'; + ciphertext: string; +} + +export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2 | IEncryptedContentFederation; + export interface IMessage extends IRocketChatRecord { rid: RoomID; msg: string; @@ -232,26 +256,30 @@ export interface IMessage extends IRocketChatRecord { customFields?: IMessageCustomFields; - content?: { - algorithm: string; // 'rc.v1.aes-sha2' - ciphertext: string; // Encrypted subset JSON of IMessage - }; + content?: EncryptedContent; } -export type EncryptedMessageContent = { - content: { - algorithm: 'rc.v1.aes-sha2'; - ciphertext: string; - }; -}; - -export const isEncryptedMessageContent = (content: unknown): content is EncryptedMessageContent => - typeof content === 'object' && - content !== null && - 'content' in content && - typeof (content as any).content === 'object' && - (content as any).content?.algorithm === 'rc.v1.aes-sha2'; +export type EncryptedMessageContent = Required>; +export function isEncryptedMessageContent(value: unknown): value is EncryptedMessageContent { + return ( + typeof value === 'object' && + value !== null && + 'content' in value && + typeof value.content === 'object' && + value.content !== null && + 'algorithm' in value.content && + (value.content.algorithm === 'rc.v1.aes-sha2' || value.content.algorithm === 'rc.v2.aes-sha2') && + 'ciphertext' in value.content && + typeof value.content.ciphertext === 'string' && + (value.content.algorithm === 'rc.v1.aes-sha2' || + (value.content.algorithm === 'rc.v2.aes-sha2' && + 'iv' in value.content && + typeof value.content.iv === 'string' && + 'kid' in value.content && + typeof value.content.kid === 'string')) + ); +} export interface ISystemMessage extends IMessage { t: MessageTypesValues; } @@ -404,10 +432,12 @@ export const isVoipMessage = (message: IMessage): message is IVoipMessage => 'vo export type IE2EEMessage = IMessage & { t: 'e2e'; e2e: 'pending' | 'done'; + content: EncryptedContent; }; export type IE2EEPinnedMessage = IMessage & { t: 'message_pinned_e2e'; + attachments: [MessageAttachment & { content: EncryptedContent }]; }; export interface IOTRMessage extends IMessage { diff --git a/packages/core-typings/src/INotification.ts b/packages/core-typings/src/INotification.ts index eee671b9458..c50dc3d3a4a 100644 --- a/packages/core-typings/src/INotification.ts +++ b/packages/core-typings/src/INotification.ts @@ -64,6 +64,7 @@ export interface INotificationDesktop { message: { msg: IMessage['msg']; t?: IMessage['t']; + content?: IMessage['content']; }; audioNotificationValue: ISubscription['audioNotificationValue']; }; diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index e487151b94b..6918bff625d 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -1,3 +1,4 @@ +import type { EncryptedContent } from './IMessage'; import type { IUser } from './IUser'; export interface IUpload { @@ -48,10 +49,7 @@ export interface IUpload { Webdav?: { path: string; }; - content?: { - algorithm: string; // 'rc.v1.aes-sha2' - ciphertext: string; // Encrypted subset JSON of IUpload - }; + content?: EncryptedContent; encryption?: { iv: string; key: JsonWebKey; @@ -67,13 +65,12 @@ export interface IUpload { }; } -export type IUploadWithUser = IUpload & { user?: Pick }; +export interface IUploadWithUser extends IUpload { + user?: Pick; +} -export type IE2EEUpload = IUpload & { - content: { - algorithm: string; // 'rc.v1.aes-sha2' - ciphertext: string; // Encrypted subset JSON of IUpload - }; -}; +export interface IE2EEUpload extends IUpload { + content: EncryptedContent; +} export const isE2EEUpload = (upload: IUpload): upload is IE2EEUpload => Boolean(upload?.content?.ciphertext && upload?.content?.algorithm); diff --git a/packages/jest-presets/src/client/jest-setup.ts b/packages/jest-presets/src/client/jest-setup.ts index 4f0d7e39f2d..f513cfdca20 100644 --- a/packages/jest-presets/src/client/jest-setup.ts +++ b/packages/jest-presets/src/client/jest-setup.ts @@ -1,3 +1,4 @@ +import { webcrypto } from 'node:crypto'; import { TextEncoder, TextDecoder } from 'node:util'; import { toHaveNoViolations } from 'jest-axe'; @@ -10,6 +11,10 @@ expect.extend(toHaveNoViolations); const urlByBlob = new WeakMap(); const blobByUrl = new Map(); +Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, +}); + globalThis.URL.createObjectURL = (blob: Blob): string => { const url = urlByBlob.get(blob) ?? `blob://${uuid.v4()}`; urlByBlob.set(blob, url); diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index 35e3d57878f..0c2def3db31 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -5,6 +5,7 @@ const ajv = new Ajv({ coerceTypes: true, allowUnionTypes: true, code: { source: true }, + discriminator: true, }); // TODO: keep ajv extension here diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index adf48ced2e4..546a35200b0 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -488,17 +488,47 @@ const ChatUpdateSchema = { }, content: { type: 'object', - properties: { - algorithm: { - type: 'string', - enum: ['rc.v1.aes-sha2'], - }, - ciphertext: { - type: 'string', - }, + discriminator: { + propertyName: 'algorithm', }, - required: ['algorithm', 'ciphertext'], - additionalProperties: false, + oneOf: [ + { + type: 'object', + properties: { + algorithm: { + const: 'rc.v1.aes-sha2', + }, + ciphertext: { + type: 'string', + minLength: 1, + }, + }, + required: ['algorithm', 'ciphertext'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + algorithm: { + const: 'rc.v2.aes-sha2', + }, + ciphertext: { + type: 'string', + minLength: 1, + }, + iv: { + type: 'string', + minLength: 1, + }, + kid: { + type: 'string', + minLength: 1, + }, + }, + required: ['algorithm', 'ciphertext', 'iv', 'kid'], + additionalProperties: false, + }, + ], }, e2eMentions: { type: 'object',