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 support for key backups #22

Merged
merged 18 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion examples/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ 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/bot.json");
const crypto = new RustSdkCryptoStorageProvider("./examples/storage/bot_sled", StoreType.Sled);
const crypto = new RustSdkCryptoStorageProvider("./examples/storage/bot_sqlite", StoreType.Sqlite);

const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);
AutojoinRoomsMixin.setupOnClient(client);
Expand Down
2 changes: 1 addition & 1 deletion examples/encryption_appservice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ try {
const dmTarget = creds?.['dmTarget'] ?? "@admin:localhost";
const homeserverUrl = creds?.['homeserverUrl'] ?? "http://localhost:8008";
const storage = new SimpleFsStorageProvider("./examples/storage/encryption_appservice.json");
const crypto = new RustSdkAppserviceCryptoStorageProvider("./examples/storage/encryption_appservice_sled", StoreType.Sled);
const crypto = new RustSdkAppserviceCryptoStorageProvider("./examples/storage/encryption_appservice_sqlite", StoreType.Sqlite);
const worksImage = fs.readFileSync("./examples/static/it-works.png");

const registration: IAppserviceRegistration = {
Expand Down
2 changes: 1 addition & 1 deletion examples/encryption_bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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");
const crypto = new RustSdkCryptoStorageProvider("./examples/storage/encryption_bot_sled", StoreType.Sled);
const crypto = new RustSdkCryptoStorageProvider("./examples/storage/encryption_bot_sqlite", StoreType.Sqlite);
const worksImage = fs.readFileSync("./examples/static/it-works.png");

const client = new MatrixClient(homeserverUrl, accessToken, storage, crypto);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"tsconfig.json"
],
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.6",
"@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.9",
"@types/express": "^4.17.13",
"another-json": "^0.2.0",
"async-lock": "^1.3.2",
Expand Down
70 changes: 70 additions & 0 deletions src/MatrixClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { DMs } from "./DMs";
import { ServerVersions } from "./models/ServerVersions";
import { RoomCreateOptions } from "./models/CreateRoom";
import { PresenceState } from './models/events/PresenceEvent';
import { IKeyBackupInfo, IKeyBackupInfoRetrieved, IKeyBackupInfoUnsigned, IKeyBackupInfoUpdate, IKeyBackupVersion, KeyBackupVersion } from "./models/KeyBackup";
import { MatrixError } from "./models/MatrixError";

const SYNC_BACKOFF_MIN_MS = 5000;
const SYNC_BACKOFF_MAX_MS = 15000;
Expand Down Expand Up @@ -1962,6 +1964,74 @@ export class MatrixClient extends EventEmitter {
});
}

/**
* Get information about the latest room key backup version.
* @returns {Promise<IKeyBackupInfoRetrieved|null>} Resolves to the retrieved key backup info,
* or null if there is no existing backup.
*/
public async getKeyBackupVersion(): Promise<IKeyBackupInfoRetrieved|null> {
try {
return await this.doRequest("GET", "/_matrix/client/v3/room_keys/version");
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
return null;
} else {
throw e;
}
}
}

/**
* Create a new room key backup.
* @param {IKeyBackupInfoUnsigned} info The properties of the key backup to create,
* with its auth_data left unsigned.
* @returns {Promise<IKeyBackupVersion>} Resolves to the version id of the new backup.
*/
@requiresCrypto()
public async signAndCreateKeyBackupVersion(info: IKeyBackupInfoUnsigned): Promise<IKeyBackupVersion> {
const data: IKeyBackupInfo = {
...info,
auth_data: {
...info.auth_data,
signatures: await this.crypto.sign(info),
},
};
return this.doRequest("POST", "/_matrix/client/v3/room_keys/version", null, data);
}

/**
* Update an existing room key backup.
* @param {KeyBackupVersion} version The key backup version to update.
* @param {IKeyBackupInfoUpdate} info The properties of the key backup to be applied.
* @returns {Promise<void>} Resolves when complete.
*/
@requiresCrypto()
public updateKeyBackupVersion(version: KeyBackupVersion, info: IKeyBackupInfoUpdate): Promise<void> {
const data = {
...info,
signatures: this.crypto.sign(info),
};
return this.doRequest("PUT", `/_matrix/client/v3/room_keys/version/${version}`, null, data);
}

/**
* Enable backing up of room keys.
* @param {IKeyBackupInfoRetrieved} info The configuration for key backup behaviour,
* as returned by {@link getKeyBackupVersion}.
* @returns {Promise<void>} Resolves when complete.
*/
@requiresCrypto()
public enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise<void> {
return this.crypto.enableKeyBackup(info);
}

/**
* Disable backing up of room keys.
*/
public disableKeyBackup(): Promise<void> {
return this.crypto?.disableKeyBackup() ?? Promise.resolve();
}

