cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit 882942f9737bf5f96965f3160bd5f2ad4e4cdcbd
parent 990c77891408340e172f391166e443326b393097
Author: MTRNord <mtrnord1@gmail.com>
Date:   Fri,  5 May 2023 17:48:40 +0200

Implement E2EE decryption

Diffstat:
Msrc/app/sdk/api/apiTypes.ts | 21++++++++++++++++++++-
Msrc/app/sdk/client.ts | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Msrc/app/sdk/room.ts | 10++++++++++
Msrc/main.tsx | 7++++++-
Msrc/pages/MainPage.tsx | 66+++++++++++++++++++++++++++++++++++++++++++++++++-----------------
5 files changed, 141 insertions(+), 34 deletions(-)

diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts @@ -182,7 +182,26 @@ export interface ISlidingSyncResp { txn_id: string; } -export interface Extensions { } +export interface Extensions { + e2ee?: E2EEExtension; + to_device?: ToDeviceExtension; +} + +export interface E2EEExtension { + device_one_time_keys_count?: { + [key: string]: number; + }; + device_lists?: { + changed?: string[]; + left?: string[]; + }; + device_unused_fallback_key_types?: string[]; +} + +export interface ToDeviceExtension { + next_batch: string; + events?: any[]; +} export interface List { ops?: (SYNC_OP | INSERT_OP | INVALIDATE_OP | DELETE_OP)[]; diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts @@ -27,7 +27,7 @@ import { IDBPDatabase, openDB } from "idb"; -import { DeviceId, KeysBackupRequest, KeysUploadRequest, OlmMachine, RequestType, RoomMessageRequest, SignatureUploadRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js"; +import { DeviceId, DeviceLists, KeysBackupRequest, KeysUploadRequest, OlmMachine, RequestType, RoomMessageRequest, SignatureUploadRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js"; import { KeysQueryRequest } from "@matrix-org/matrix-sdk-crypto-js"; import { KeysClaimRequest } from "@matrix-org/matrix-sdk-crypto-js"; import { ToDeviceRequest } from "@matrix-org/matrix-sdk-crypto-js"; @@ -91,6 +91,7 @@ interface MatrixDB extends DBSchema { initialSync: boolean; lastRanges?: { [key: string]: number[][] }; // [start, end] lastTxnID?: string; + to_device_since?: string; }; // User ID key: string; @@ -116,7 +117,8 @@ export class MatrixClient extends EventEmitter { private profileInfo?: IProfileInfo; private lastRanges?: { [key: string]: number[][] }; private lastTxnID?: string; - private olmMachine?: OlmMachine; + private to_device_since?: string; + public olmMachine?: OlmMachine; public get isLoggedIn(): boolean { return this.access_token !== undefined; @@ -156,6 +158,8 @@ export class MatrixClient extends EventEmitter { instance.initialSync = syncInfo.initialSync; instance.lastRanges = syncInfo.lastRanges; instance.lastTxnID = syncInfo.lastTxnID; + console.log("to_device_since:", syncInfo.to_device_since) + instance.to_device_since = syncInfo.to_device_since; } // Load rooms @@ -193,20 +197,22 @@ export class MatrixClient extends EventEmitter { } private async createDatabase() { - this.database = await openDB<MatrixDB>("matrix", 3, { - upgrade(db) { - if (db.objectStoreNames.contains("rooms")) { - db.deleteObjectStore("rooms"); - } - //if (db.objectStoreNames.contains("loginInfo")) { - // db.deleteObjectStore("loginInfo"); - //} - if (db.objectStoreNames.contains("syncInfo")) { - db.deleteObjectStore("syncInfo"); + this.database = await openDB<MatrixDB>("matrix", 4, { + upgrade(db, oldVersion) { + if (oldVersion < 1) { + if (db.objectStoreNames.contains("rooms")) { + db.deleteObjectStore("rooms"); + } + //if (db.objectStoreNames.contains("loginInfo")) { + // db.deleteObjectStore("loginInfo"); + //} + if (db.objectStoreNames.contains("syncInfo")) { + db.deleteObjectStore("syncInfo"); + } + db.createObjectStore('rooms', { keyPath: 'roomID' }); + db.createObjectStore('loginInfo', { keyPath: 'userId' }); + db.createObjectStore('syncInfo', { keyPath: 'userId' }); } - db.createObjectStore('rooms', { keyPath: 'roomID' }); - db.createObjectStore('loginInfo', { keyPath: 'userId' }); - db.createObjectStore('syncInfo', { keyPath: 'userId' }); } }); } @@ -574,6 +580,7 @@ export class MatrixClient extends EventEmitter { url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=30000&pos=${this.syncPos}` } + console.log("to_device_since:", this.to_device_since) const resp = await fetch(url, { method: "POST", headers: { @@ -641,6 +648,11 @@ export class MatrixClient extends EventEmitter { extensions: { e2ee: { enabled: true, + }, + to_device: { + enabled: true, + // TODO: if not initial sync add since token + since: this.to_device_since || null } } }) @@ -666,6 +678,26 @@ export class MatrixClient extends EventEmitter { const json = await resp.json() as ISlidingSyncResp; this.syncPos = json.pos; + if (json.extensions?.to_device) { + console.log("Processing to_device events") + await this.olmMachine?.receiveSyncChanges( + JSON.stringify(json.extensions.to_device.events || []), + new DeviceLists( + json.extensions.e2ee?.device_lists?.changed?.map( + user_id => new UserId(user_id) + ), + json.extensions.e2ee?.device_lists?.left?.map( + user_id => new UserId(user_id) + ) + ), + new Map(Object.entries(json.extensions.e2ee?.device_one_time_keys_count || [])), + new Set(json.extensions.e2ee?.device_unused_fallback_key_types) + ); + this.to_device_since = json.extensions.to_device.next_batch; + } + + await this.sendIdentifyAndOneTimeKeys(); + const syncInfoTX = this.database?.transaction('syncInfo', 'readwrite'); await syncInfoTX?.store.put({ @@ -674,6 +706,7 @@ export class MatrixClient extends EventEmitter { initialSync: this.initialSync, lastRanges: this.lastRanges, lastTxnID: this.lastTxnID, + to_device_since: this.to_device_since, }); await syncInfoTX?.done; @@ -888,6 +921,14 @@ export class MatrixClient extends EventEmitter { if (state_events) { roomObj.addStateEvents(state_events); } + if (required_state || state_events) { + if (roomObj.isEncrypted()) { + const joinEvents = [...(required_state || []), ...(state_events || [])] + .filter(event => event.type === "m.room.member" && event.content.membership === "join"); + const memberIds = joinEvents.map(event => new UserId(event.state_key)); + await this.olmMachine?.updateTrackedUsers(memberIds); + } + } if (is_dm) { roomObj.setDM(is_dm); } diff --git a/src/app/sdk/room.ts b/src/app/sdk/room.ts @@ -185,4 +185,14 @@ export class Room { }); return avatarURL; } + + public isEncrypted(): boolean { + let isEncrypted: boolean = false; + this.stateEvents.forEach((event) => { + if (event.type === "m.room.encryption" && event.content.algorithm === "m.megolm.v1.aes-sha2" && event.state_key === "") { + isEncrypted = true; + } + }); + return isEncrypted; + } } \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx @@ -1,10 +1,15 @@ /// <reference types="./@types/global.d.ts" /> -import { initAsync, start } from "@matrix-org/matrix-sdk-crypto-js"; +import { LoggerLevel, Tracing, initAsync, start } from "@matrix-org/matrix-sdk-crypto-js"; import React from 'react' import { createRoot } from 'react-dom/client'; import './index.scss'; initAsync().then(() => { + if (Tracing.isAvailable()) { + console.log("Tracing is available, turning on"); + let tracing = new Tracing(LoggerLevel.Trace); + tracing.turnOn(); + } start(); import('./app/sdk/client').then(({ MatrixContext, defaultMatrixClient }) => { diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx @@ -15,6 +15,7 @@ import UnknownEvent from '../components/events/unknownEvent'; import MemberEvent from '../components/events/memberEvent'; import { IRoomEvent, IRoomMemberEvent } from '../app/sdk/api/apiTypes'; import Linkify from 'linkify-react'; +import { RoomId } from '@matrix-org/matrix-sdk-crypto-js'; type ChatViewProps = { /** @@ -34,8 +35,56 @@ type ChatViewProps = { const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => { const room = useRoom(decodeURIComponent(roomID || "")); + const client = useContext(MatrixContext); const events = room?.getEvents(); const { pathname } = useLocation(); + const [renderedEvents, setRenderedEvents] = useState<JSX.Element[]>([]); + + // Map events to components but also tell components if the previous event was from the same sender and which type it was + const renderEvents = (events: IRoomEvent[]) => { + const dedupedEvents = events?.filter((event, index, self) => { + return self.findIndex(e => e.event_id === event.event_id) === index; + }); + + + Promise.all(dedupedEvents?.filter(event => event.type !== "m.reaction").map(async (event, index) => { + const previousEvent = dedupedEvents?.filter(event => event.type !== "m.reaction")[index - 1]; + const previousEventIsFromSameSender = previousEvent?.sender === event.sender; + let previousEventType = previousEvent?.type; + + // Make a list of events which are reactions for the current event we want to render + const reactions = dedupedEvents?.filter((e) => { + return e.type === "m.reaction" && e.content["m.relates_to"].event_id === event.event_id; + }); + + // Decrypt the event if it is encrypte + if (event.type === "m.room.encrypted") { + try { + const decrypted_event = await client.olmMachine?.decryptRoomEvent(JSON.stringify(event), new RoomId(roomID || "")); + if (decrypted_event) { + event = JSON.parse(decrypted_event.event) as IRoomEvent; + previousEventType = event.type; + } else { + return (<p>Unable to decrypt event</p>) + } + } catch (e: any) { + console.error(e); + } + } + + return renderEvent(event, previousEventIsFromSameSender, previousEventType, reactions); + })).then((renderedEvents) => { + setRenderedEvents(renderedEvents); + }); + } + + useEffect(() => { + if (events) { + renderEvents(events); + } + }, [events]); + + useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current?.scrollHeight); }, [pathname]); @@ -54,23 +103,6 @@ const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => { } } - const dedupedEvents = events?.filter((event, index, self) => { - return self.findIndex(e => e.event_id === event.event_id) === index; - }); - - // Map events to components but also tell components if the previous event was from the same sender and which type it was - const renderedEvents = dedupedEvents?.filter(event => event.type !== "m.reaction").map((event, index) => { - const previousEvent = dedupedEvents?.filter(event => event.type !== "m.reaction")[index - 1]; - const previousEventIsFromSameSender = previousEvent?.sender === event.sender; - const previousEventType = previousEvent?.type; - - // Make a list of events which are reactions for the current event we want to render - const reactions = dedupedEvents?.filter((e) => { - return e.type === "m.reaction" && e.content["m.relates_to"].event_id === event.event_id; - }); - - return renderEvent(event, previousEventIsFromSameSender, previousEventType, reactions); - }); return <div className='max-w-[130ch] flex flex-col'>{renderedEvents}</div>; });