Skip to content

Commit

Permalink
WIP for sending encrypted events
Browse files Browse the repository at this point in the history
  • Loading branch information
turt2live committed Aug 4, 2021
1 parent bc51f92 commit e42a44b
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 4 deletions.
47 changes: 46 additions & 1 deletion examples/encryption_bot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { LogLevel, LogService, MatrixClient, RichConsoleLogger, SimpleFsStorageProvider } from "../src";
import {
EncryptionAlgorithm,
LogLevel,
LogService,
MatrixClient,
RichConsoleLogger,
SimpleFsStorageProvider
} from "../src";
import { SqliteCryptoStorageProvider } from "../src/storage/SqliteCryptoStorageProvider";

LogService.setLogger(new RichConsoleLogger());
Expand All @@ -13,6 +20,7 @@ try {
// ignore
}

const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost";
const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008";
const accessToken = creds?.['accessToken'] ?? 'YOUR_TOKEN';
const storage = new SimpleFsStorageProvider("./examples/storage/encryption_bot.json");
Expand All @@ -21,10 +29,47 @@ const crypto = new SqliteCryptoStorageProvider("./examples/storage/encryption_bo
const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);

(async function() {
let encryptedRoomId: string;
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms); // init crypto because we're doing things before the client is started
for (const roomId of joinedRooms) {
if (await client.crypto.isRoomEncrypted(roomId)) {
encryptedRoomId = roomId;
break;
}
}
if (!encryptedRoomId) {
encryptedRoomId = await client.createRoom({
invite: [dmTarget],
is_direct: true,
visibility: "private",
preset: "trusted_private_chat",
initial_state: [
{type: "m.room.encryption", state_key: "", content: {algorithm: EncryptionAlgorithm.MegolmV1AesSha2}},
{type: "m.room.guest_access", state_key: "", content: {guest_access: "can_join"}},
],
});
}
await sendEncryptedNotice(encryptedRoomId, "This is an encrypted room");

client.on("room.event", (roomId: string, event: any) => {
if (roomId !== encryptedRoomId) return;
LogService.debug("index", `${roomId}`, event);
});

LogService.info("index", "Starting bot...");
await client.start();
})();