/**
* Get relations for a given event.
* @param {string} roomId The room ID to for the given event.
Expand Down
38 changes: 34 additions & 4 deletions src/e2ee/CryptoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { EncryptedFile } from "../models/events/MessageEvent";
import { RustSdkCryptoStorageProvider } from "../storage/RustSdkCryptoStorageProvider";
import { RustEngine, SYNC_LOCK_NAME } from "./RustEngine";
import { MembershipEvent } from "../models/events/MembershipEvent";
import { IKeyBackupInfoRetrieved } from "../models/KeyBackup";

/**
* Manages encryption for a MatrixClient. Get an instance from a MatrixClient directly
Expand Down Expand Up @@ -168,12 +169,13 @@ export class CryptoClient {
leftDeviceLists.map(u => new UserId(u)));

await this.engine.lock.acquire(SYNC_LOCK_NAME, async () => {
const syncResp = await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs);
const decryptedToDeviceMessages = JSON.parse(syncResp);
if (Array.isArray(decryptedToDeviceMessages)) {
for (const msg of decryptedToDeviceMessages) {
const syncResp = JSON.parse(await this.engine.machine.receiveSyncChanges(deviceMessages, deviceLists, otkCounts, unusedFallbackKeyAlgs));
if (Array.isArray(syncResp) && syncResp.length === 2 && Array.isArray(syncResp[0])) {
for (const msg of syncResp[0] as IToDeviceMessage[]) {
this.client.emit("to_device.decrypted", msg);
}
} else {
LogService.error("CryptoClient", "OlmMachine.receiveSyncChanges did not return an expected value of [to-device events, room key changes]");
}

await this.engine.run();
Expand Down Expand Up @@ -284,4 +286,32 @@ export class CryptoClient {
const decrypted = Attachment.decrypt(encrypted);
return Buffer.from(decrypted);
}

/**
* Enable backing up of room keys.
* @param {IKeyBackupInfoRetrieved} info The configuration for key backup behaviour,
* as returned by {@link MatrixClient#getKeyBackupVersion}.
* @returns {Promise<void>} Resolves when complete.
*/
@requiresReady()
public async enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise<void> {
this.client.on("to_device.decrypted", this.onToDeviceMessage);
await this.engine.enableKeyBackup(info);
this.engine.backupRoomKeys();
}

/**
* Disable backing up of room keys.
*/
@requiresReady()
public async disableKeyBackup(): Promise<void> {
await this.engine.disableKeyBackup();
this.client.removeListener("to_device.decrypted", this.onToDeviceMessage);
}

private readonly onToDeviceMessage = (msg: IToDeviceMessage): void => {
if (msg.type === "m.room_key") {
this.engine.backupRoomKeys();
}
};
}
85 changes: 84 additions & 1 deletion src/e2ee/RustEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
KeysUploadRequest,
KeysQueryRequest,
ToDeviceRequest,
KeysBackupRequest,
} from "@matrix-org/matrix-sdk-crypto-nodejs";
import * as AsyncLock from "async-lock";

import { MatrixClient } from "../MatrixClient";
import { ICryptoRoomInformation } from "./ICryptoRoomInformation";
import { EncryptionAlgorithm } from "../models/Crypto";
import { EncryptionEvent } from "../models/events/EncryptionEvent";
import { ICurve25519AuthData, IKeyBackupInfoRetrieved, KeyBackupEncryptionAlgorithm, KeyBackupVersion } from "../models/KeyBackup";

