Skip to content

Commit 028635c

Browse files
committed
Support decryption
1 parent e277656 commit 028635c

File tree

6 files changed

+185
-17
lines changed

6 files changed

+185
-17
lines changed

examples/encryption_bot.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
2+
EncryptedRoomEvent,
23
EncryptionAlgorithm,
34
LogLevel,
45
LogService,
5-
MatrixClient,
6-
RichConsoleLogger,
6+
MatrixClient, MessageEvent,
7+
RichConsoleLogger, RichReply,
78
SimpleFsStorageProvider
89
} from "../src";
910
import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider";
@@ -50,21 +51,27 @@ const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);
5051
],
5152
});
5253
}
53-
await sendEncryptedNotice(encryptedRoomId, "This is an encrypted room");
5454

55-
client.on("room.event", (roomId: string, event: any) => {
56-
if (roomId !== encryptedRoomId) return;
57-
LogService.debug("index", `${roomId}`, event);
55+
client.on("room.event", async (roomId: string, event: any) => {
56+
if (roomId !== encryptedRoomId || event['type'] !== "m.room.encrypted") return;
57+
58+
try {
59+
const decrypted = await client.crypto.decryptRoomEvent(new EncryptedRoomEvent(event), roomId);
60+
if (decrypted.type === "m.room.message") {
61+
const message = new MessageEvent(decrypted.raw);
62+
if (message.messageType !== "m.text") return;
63+
if (message.textBody.startsWith("!ping")) {
64+
const reply = RichReply.createFor(roomId, message.raw, "Pong", "Pong");
65+
reply['msgtype'] = "m.notice";
66+
const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", reply);
67+
await client.sendEvent(roomId, "m.room.encrypted", encrypted);
68+
}
69+
}
70+
} catch (e) {
71+
LogService.error("index", e);
72+
}
5873
});
5974

6075
LogService.info("index", "Starting bot...");
6176
await client.start();
6277
})();
63-
64-
async function sendEncryptedNotice(roomId: string, text: string) {
65-
const encrypted = await client.crypto.encryptRoomEvent(roomId, "m.room.message", {
66-
msgtype: "m.notice",
67-
body: text,
68-
});
69-
await client.sendEvent(roomId, "m.room.encrypted", encrypted);
70-
}

src/e2ee/CryptoClient.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { requiresReady } from "./decorators";
2222
import { RoomTracker } from "./RoomTracker";
2323
import { DeviceTracker } from "./DeviceTracker";
2424
import { EncryptionEvent } from "../models/events/EncryptionEvent";
25+
import { EncryptedRoomEvent } from "../models/events/EncryptedRoomEvent";
26+
import { RoomEvent } from "../models/events/RoomEvent";
2527

