RocketChat/Rocket.Chat

View on GitHub
apps/meteor/app/otr/client/OTRRoom.ts

Summary

Maintainability
D
2 days
Test Coverage
import type { IRoom, IMessage, IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import EJSON from 'ejson';
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';

import GenericModal from '../../../client/components/GenericModal';
import { imperativeModal } from '../../../client/lib/imperativeModal';
import type { UserPresence } from '../../../client/lib/presence';
import { Presence } from '../../../client/lib/presence';
import { dispatchToastMessage } from '../../../client/lib/toast';
import { getUidDirectMessage } from '../../../client/lib/utils/getUidDirectMessage';
import { goToRoomById } from '../../../client/lib/utils/goToRoomById';
import { sdk } from '../../utils/client/lib/SDKClient';
import { t } from '../../utils/lib/i18n';
import type { IOnUserStreamData, IOTRAlgorithm, IOTRDecrypt, IOTRRoom } from '../lib/IOTR';
import { OtrRoomState } from '../lib/OtrRoomState';
import { otrSystemMessages } from '../lib/constants';
import {
    decryptAES,
    deriveBits,
    digest,
    encryptAES,
    exportKey,
    generateKeyPair,
    importKey,
    importKeyRaw,
    joinEncryptedData,
} from '../lib/functions';

export class OTRRoom implements IOTRRoom {
    private _userId: string;

    private _roomId: string;

    private _keyPair: CryptoKeyPair | null;

    private _exportedPublicKey: JsonWebKey;

    private _sessionKey: CryptoKey | null;

    private _userOnlineComputation: Tracker.Computation;

    private peerId: string;

    private state: ReactiveVar<OtrRoomState> = new ReactiveVar(OtrRoomState.NOT_STARTED);

    private isFirstOTR: boolean;

    private onPresenceEventHook: (event: UserPresence | undefined) => void;

    protected constructor(uid: IUser['_id'], rid: IRoom['_id'], peerId: IUser['_id']) {
        this._userId = uid;
        this._roomId = rid;
        this._keyPair = null;
        this._sessionKey = null;
        this.peerId = peerId;
        this.isFirstOTR = true;
        this.onPresenceEventHook = this.onPresenceEvent.bind(this);
    }

    public static create(uid: IUser['_id'], rid: IRoom['_id']): OTRRoom | undefined {
        const peerId = getUidDirectMessage(rid);

        if (!peerId) {
            return undefined;
        }

        return new OTRRoom(uid, rid, peerId);
    }

    getPeerId(): string {
        return this.peerId;
    }

    getState(): OtrRoomState {
        return this.state.get();
    }

    setState(nextState: OtrRoomState): void {
        if (this.getState() === nextState) {
            return;
        }

        this.state.set(nextState);
    }

    async handshake(refresh?: boolean): Promise<void> {
        this.setState(OtrRoomState.ESTABLISHING);

        await this.generateKeyPair();
        sdk.publish('notify-user', [
            `${this.peerId}/otr`,
            'handshake',
            {
                roomId: this._roomId,
                userId: this._userId,
                publicKey: EJSON.stringify(this._exportedPublicKey),
                refresh,
            },
        ]);

        if (refresh) {
            const user = Meteor.user();
            if (!user) {
                return;
            }
            await sdk.rest.post('/v1/chat.otr', {
                roomId: this._roomId,
                type: otrSystemMessages.USER_REQUESTED_OTR_KEY_REFRESH,
            });
            this.isFirstOTR = false;
        }
    }

    onPresenceEvent(event: UserPresence | undefined): void {
        if (!event) {
            return;
        }
        if (event.status !== UserStatus.OFFLINE) {
            return;
        }
        console.warn(`OTR Room ${this._roomId} ended because ${this.peerId} went offline`);
        this.end();

        imperativeModal.open({
            component: GenericModal,
            props: {
                variant: 'warning',
                title: t('OTR'),
                children: t('OTR_Session_ended_other_user_went_offline', { username: event.username }),
                confirmText: t('Ok'),
                onClose: imperativeModal.close,
                onConfirm: imperativeModal.close,
            },
        });
    }

    // Starts listening to other user's status changes and end OTR if any of the Users goes offline
    // this should be called in 2 places: on acknowledge (meaning user accepted OTR) or on establish (meaning user initiated OTR)
    listenToUserStatus(): void {
        Presence.listen(this.peerId, this.onPresenceEventHook);
    }

    acknowledge(): void {
        void sdk.rest.post('/v1/statistics.telemetry', { params: [{ eventName: 'otrStats', timestamp: Date.now(), rid: this._roomId }] });

        sdk.publish('notify-user', [
            `${this.peerId}/otr`,
            'acknowledge',
            {
                roomId: this._roomId,
                userId: this._userId,
                publicKey: EJSON.stringify(this._exportedPublicKey),
            },
        ]);
    }

    deny(): void {
        this.reset();
        this.setState(OtrRoomState.DECLINED);
        sdk.publish('notify-user', [
            `${this.peerId}/otr`,
            'deny',
            {
                roomId: this._roomId,
                userId: this._userId,
            },
        ]);
    }

    softReset(): void {
        this.isFirstOTR = true;
        this.setState(OtrRoomState.NOT_STARTED);
        this._keyPair = null;
        this._exportedPublicKey = {};
        this._sessionKey = null;
    }

    end(): void {
        this.isFirstOTR = true;
        this.reset();
        this.setState(OtrRoomState.NOT_STARTED);
        Presence.stop(this.peerId, this.onPresenceEventHook);
        sdk.publish('notify-user', [
            `${this.peerId}/otr`,
            'end',
            {
                roomId: this._roomId,
                userId: this._userId,
            },
        ]);
    }

    reset(): void {
        this._keyPair = null;
        this._exportedPublicKey = {};
        this._sessionKey = null;
        void sdk.call('deleteOldOTRMessages', this._roomId);
    }

    async generateKeyPair(): Promise<void> {
        if (this._userOnlineComputation) {
            this._userOnlineComputation.stop();
        }

        this._userOnlineComputation = Tracker.autorun(() => {
            const $room = document.querySelector(`#chat-window-${this._roomId}`);
            const $title = $room?.querySelector('.rc-header__title');
            if (this.getState() === OtrRoomState.ESTABLISHED) {
                if ($room && $title && !$title.querySelector('.otr-icon')) {
                    $title.prepend("<i class='otr-icon icon-key'></i>");
                }
            } else if ($title) {
                $title.querySelector('.otr-icon')?.remove();
            }
        });
        try {
            // Generate an ephemeral key pair.
            this._keyPair = await generateKeyPair();

            if (!this._keyPair.publicKey) {
                throw new Error('Public key is not generated');
            }

            this._exportedPublicKey = await exportKey(this._keyPair.publicKey);

            // Once we have generated new keys, it's safe to delete old messages
            void sdk.call('deleteOldOTRMessages', this._roomId);
        } catch (e) {
            this.setState(OtrRoomState.ERROR);
            throw e;
        }
    }

    async importPublicKey(publicKey: string): Promise<void> {
        try {
            if (!this._keyPair) throw new Error('No key pair');
            const publicKeyObject: JsonWebKey = EJSON.parse(publicKey);
            const peerPublicKey = await importKey(publicKeyObject);
            const ecdhObj: IOTRAlgorithm = {
                name: 'ECDH',
                namedCurve: 'P-256',
                public: peerPublicKey,
            };
            const bits = await deriveBits({ ecdhObj, _keyPair: this._keyPair });
            const hashedBits = await digest(bits);
            // We truncate the hash to 128 bits.
            const sessionKeyData = new Uint8Array(hashedBits).slice(0, 16);
            // Session key available.
            this._sessionKey = await importKeyRaw(sessionKeyData);
        } catch (e) {
            this.setState(OtrRoomState.ERROR);
            throw e;
        }
    }

    async encryptText(data: string | Uint8Array): Promise<string> {
        if (typeof data === 'string') {
            data = new TextEncoder().encode(EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) }));
        }
        try {
            if (!this._sessionKey) throw new Error('Session Key not available');

            const iv = crypto.getRandomValues(new Uint8Array(12));
            const encryptedData = await encryptAES({ iv, _sessionKey: this._sessionKey, data });

            const output = joinEncryptedData({ encryptedData, iv });

            return EJSON.stringify(output);
        } catch (e) {
            this.setState(OtrRoomState.ERROR);
            throw new Meteor.Error('encryption-error', 'Encryption error.');
        }
    }

    async encrypt(message: Pick<IMessage, '_id' | 'msg'>): Promise<string> {
        try {
            const data = new TextEncoder().encode(
                EJSON.stringify({
                    _id: message._id,
                    text: message.msg,
                    userId: this._userId,
                    ack: Random.id((Random.fraction() + 1) * 20),
                    ts: new Date(),
                }),
            );
            const enc = await this.encryptText(data);
            return enc;
        } catch (e) {
            throw new Meteor.Error('encryption-error', 'Encryption error.');
        }
    }

    async decrypt(message: string): Promise<IOTRDecrypt | string> {
        try {
            if (!this._sessionKey) throw new Error('Session Key not available.');

            const cipherText: Uint8Array = EJSON.parse(message);
            const data = await decryptAES(cipherText, this._sessionKey);
            const msgDecoded: IOTRDecrypt = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(data)));
            if (msgDecoded && typeof msgDecoded === 'object') {
                return msgDecoded;
            }
            return message;
        } catch (e) {
            dispatchToastMessage({ type: 'error', message: e });
            this.setState(OtrRoomState.ERROR);
            return message;
        }
    }

    async onUserStream(type: string, data: IOnUserStreamData): Promise<void> {
        switch (type) {
            case 'handshake':
                let timeout: NodeJS.Timeout;

                const establishConnection = async (): Promise<void> => {
                    this.setState(OtrRoomState.ESTABLISHING);
                    clearTimeout(timeout);
                    try {
                        if (!data.publicKey) throw new Error('Public key is not generated');
                        await this.generateKeyPair();
                        await this.importPublicKey(data.publicKey);
                        await goToRoomById(data.roomId);
                        setTimeout(async () => {
                            this.setState(OtrRoomState.ESTABLISHED);
                            this.acknowledge();
                            this.listenToUserStatus();

                            if (data.refresh) {
                                await sdk.rest.post('/v1/chat.otr', {
                                    roomId: this._roomId,
                                    type: otrSystemMessages.USER_KEY_REFRESHED_SUCCESSFULLY,
                                });
                            }
                        }, 0);
                    } catch (e) {
                        dispatchToastMessage({ type: 'error', message: e });
                        throw new Meteor.Error('establish-connection-error', 'Establish connection error.');
                    }
                };

                const closeOrCancelModal = (): void => {
                    clearTimeout(timeout);
                    this.deny();
                    imperativeModal.close();
                };

                try {
                    const obj = await Presence.get(data.userId);
                    if (!obj?.username) {
                        throw new Meteor.Error('user-not-defined', 'User not defined.');
                    }

                    if (data.refresh && this.getState() === OtrRoomState.ESTABLISHED) {
                        this.reset();
                        await establishConnection();
                    } else {
                        /*     We have to check if there's an in progress handshake request because
                            Notifications.notifyUser will sometimes dispatch 2 events */
                        if (this.getState() === OtrRoomState.REQUESTED) {
                            return;
                        }

                        if (this.getState() === OtrRoomState.ESTABLISHED) {
                            this.reset();
                        }

                        this.setState(OtrRoomState.REQUESTED);
                        imperativeModal.open({
                            component: GenericModal,
                            props: {
                                variant: 'warning',
                                title: t('OTR'),
                                children: t('Username_wants_to_start_otr_Do_you_want_to_accept', {
                                    username: obj.username,
                                }),
                                confirmText: t('Yes'),
                                cancelText: t('No'),
                                onClose: (): void => closeOrCancelModal(),
                                onCancel: (): void => closeOrCancelModal(),
                                onConfirm: async (): Promise<void> => {
                                    await establishConnection();
                                    imperativeModal.close();
                                },
                            },
                        });
                        timeout = setTimeout(() => {
                            this.setState(OtrRoomState.TIMEOUT);
                            imperativeModal.close();
                        }, 10000);
                    }
                } catch (e) {
                    dispatchToastMessage({ type: 'error', message: e });
                }
                break;

            case 'acknowledge':
                try {
                    if (!data.publicKey) throw new Error('Public key is not generated');
                    await this.importPublicKey(data.publicKey);

                    this.setState(OtrRoomState.ESTABLISHED);

                    if (this.isFirstOTR) {
                        this.listenToUserStatus();
                        await sdk.rest.post('/v1/chat.otr', {
                            roomId: this._roomId,
                            type: otrSystemMessages.USER_JOINED_OTR,
                        });
                    }
                    this.isFirstOTR = false;
                } catch (e) {
                    dispatchToastMessage({ type: 'error', message: e });
                }
                break;

            case 'deny':
                if (this.getState() === OtrRoomState.ESTABLISHING) {
                    this.reset();
                    this.setState(OtrRoomState.DECLINED);
                }
                break;

            case 'end':
                try {
                    const obj = await Presence.get(this.peerId);
                    if (!obj?.username) {
                        throw new Meteor.Error('user-not-defined', 'User not defined.');
                    }

                    if (this.getState() === OtrRoomState.ESTABLISHED) {
                        this.reset();
                        this.setState(OtrRoomState.NOT_STARTED);
                        imperativeModal.open({
                            component: GenericModal,
                            props: {
                                variant: 'warning',
                                title: t('OTR'),
                                children: t('Username_ended_the_OTR_session', { username: obj.username }),
                                confirmText: t('Ok'),
                                onClose: imperativeModal.close,
                                onConfirm: imperativeModal.close,
                            },
                        });
                    }
                } catch (e) {
                    dispatchToastMessage({ type: 'error', message: e });
                }

                break;
        }
    }
}