/**
* @internal
Expand All @@ -29,6 +31,10 @@ export const SYNC_LOCK_NAME = "sync";
export class RustEngine {
public readonly lock = new AsyncLock();

private keyBackupVersion: KeyBackupVersion|undefined;
private keyBackupWaiter = Promise.resolve();
private isBackupEnabled = false;

public constructor(public readonly machine: OlmMachine, private client: MatrixClient) {
}

Expand Down Expand Up @@ -59,7 +65,8 @@ export class RustEngine {
case RequestType.SignatureUpload:
throw new Error("Bindings error: Backup feature not possible");
case RequestType.KeysBackup:
throw new Error("Bindings error: Backup feature not possible");
await this.processKeysBackupRequest(request);
break;
default:
throw new Error("Bindings error: Unrecognized request type: " + request.type);
}
Expand Down Expand Up @@ -125,7 +132,69 @@ export class RustEngine {
for (const req of requests) {
await this.actuallyProcessToDeviceRequest(req.txn_id, req.event_type, req.messages);
}
// Back up keys asynchronously
void this.backupRoomKeysIfEnabled();
});
}

public enableKeyBackup(info: IKeyBackupInfoRetrieved): Promise<void> {
this.keyBackupWaiter = this.keyBackupWaiter.then(async () => {
if (this.isBackupEnabled) {
AndrewFerr marked this conversation as resolved.
Show resolved Hide resolved
// Finish any pending backups before changing the backup version/pubkey
await this.actuallyDisableKeyBackup();
}
let publicKey: string;
switch (info.algorithm) {
case KeyBackupEncryptionAlgorithm.MegolmBackupV1Curve25519AesSha2:
publicKey = (info.auth_data as ICurve25519AuthData).public_key;
break;
default:
throw new Error("Key backup error: cannot enable backups with unsupported backup algorithm " + info.algorithm);
}
await this.machine.enableBackupV1(publicKey, info.version);
this.keyBackupVersion = info.version;
this.isBackupEnabled = true;
});
return this.keyBackupWaiter;
}

public disableKeyBackup(): Promise<void> {
this.keyBackupWaiter = this.keyBackupWaiter.then(async () => {
await this.actuallyDisableKeyBackup();
});
return this.keyBackupWaiter;
}

private async actuallyDisableKeyBackup(): Promise<void> {
await this.machine.disableBackup();
this.keyBackupVersion = undefined;
this.isBackupEnabled = false;
}

public backupRoomKeys(): Promise<void> {
this.keyBackupWaiter = this.keyBackupWaiter.then(async () => {
if (!this.isBackupEnabled) {
throw new Error("Key backup error: attempted to create a backup before having enabled backups");
}
await this.actuallyBackupRoomKeys();
});
return this.keyBackupWaiter;
}

private backupRoomKeysIfEnabled(): Promise<void> {
this.keyBackupWaiter = this.keyBackupWaiter.then(async () => {
if (this.isBackupEnabled) {
await this.actuallyBackupRoomKeys();
}
});
return this.keyBackupWaiter;
}

private async actuallyBackupRoomKeys(): Promise<void> {
const request = await this.machine.backupRoomKeys();
if (request) {
await this.processKeysBackupRequest(request);
}
}

private async processKeysClaimRequest(request: KeysClaimRequest) {
Expand Down Expand Up @@ -154,4 +223,18 @@ export class RustEngine {
const resp = await this.client.sendToDevices(type, messages);
await this.machine.markRequestAsSent(id, RequestType.ToDevice, JSON.stringify(resp));
}

private async processKeysBackupRequest(request: KeysBackupRequest) {
let resp: Awaited<ReturnType<MatrixClient["doRequest"]>>;
try {
if (!this.keyBackupVersion) {
throw new Error("Key backup version missing");
}
resp = await this.client.doRequest("PUT", "/_matrix/client/v3/room_keys/keys", { version: this.keyBackupVersion }, JSON.parse(request.body));
} catch (e) {
this.client.emit("crypto.failed_backup", e);
return;
}
await this.machine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
}
}
5 changes: 5 additions & 0 deletions src/helpers/Types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Json = string | number | boolean | null | undefined | Json[] | { [key: string]: Json };

export interface IJsonType {
[key: string]: Json;
}
14 changes: 10 additions & 4 deletions src/models/Crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ export interface Signatures {
};
}

/**
* Interface that can be extended by
* any object that needs a signature.
*/
export interface Signed {
signatures: Signatures;
}

/**
* A signed_curve25519 one time key.
* @category Models
*/
export interface SignedCurve25519OTK {
export interface SignedCurve25519OTK extends Signed {
key: string;
signatures: Signatures;
fallback?: boolean;
}

Expand Down Expand Up @@ -89,12 +96,11 @@ export type DeviceKeyLabel<Algorithm extends DeviceKeyAlgorithm, ID extends stri
* Represents a user's device.
* @category Models
*/
export interface UserDevice {
export interface UserDevice extends Signed {
user_id: string;
device_id: string;
algorithms: (EncryptionAlgorithm | string)[];
keys: Record<DeviceKeyLabel<DeviceKeyAlgorithm, string>, string>;
signatures: Signatures;
unsigned?: {
[k: string]: any;
device_display_name?: string;
Expand Down
47 changes: 47 additions & 0 deletions src/models/KeyBackup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { IJsonType } from "../helpers/Types";
import { Signed } from "./Crypto";

/**
* The kinds of key backup encryption algorithms allowed by the spec.
* @category Models
*/
export enum KeyBackupEncryptionAlgorithm {
MegolmBackupV1Curve25519AesSha2 = "m.megolm_backup.v1.curve25519-aes-sha2",
}

export interface ICurve25519AuthDataUnsigned {
public_key: string;
}
export type ICurve25519AuthData = ICurve25519AuthDataUnsigned & Signed;

/**
* Information about a server-side key backup,
* with its auth_data left unsigned.
*/
export interface IKeyBackupInfoUnsigned {
algorithm: string | KeyBackupEncryptionAlgorithm;
auth_data: IJsonType | ICurve25519AuthDataUnsigned;
}

/**
* Information about a server-side key backup,
* with its auth_data signed by the entity that created it.
*/
export type IKeyBackupInfo = IKeyBackupInfoUnsigned & {
auth_data: Signed & IKeyBackupInfoUnsigned["auth_data"];
};

export type KeyBackupVersion = string;

export interface IKeyBackupVersion {
version: KeyBackupVersion;
}

export interface IKeyBackupUpdateResponse {
count: number;
etag: string;
}

export type IKeyBackupInfoRetrieved = IKeyBackupInfo & IKeyBackupVersion & IKeyBackupUpdateResponse;

export type IKeyBackupInfoUpdate = IKeyBackupInfo & Partial<IKeyBackupVersion>;
Loading
Loading