2628
/**
2729
* Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly
@@ -506,6 +508,57 @@ export class CryptoClient {
506508
}
507509
}
508510

511+
/**
512+
* Decrypts a room event. Currently only supports Megolm-encrypted events (default for this SDK).
513+
* @param {EncryptedRoomEvent} event The encrypted event.
514+
* @param {string} roomId The room ID where the event was sent.
515+
* @returns {Promise<RoomEvent<unknown>>} Resolves to a decrypted room event, or rejects/throws with
516+
* an error if the event is undecryptable.
517+
*/
518+
public async decryptRoomEvent(event: EncryptedRoomEvent, roomId: string): Promise<RoomEvent<unknown>> {
519+
if (event.algorithm !== EncryptionAlgorithm.MegolmV1AesSha2) {
520+
throw new Error("Unable to decrypt: Unknown algorithm");
521+
}
522+
523+
const encrypted = event.megolmProperties;
524+
const senderDevice = await this.client.cryptoStore.getUserDevice(event.sender, encrypted.device_id);
525+
if (!senderDevice) {
526+
throw new Error("Unable to decrypt: Unknown device for sender");
527+
}
528+
529+
if (senderDevice.keys[`${DeviceKeyAlgorithm.Curve25519}:${senderDevice.device_id}`] !== encrypted.sender_key) {
530+
throw new Error("Unable to decrypt: Device key mismatch");
531+
}
532+
533+
const storedSession = await this.client.cryptoStore.getInboundGroupSession(event.sender, encrypted.device_id, roomId, encrypted.session_id);
534+
if (!storedSession) {
535+
throw new Error("Unable to decrypt: Unknown inbound session ID");
536+
}
537+
538+
const session = new Olm.InboundGroupSession();
539+
try {
540+
session.unpickle(this.pickleKey, storedSession.pickled);
541+
const cleartext = session.decrypt(encrypted.ciphertext) as { plaintext: string, message_index: number };
542+
const eventBody = JSON.parse(cleartext.plaintext);
543+
const messageIndex = cleartext.message_index;
544+
545+
const existingEventId = await this.client.cryptoStore.getEventForMessageIndex(roomId, storedSession.sessionId, messageIndex);
546+
if (existingEventId && existingEventId !== event.eventId) {
547+
throw new Error("Unable to decrypt: Message replay attack");
548+
}
549+
550+
await this.client.cryptoStore.setMessageIndexForEvent(roomId, event.eventId, storedSession.sessionId, messageIndex);
551+
552+
return new RoomEvent<unknown>({
553+
...event.raw,
554+
type: eventBody.type || "io.t2bot.unknown",
555+
content: (typeof(eventBody.content) === 'object') ? eventBody.content : {},
556+
});
557+
} finally {
558+
session.free();
559+
}
560+
}
561+
509562
/**
510563
* Handles an inbound to-device message, decrypting it if needed. This will not throw
511564
* under normal circumstances and should always resolve successfully.
@@ -593,14 +646,14 @@ export class CryptoClient {
593646
session.free();
594647
}
595648

596-
const wasForUs = decrypted.recipient !== (await this.client.getUserId());
649+
const wasForUs = decrypted.recipient === (await this.client.getUserId());
597650
const wasFromThem = decrypted.sender === message.sender;
598651
const hasType = typeof(decrypted.type) === 'string';
599652
const hasContent = typeof(decrypted.content) === 'object';
600653
const ourKeyMatches = decrypted.recipient_keys?.ed25519 === this.deviceEd25519;
601654
const theirKeyMatches = decrypted.keys?.ed25519 === senderDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${senderDevice.device_id}`];
602655
if (!wasForUs || !wasFromThem || !hasType || !hasContent || !ourKeyMatches || !theirKeyMatches) {
603-
LogService.warn("CryptoClient", "Successfully decrypted to-device message, but if failed validation. Ignoring message.");
656+
LogService.warn("CryptoClient", "Successfully decrypted to-device message, but it failed validation. Ignoring message.");
604657
return;
605658
}
606659

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export * from "./models/events/RoomNameEvent";
6868
export * from "./models/events/RoomTopicEvent";
6969
export * from "./models/events/SpaceChildEvent";
7070
export * from "./models/events/EncryptionEvent";
71+
export * from "./models/events/EncryptedRoomEvent";
7172

7273
// Preprocessors
7374
export * from "./preprocessors/IPreprocessor";
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { RoomEvent } from "./RoomEvent";
2+
import { EncryptionAlgorithm, IMegolmEncrypted } from "../Crypto";
3+
4+
/**
5+
* The content definition for m.room.encrypted events
6+
* @category Matrix event contents
7+
* @see EncryptedRoomEvent
8+
*/
9+
export interface EncryptedRoomEventContent {
10+
algorithm: EncryptionAlgorithm;
11+
12+
/**
13+
* For m.megolm.v1.aes-sha2 messages. The sender's Curve25519 key.
14+
*/
15+
sender_key?: string;
16+
17+
/**
18+
* For m.megolm.v1.aes-sha2 messages. The session ID established by the sender.
19+
*/
20+
session_id?: string;
21+
22+
/**
23+
* For m.megolm.v1.aes-sha2 messages. The encrypted payload.
24+
*/
25+
ciphertext?: string;
26+
27+
/**
28+
* For m.megolm.v1.aes-sha2 messages. The sender's device ID.
29+
*/
30+
device_id?: string;
31+
32+
// Other algorithms not supported at the moment
33+
}
34+
35+
/**
36+
* Represents an m.room.encrypted room event
37+
* @category Matrix events
38+
*/
39+
export class EncryptedRoomEvent extends RoomEvent<EncryptedRoomEventContent> {
40+
constructor(event: any) {
41+
super(event);
42+
}
43+
44+
/**
45+
* The encryption algorithm used on the event. Should match the m.room.encryption
46+
* state config.
47+
*/
48+
public get algorithm(): EncryptionAlgorithm {
49+
return this.content.algorithm;
50+
}
51+
52+
/**
53+
* The Megolm encrypted payload information.
54+
*/
55+
public get megolmProperties(): IMegolmEncrypted {
56+
return this.content as IMegolmEncrypted;
57+
}
58+
}

src/storage/ICryptoStorageProvider.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { EncryptionEventContent } from "../models/events/EncryptionEvent";
2-
import { IInboundGroupSession, IOlmSession, IOutboundGroupSession, UserDevice } from "../models/Crypto";
2+
import {
3+
IInboundGroupSession,
4+
IOlmSession,
5+
IOutboundGroupSession,
6+
UserDevice,
7+
} from "../models/Crypto";
38

