Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic encryption support for simple bots #142

Merged
merged 26 commits into from
Aug 17, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4b85ab6
Add early support for encryption: Bootstrap process
turt2live Jul 24, 2021
cc2f5ea
Link encryption up to sync
turt2live Jul 27, 2021
aab5610
First round of tests
turt2live Jul 28, 2021
1ab0779
Add another round of tests
turt2live Jul 28, 2021
8ce133d
Docs + ability to query device lists
turt2live Jul 28, 2021
c69d703
Set up a proper crypto store and use it to flag e2ee enablement
turt2live Jul 29, 2021
bc51f92
Initial device list tracking
turt2live Jul 29, 2021
e42a44b
WIP for sending encrypted events
turt2live Aug 4, 2021
bb79d52
Finish primary send path
turt2live Aug 7, 2021
075f053
Add tests for recent work + fix related bugs
turt2live Aug 9, 2021
343ced3
Organize imports
turt2live Aug 9, 2021
68911aa
Initial untested code for receiving decryption keys
turt2live Aug 9, 2021
e277656
Appease the linter
turt2live Aug 9, 2021
028635c
Support decryption
turt2live Aug 10, 2021
d96a873
Encrypt and decrypt by default when possible
turt2live Aug 11, 2021
2569dd6
Fix immediate tests
turt2live Aug 12, 2021
a0b3c7c
Add tests for missed code
turt2live Aug 14, 2021
59e2627
Organize imports
turt2live Aug 14, 2021
f4fecf9
Add support for media encryption/decryption
turt2live Aug 15, 2021
f58d7ea
Don't spam logs with buffer contents
turt2live Aug 16, 2021
9cfd907
Actually use sent_outbound_sessions table
turt2live Aug 16, 2021
5798099
Store outbound group session as soon as possible
turt2live Aug 16, 2021
161829b
Fix first message being undecryptable after 9cfd907
turt2live Aug 16, 2021
8e2188d
Add support for fallback keys
turt2live Aug 17, 2021
19b09bf
Protect against double device reuse
turt2live Aug 17, 2021
e07b0f4
Test to ensure CryptoClient stores outbound group sessions as inbound…
turt2live Aug 17, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add tests for recent work + fix related bugs
  • Loading branch information
turt2live committed Aug 9, 2021
commit 075f053d773d24c66875f9cad4574a141ab2930e
2 changes: 1 addition & 1 deletion src/MatrixClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export class MatrixClient extends EventEmitter {
this.crypto = new CryptoClient(this);
LogService.debug("MatrixClientLite", "End-to-end encryption client created");
} else {
LogService.trace("MatrixClientLite", "Not setting up encryption");
// LogService.trace("MatrixClientLite", "Not setting up encryption");
}

if (!this.storage) this.storage = new MemoryStorageProvider();
Expand Down
143 changes: 81 additions & 62 deletions src/e2ee/CryptoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,24 @@ export class CryptoClient {
try {
const sig = account.sign(anotherJson.stringify(obj));
return {
...existingSignatures,
[await this.client.getUserId()]: {
[`${DeviceKeyAlgorithm.Ed25119}:${this.deviceId}`]: sig,
},
...existingSignatures,
};
} finally {
account.free();
}
}