async function sendEncryptedNotice(roomId: string, text: string) {
const payload = {
room_id: roomId,
type: "m.room.message",
content: {
msgtype: "m.notice",
body: text,
},
};


}
2 changes: 1 addition & 1 deletion src/MatrixClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ export class MatrixClient extends EventEmitter {
* @returns {Promise<string>} resolves to the event ID that represents the event
*/
@timedMatrixClientFunctionCall()
public sendEvent(roomId: string, eventType: string, content: any): Promise<string> {
public async sendEvent(roomId: string, eventType: string, content: any): Promise<string> {
const txnId = (new Date().getTime()) + "__inc" + (++this.requestId);
return this.doRequest("PUT", "/_matrix/client/r0/rooms/" + encodeURIComponent(roomId) + "/send/" + encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId), null, content).then(response => {
return response['event_id'];
Expand Down
71 changes: 71 additions & 0 deletions src/e2ee/CryptoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { requiresReady } from "./decorators";
import { RoomTracker } from "./RoomTracker";
import { DeviceTracker } from "./DeviceTracker";
import { EncryptionEvent } from "../models/events/EncryptionEvent";

/**
* Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly
Expand Down Expand Up @@ -227,6 +228,76 @@ export class CryptoClient {
* @param {boolean} resync True (default) to queue an immediate update, false otherwise.
*/
public flagUsersDeviceListsOutdated(userIds: string[], resync = true) {
// noinspection JSIgnoredPromiseFromCall
this.deviceTracker.flagUsersOutdated(userIds, resync);
}

/**
* Encrypts the details of a room event, returning an encrypted payload to be sent in an
* `m.room.encrypted` event to the room. If needed, this function will send decryption keys
* to the appropriate devices in the room (this happens when the Megolm session rotates or
* gets created).
* @param {string} roomId The room ID to encrypt within. If the room is not encrypted, an
* error is thrown.
* @param {string} eventType The event type being encrypted.
* @param {any} content The event content being encrypted.
* @returns {Promise<any>} Resolves to the encrypted content for an `m.room.encrypted` event.
*/
public async encryptEvent(roomId: string, eventType: string, content: any): Promise<any> {
if (!(await this.isRoomEncrypted(roomId))) {
throw new Error("Room is not encrypted");
}

const now = (new Date()).getTime();

let currentSession = await this.client.cryptoStore.getCurrentOutboundGroupSession(roomId);
if (currentSession && (currentSession.expiresTs <= now || currentSession.usesLeft <= 0)) {
currentSession = null; // force rotation
}
if (!currentSession) {
// Make a new session, either because we don't have one or it rotated.
const roomConfig = new EncryptionEvent({
type: "m.room.encryption",
state_key: "",
content: await this.roomTracker.getRoomCryptoConfig(roomId),
});

const session = new Olm.OutboundGroupSession();
try {
session.create();
const pickled = session.pickle(this.pickleKey);
currentSession = {
sessionId: session.session_id(),
roomId: roomId,
pickled: pickled,
isCurrent: true,
usesLeft: roomConfig.rotationPeriodMessages,
expiresTs: now + roomConfig.rotationPeriodMs,
};
await this.client.cryptoStore.storeOutboundGroupSession(currentSession);
// TODO: Store as inbound session too

} finally {
session.free();
}
}

// TODO: Include invited members?
const memberUserIds = await this.client.getJoinedRoomMembers(roomId);
const devices = await this.deviceTracker.getDevicesFor(memberUserIds);

const session = new Olm.OutboundGroupSession();
try {
session.unpickle(this.pickleKey, currentSession.pickled);

for (const userId of Object.keys(devices)) {
for (const deviceId of Object.keys(devices[userId])) {
const device = devices[userId][deviceId];
// TODO: Olm session management
}
}
} finally {
session.free();
}
}
}
23 changes: 23 additions & 0 deletions src/e2ee/DeviceTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ export class DeviceTracker {
public constructor(private client: MatrixClient) {
}

/**
* Gets the device lists for the given user IDs. Outdated device lists will be updated before
* returning.
* @param {string[]} userIds The user IDs to get the device lists of.
* @returns {Promise<Record<string, UserDevice[]>>} Resolves to a map of user ID to device list.
* If a user has no devices, they may be excluded from the result or appear as an empty array.
*/
public async getDevicesFor(userIds: string[]): Promise<Record<string, UserDevice[]>> {
const outdatedUserIds: string[] = [];
for (const userId of userIds) {
const isOutdated = await this.client.cryptoStore.isUserOutdated(userId);
if (isOutdated) outdatedUserIds.push(userId);
}

await this.updateUsersDeviceLists(outdatedUserIds);

const userDeviceMap: Record<string, UserDevice[]> = {};
for (const userId of userIds) {
userDeviceMap[userId] = await this.client.cryptoStore.getUserDevices(userId);
}
return userDeviceMap;
}

/**
* Flags multiple user's device lists as outdated, optionally queuing an immediate update.
* @param {string} userIds The user IDs to flag the device lists of.
Expand Down
17 changes: 17 additions & 0 deletions src/models/Crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export interface UserDevice {
};
}

/**
* Device list response for a multi-user query.
* @category Models
*/
export interface MultiUserDeviceListResponse {
/**
* Federation failures, keyed by server name. The mapped object should be a standard
Expand All @@ -103,3 +107,16 @@ export interface MultiUserDeviceListResponse {
*/
device_keys: Record<string, Record<string, UserDevice>>;
}

/**
* An outbound group session.
* @category Models
*/
export interface IOutboundGroupSession {
sessionId: string;
roomId: string;
pickled: string;
isCurrent: boolean;
usesLeft: number;
expiresTs: number;
}
55 changes: 54 additions & 1 deletion src/storage/ICryptoStorageProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EncryptionEventContent } from "../models/events/EncryptionEvent";
import { UserDevice } from "../models/Crypto";
import { IOutboundGroupSession, UserDevice } from "../models/Crypto";

/**
* A storage provider capable of only providing crypto-related storage.
Expand Down Expand Up @@ -94,4 +94,57 @@ export interface ICryptoStorageProvider {
* @returns {Promise<boolean>} Resolves to true if outdated, false otherwise.
*/
isUserOutdated(userId: string): Promise<boolean>;

/**
* Stores a pickled outbound group session. If the session is flagged as current, all other sessions
* for the room ID will be flagged as not-current.
* @param {IOutboundGroupSession} session The session to store.
* @returns {Promise<void>} Resolves when complete.
*/
storeOutboundGroupSession(session: IOutboundGroupSession): Promise<void>;

/**
* Gets a previously stored outbound group session. If the session ID is not known, a falsy value
* will be returned.
* @param {string} sessionId The session ID.
* @param {string} roomId The room ID where the session is stored.
* @returns {Promise<IOutboundGroupSession>} Resolves to the session, or falsy if not known.
*/
getOutboundGroupSession(sessionId: string, roomId: string): Promise<IOutboundGroupSession>;

/**
* Gets the current outbound group session for a room. If the room does not have a current session,
* a falsy value will be returned.
* @param {string} roomId The room ID.
* @returns {Promise<IOutboundGroupSession>} Resolves to the current session, or falsy if not known.
*/
getCurrentOutboundGroupSession(roomId: string): Promise<IOutboundGroupSession>;

/**
* Decrements the available usages for an outbound group session.
* @param {string} sessionId The session ID.
* @param {string} roomId The room ID.
* @returns {Promise<void>} Resolves when complete.
*/
useOutboundGroupSession(sessionId: string, roomId: string): Promise<void>;

/**
* Stores a session as sent to a user's device.
* @param {IOutboundGroupSession} session The session that was sent.
* @param {number} index The session index.
* @param {UserDevice} device The device the session was sent to.
* @returns {Promise<void>} Resolves when complete.
*/
storeSentOutboundGroupSession(session: IOutboundGroupSession, index: number, device: UserDevice): Promise<void>;

/**
* Gets the last sent session that was sent to a user's device. If none is recorded,
* a falsy value is returned.
* @param {string} userId The user ID to look for.
* @param {string} deviceId The device ID to look for.
* @param {string} roomId The room ID to look in.
* @returns {Promise<{sessionId: string, index: number}>} Resolves to the last session
* sent, or falsy if not known.
*/
getLastSentOutboundGroupSession(userId: string, deviceId: string, roomId: string): Promise<{sessionId: string, index: number}>;
}
Loading

0 comments on commit e42a44b

Please sign in to comment.