49
/**
510
* A storage provider capable of only providing crypto-related storage.
@@ -198,4 +203,27 @@ export interface ICryptoStorageProvider {
198203
* @returns {Promise<IInboundGroupSession>} Resolves to the session, or falsy if not known.
199204
*/
200205
getInboundGroupSession(senderUserId: string, senderDeviceId: string, roomId: string, sessionId: string): Promise<IInboundGroupSession>;
206+
207+
/**
208+
* Sets the successfully decrypted message index for an event. Useful for tracking replay attacks.
209+
* @param {string} roomId The room ID where the event was sent.
210+
* @param {string} eventId The event ID.
211+
* @param {string} sessionId The inbound group session ID for the event.
212+
* @param {number} messageIndex The message index, as reported after decryption.
213+
* @returns {Promise<void>} Resolves when complete.
214+
*/
215+
setMessageIndexForEvent(roomId: string, eventId: string, sessionId: string, messageIndex: number): Promise<void>;
216+
217+
/**
218+
* Gets the event ID for a previously successful decryption from a session and message index. If
219+
* no event ID is known, this will return falsy. The caller can use this function to determine if
220+
* a replay attack is being performed by checking the returned event ID, if present, against the
221+
* event ID of the event it is decrypting. If the event IDs do not match but are truthy then the
222+
* session may have been inappropriately re-used.
223+
* @param {string} roomId The room ID.
224+
* @param {string} sessionId The inbound group session ID.
225+
* @param {number} messageIndex The message index.
226+
* @returns {Promise<string>} Resolves to the event ID of the matching event, or falsy if not known.
227+
*/
228+
getEventForMessageIndex(roomId: string, sessionId: string, messageIndex: number): Promise<string>;
201229
}

src/storage/SqliteCryptoStorageProvider.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
3232
private olmSessionSelect: Database.Statement;
3333
private ibGroupSessionUpsert: Database.Statement;
3434
private ibGroupSessionSelect: Database.Statement;
35+
private deMetadataUpsert: Database.Statement;
36+
private deMetadataSelect: Database.Statement;
3537

3638
/**
3739
* Creates a new Sqlite storage provider.
@@ -49,6 +51,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
4951
this.db.exec("CREATE TABLE IF NOT EXISTS sent_outbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, session_index INT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id, session_index))");
5052
this.db.exec("CREATE TABLE IF NOT EXISTS olm_sessions (user_id TEXT NOT NULL, device_id TEXT NOT NULL, session_id TEXT NOT NULL, last_decryption_ts NUMBER NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (user_id, device_id, session_id))");
5153
this.db.exec("CREATE TABLE IF NOT EXISTS inbound_group_sessions (session_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL, device_id TEXT NOT NULL, pickled TEXT NOT NULL, PRIMARY KEY (session_id, room_id, user_id, device_id))");
54+
this.db.exec("CREATE TABLE IF NOT EXISTS decrypted_event_metadata (room_id TEXT NOT NULL, event_id TEXT NOT NULL, session_id TEXT NOT NULL, message_index INT NOT NULL, PRIMARY KEY (room_id, event_id))");
55+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_decrypted_event_metadata_by_message_index ON decrypted_event_metadata (room_id, session_id, message_index)");
5256

5357
this.kvUpsert = this.db.prepare("INSERT INTO kv (name, value) VALUES (@name, @value) ON CONFLICT (name) DO UPDATE SET value = @value");
5458
this.kvSelect = this.db.prepare("SELECT name, value FROM kv WHERE name = @name");
@@ -79,6 +83,9 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
7983

8084
this.ibGroupSessionUpsert = this.db.prepare("INSERT INTO inbound_group_sessions (session_id, room_id, user_id, device_id, pickled) VALUES (@sessionId, @roomId, @userId, @deviceId, @pickled) ON CONFLICT (session_id, room_id, user_id, device_id) DO UPDATE SET pickled = @pickled");
8185
this.ibGroupSessionSelect = this.db.prepare("SELECT session_id, room_id, user_id, device_id, pickled FROM inbound_group_sessions WHERE session_id = @sessionId AND room_id = @roomId AND user_id = @userId AND device_id = @deviceId");
86+
87+
this.deMetadataUpsert = this.db.prepare("INSERT INTO decrypted_event_metadata (room_id, event_id, session_id, message_index) VALUES (@roomId, @eventId, @sessionId, @messageIndex) ON CONFLICT (room_id, event_id) DO UPDATE SET message_index = @messageIndex, session_id = @sessionId");
88+
this.deMetadataSelect = this.db.prepare("SELECT room_id, event_id, session_id, message_index FROM decrypted_event_metadata WHERE room_id = @roomId AND session_id = @sessionId AND message_index = @messageIndex LIMIT 1");
8289
}
8390

8491
public async setDeviceId(deviceId: string): Promise<void> {
@@ -296,6 +303,20 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
296303
return null;
297304
}
298305

306+
public async setMessageIndexForEvent(roomId: string, eventId: string, sessionId: string, messageIndex: number): Promise<void> {
307+
this.deMetadataUpsert.run({
308+
roomId: roomId,
309+
eventId: eventId,
310+
sessionId: sessionId,
311+
messageIndex: messageIndex,
312+
});
313+
}
314+
315+
public async getEventForMessageIndex(roomId: string, sessionId: string, messageIndex: number): Promise<string> {
316+
const result = this.deMetadataSelect.get({roomId: roomId, sessionId: sessionId, messageIndex: messageIndex});
317+
return result?.event_id;
318+
}
319+
299320
/**
300321
* Closes the crypto store. Primarily for testing purposes.
301322
*/

0 commit comments

Comments
 (0)