/**
* Verifies a signature on an object.
* @param {object} obj The signed object.
* @param {string} key The key which has supposedly signed the object.
* @param {string} signature The advertised signature.
* @returns {Promise<boolean>} Resolves to true if a valid signature, false otherwise.
*/
@requiresReady()
public async verifySignature(obj: object, key: string, signature: string): Promise<boolean> {
obj = JSON.parse(JSON.stringify(obj));

Expand All @@ -237,10 +245,12 @@ export class CryptoClient {
* 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.
* @param {boolean} resync True (default) to queue an immediate update, false otherwise.
* @returns {Promise<void>} Resolves when the device lists have been flagged. Will also wait
* for the resync if one was requested.
*/
public flagUsersDeviceListsOutdated(userIds: string[], resync = true) {
// noinspection JSIgnoredPromiseFromCall
this.deviceTracker.flagUsersOutdated(userIds, resync);
@requiresReady()
public flagUsersDeviceListsOutdated(userIds: string[], resync = true): Promise<void> {
return this.deviceTracker.flagUsersOutdated(userIds, resync);
}

/**
Expand All @@ -251,6 +261,7 @@ export class CryptoClient {
* ID to session. Users/devices which cannot have sessions made will not be included, thus the object
* may be empty.
*/
@requiresReady()
public async getOrCreateOlmSessions(userDeviceMap: Record<string, string[]>): Promise<Record<string, Record<string, IOlmSession>>> {
const otkClaimRequest: Record<string, Record<string, OTKAlgorithm>> = {};
const userDeviceSessionIds: Record<string, Record<string, IOlmSession>> = {};
Expand All @@ -275,71 +286,80 @@ export class CryptoClient {
}
}

const claimed = await this.client.claimOneTimeKeys(otkClaimRequest);
for (const userId of Object.keys(claimed.one_time_keys)) {
const storedDevices = await this.client.cryptoStore.getUserDevices(userId);
for (const deviceId of Object.keys(claimed.one_time_keys[userId])) {
try {
const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId);
if (!device) {
LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`);
continue;
}

const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`;

const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0];
const signedKey = claimed.one_time_keys[userId][deviceId][keyId];
const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel];
if (!signature) {
LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`);
continue;
}

const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature);
if (!verified) {
LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`);
continue;
}

// TODO: Handle spec rate limiting
// Clients should rate-limit the number of sessions it creates per device that it receives a message
// from. Clients should not create a new session with another device if it has already created one
// for that given device in the past 1 hour.

// Finally, we can create a session. We do this on each loop just in case something goes wrong given
// we don't have app-level transaction support here. We want to persist as many outbound sessions as
// we can before exploding.
const account = await this.getOlmAccount();
const session = new Olm.Session();
if (Object.keys(otkClaimRequest).length > 0) {
const claimed = await this.client.claimOneTimeKeys(otkClaimRequest);
for (const userId of Object.keys(claimed.one_time_keys)) {
if (!otkClaimRequest[userId]) {
LogService.warn("CryptoClient", `Server injected unexpected user: ${userId} - not claiming keys`);
continue;
}
const storedDevices = await this.client.cryptoStore.getUserDevices(userId);
for (const deviceId of Object.keys(claimed.one_time_keys[userId])) {
try {
const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`];
session.create_outbound(account, curveDeviceKey, signedKey.key);
const storedSession: IOlmSession = {
sessionId: session.session_id(),
lastDecryptionTs: Date.now(),
pickled: session.pickle(this.pickleKey),
};
await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession);

if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {};
userDeviceSessionIds[userId][deviceId] = storedSession;

// Send a dummy event so the device can prepare the session.
// await this.encryptAndSendOlmMessage(device, storedSession, "m.dummy", {});
} finally {
session.free();
await this.storeAndFreeOlmAccount(account);
if (!otkClaimRequest[userId][deviceId]) {
LogService.warn("CryptoClient", `Server provided an unexpected device in claim response (skipping): ${userId} ${deviceId}`);
continue;
}

const device = storedDevices.find(d => d.user_id === userId && d.device_id === deviceId);
if (!device) {
LogService.warn("CryptoClient", `Failed to handle claimed OTK: unable to locate stored device for user: ${userId} ${deviceId}`);
continue;
}

const deviceKeyLabel = `${DeviceKeyAlgorithm.Ed25119}:${deviceId}`;

const keyId = Object.keys(claimed.one_time_keys[userId][deviceId])[0];
const signedKey = claimed.one_time_keys[userId][deviceId][keyId];
const signature = signedKey?.signatures?.[userId]?.[deviceKeyLabel];
if (!signature) {
LogService.warn("CryptoClient", `Failed to find appropriate signature for claimed OTK ${userId} ${deviceId}`);
continue;
}

const verified = await this.verifySignature(signedKey, device.keys[deviceKeyLabel], signature);
if (!verified) {
LogService.warn("CryptoClient", `Invalid signature for claimed OTK ${userId} ${deviceId}`);
continue;
}

// TODO: Handle spec rate limiting
// Clients should rate-limit the number of sessions it creates per device that it receives a message
// from. Clients should not create a new session with another device if it has already created one
// for that given device in the past 1 hour.

// Finally, we can create a session. We do this on each loop just in case something goes wrong given
// we don't have app-level transaction support here. We want to persist as many outbound sessions as
// we can before exploding.
const account = await this.getOlmAccount();
const session = new Olm.Session();
try {
const curveDeviceKey = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`];
session.create_outbound(account, curveDeviceKey, signedKey.key);
const storedSession: IOlmSession = {
sessionId: session.session_id(),
lastDecryptionTs: Date.now(),
pickled: session.pickle(this.pickleKey),
};
await this.client.cryptoStore.storeOlmSession(userId, deviceId, storedSession);

if (!userDeviceSessionIds[userId]) userDeviceSessionIds[userId] = {};
userDeviceSessionIds[userId][deviceId] = storedSession;
} finally {
session.free();
await this.storeAndFreeOlmAccount(account);
}
} catch (e) {
LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e);
}
} catch (e) {
LogService.warn("CryptoClient", `Unable to verify signature of claimed OTK ${userId} ${deviceId}:`, e);
}
}
}

return userDeviceSessionIds;
}

@requiresReady()
private async encryptAndSendOlmMessage(device: UserDevice, session: IOlmSession, type: string, content: any): Promise<void> {
const olmSession = new Olm.Session();
try {
Expand Down Expand Up @@ -390,6 +410,7 @@ export class CryptoClient {
* @param {any} content The event content being encrypted.
* @returns {Promise<any>} Resolves to the encrypted content for an `m.room.encrypted` event.
*/
@requiresReady()
public async encryptRoomEvent(roomId: string, eventType: string, content: any): Promise<any> {
if (!(await this.isRoomEncrypted(roomId))) {
throw new Error("Room is not encrypted");
Expand Down Expand Up @@ -475,7 +496,5 @@ export class CryptoClient {
} finally {
session.free();
}


}
}
84 changes: 47 additions & 37 deletions src/e2ee/DeviceTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ export class DeviceTracker {
* 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.
* @param {boolean} resync True (default) to queue an immediate update, false otherwise.
* @returns {Promise<void>} Resolves when the flagging has completed. Will wait for the resync
* if requested too.
*/
public async flagUsersOutdated(userIds: string[], resync = true) {
public async flagUsersOutdated(userIds: string[], resync = true): Promise<void> {
await this.client.cryptoStore.flagUsersOutdated(userIds);
if (resync) {
// We don't really want to wait around for this, so let it work in the background
// noinspection ES6MissingAwait
this.updateUsersDeviceLists(userIds);
await this.updateUsersDeviceLists(userIds);
}
}

Expand All @@ -63,52 +63,62 @@ export class DeviceTracker {
await Promise.all(existingPromises);
}

const promise = new Promise<void>(async resolve => {
const resp = await this.client.getUserDevices(userIds);
for (const userId of Object.keys(resp.device_keys)) {
const validated: UserDevice[] = [];
for (const deviceId of Object.keys(resp.device_keys[userId])) {
const device = resp.device_keys[userId][deviceId];
if (device.user_id !== userId || device.device_id !== deviceId) {
LogService.warn("DeviceTracker", `Server appears to be lying about device lists: ${userId} ${deviceId} has unexpected device ${device.user_id} ${device.device_id} listed - ignoring device`);
const promise = new Promise<void>(async (resolve, reject) => {
try {
const resp = await this.client.getUserDevices(userIds);
for (const userId of Object.keys(resp.device_keys)) {
if (!userIds.includes(userId)) {
LogService.warn("DeviceTracker", `Server returned unexpected user ID: ${userId} - ignoring user`);
continue;
}

const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`];
const validated: UserDevice[] = [];
for (const deviceId of Object.keys(resp.device_keys[userId])) {
const device = resp.device_keys[userId][deviceId];
if (device.user_id !== userId || device.device_id !== deviceId) {
LogService.warn("DeviceTracker", `Server appears to be lying about device lists: ${userId} ${deviceId} has unexpected device ${device.user_id} ${device.device_id} listed - ignoring device`);
continue;
}

if (!ed25519 || !curve25519) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing either an Ed25519 or Curve25519 key - ignoring device`);
continue;
}
const ed25519 = device.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
const curve25519 = device.keys[`${DeviceKeyAlgorithm.Curve25519}:${deviceId}`];

if (!ed25519 || !curve25519) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing either an Ed25519 or Curve25519 key - ignoring device`);
continue;
}

const currentDevices = await this.client.cryptoStore.getUserDevices(userId);
const existingDevice = currentDevices.find(d => d.device_id === deviceId);

const currentDevices = await this.client.cryptoStore.getUserDevices(userId);
const existingDevice = currentDevices.find(d => d.device_id === deviceId);
if (existingDevice) {
const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
if (existingEd25519 !== ed25519) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`);
continue;
}
}

if (existingDevice) {
const existingEd25519 = existingDevice.keys[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
if (existingEd25519 !== ed25519) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} appears compromised: Ed25519 key changed - ignoring device`);
const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
if (!signature) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`);
continue;
}
}

const signature = device.signatures?.[userId]?.[`${DeviceKeyAlgorithm.Ed25119}:${deviceId}`];
if (!signature) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} is missing a signature - ignoring device`);
continue;
}
const validSignature = await this.client.crypto.verifySignature(device, ed25519, signature);
if (!validSignature) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} has an invalid signature - ignoring device`);
continue;
}

const validSignature = await this.client.crypto.verifySignature(device, ed25519, signature);
if (!validSignature) {
LogService.warn("DeviceTracker", `Device ${userId} ${deviceId} has an invalid signature - ignoring device`);
continue;
validated.push(device);
}

validated.push(device);
await this.client.cryptoStore.setUserDevices(userId, validated);
}

await this.client.cryptoStore.setUserDevices(userId, validated);
} catch (e) {
LogService.error("DeviceTracker", "Error updating device lists:", e);
// return reject(e);
}
resolve();
});
Expand Down
10 changes: 5 additions & 5 deletions src/storage/SqliteCryptoStorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
usesLeft: session.usesLeft,
expiresTs: session.expiresTs,
});
});
})();
}

public async getOutboundGroupSession(sessionId: string, roomId: string): Promise<IOutboundGroupSession> {
Expand All @@ -183,8 +183,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
roomId: result.room_id,
pickled: result.pickled,
isCurrent: result.current === 1,
usesLeft: result.usesLeft,
expiresTs: result.expiresTs,
usesLeft: result.uses_left,
expiresTs: result.expires_ts,
};
}
return null;
Expand All @@ -198,8 +198,8 @@ export class SqliteCryptoStorageProvider implements ICryptoStorageProvider {
roomId: result.room_id,
pickled: result.pickled,
isCurrent: result.current === 1,
usesLeft: result.usesLeft,
expiresTs: result.expiresTs,
usesLeft: result.uses_left,
expiresTs: result.expires_ts,
};
}
return null;
Expand Down
Loading