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

Key backup: clean up SecureKeyBackup.prepareKeyBackupVersion #3559

Closed
wants to merge 14 commits into from
Closed
177 changes: 140 additions & 37 deletions spec/integ/crypto/megolm-backup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ limitations under the License.
*/

import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";

import { logger } from "../../../src/logger";
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
import { createClient, ICreateClientOpts, IEvent, MatrixClient } from "../../../src";
import { MatrixEventEvent } from "../../../src/models/event";
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient } from "../../../src";
import { SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { syncPromise } from "../../test-utils/test-utils";
import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
import { SecureKeyBackup } from "../../../src/common-crypto/SecureKeyBackup";

const ROOM_ID = "!ROOM:ID";

Expand Down Expand Up @@ -72,22 +72,14 @@ const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
},
};

const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
// Will be updated with correct value on the fly
signatures: {},
},
};

const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";

const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";

describe("megolm key backups", function () {
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;

let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests on the test homeserver */
let syncResponder: SyncResponder;
Expand All @@ -108,6 +100,7 @@ describe("megolm key backups", function () {
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
e2eKeyResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
});

Expand All @@ -130,12 +123,12 @@ describe("megolm key backups", function () {
deviceId: TEST_DEVICE_ID,
...opts,
});
await client.initCrypto();
await initCrypto(client);

return client;
}

it("Alice checks key backups when receiving a message she can't decrypt", async function () {
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
const syncResponse = {
next_batch: 1,
rooms: {
Expand All @@ -150,35 +143,145 @@ describe("megolm key backups", function () {
};

fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);

// mock for the outgoing key requests that will be sent
fetchMock.put("express:/_matrix/client/r0/sendToDevice/m.room_key_request/:txid", {});

// We'll need to add a signature to the backup data, so take a copy to avoid mutating global state.
const backupData = JSON.parse(JSON.stringify(CURVE25519_BACKUP_INFO));
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupData);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);

aliceClient = await initTestClient();
await aliceClient.crypto!.signObject(backupData.auth_data);
await aliceClient.crypto!.storeSessionBackupPrivateKey(decodeRecoveryKey(RECOVERY_KEY));
await aliceClient.crypto!.backupManager!.checkAndStart();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));

// start after saving the private key
await aliceClient.startClient();

// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// @ts-ignore backupManager is an internal property
await aliceCrypto.backupManager.checkAndStart();

// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);

const room = aliceClient.getRoom(ROOM_ID)!;

const event = room.getLiveTimeline().getEvents()[0];
await new Promise((resolve, reject) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
await awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent()).toEqual("testytest");
});

oldBackendOnly("getKeyBackupStatus() should give correct status", async function () {
// 404 means that there is no active backup
fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404);

aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// @ts-ignore backupManager is an internal property
const aliceBackupManager: SecureKeyBackup = aliceCrypto.backupManager;
await aliceClient.startClient();

// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceBackupManager.checkAndStart();

// At this point there is no backup
let backupStatus: string | null;
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toBeNull();

// Serve a backup with no trusted signature
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
delete unsignedBackup.auth_data.signatures;
fetchMock.get("express:/_matrix/client/v3/room_keys/version", unsignedBackup, {
overwriteRoutes: true,
});

const checked = await aliceBackupManager.checkAndStart();
expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version);
expect(checked?.trustInfo?.usable).toBeFalsy();

backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toBeNull();

// Add a valid signature to the backup
fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
overwriteRoutes: true,
});

// check that signaling is working
const backupPromise = new Promise<void>((resolve, reject) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});

expect(event.getContent()).toEqual("testytest");
const validCheck = await aliceBackupManager.checkAndStart();
expect(validCheck?.trustInfo?.usable).toStrictEqual(true);

await backupPromise;

backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
});

oldBackendOnly("test backup version creation", async function () {
// 404 means that there is no active backup
fetchMock.get("/_matrix/client/v3/room_keys/version", 404);

aliceClient = await initTestClient();
await aliceClient.startClient();

const preparedBackup = await aliceClient.prepareKeyBackupVersion();

// The prepared backup should be signed
// Only device signature as cross signing is not bootstraped
// TODO improvement bootstrap cross signing to check for the added signature
expect(preparedBackup?.auth_data.signatures?.[TEST_USER_ID]).toBeDefined();
expect(preparedBackup?.auth_data.signatures?.[TEST_USER_ID]?.[`ed25519:${TEST_DEVICE_ID}`]).toBeDefined();

// mock backup creation API
const backupVersion = "1";
const expectedBackupResponse = {
algorithm: preparedBackup.algorithm,
auth_data: preparedBackup.auth_data,
version: backupVersion,
};
fetchMock.post("express:/_matrix/client/v3/room_keys/version", expectedBackupResponse);
fetchMock.get("express:/_matrix/client/v3/room_keys/version", expectedBackupResponse, {
overwriteRoutes: true,
});

const backupEnabled = new Promise<void>((resolve, reject) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});

await aliceClient.createKeyBackupVersion({
algorithm: preparedBackup.algorithm,
auth_data: preparedBackup.auth_data,
});

await backupEnabled;

const backupStatus = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
expect(backupStatus).toBeDefined();
expect(backupStatus).toStrictEqual(backupVersion);
});

/** make sure that the client knows about the dummy device */
async function waitForDeviceList(): Promise<void> {
// Completing the initial sync will make the device list download outdated device lists (of which our own
// user will be one).
syncResponder.sendOrQueueSyncResponse({});
// DeviceList has a sleep(5) which we need to make happen
await jest.advanceTimersByTimeAsync(10);

// The client should now know about the dummy device
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID);
}
});
6 changes: 3 additions & 3 deletions spec/integ/crypto/verification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
describe("Outgoing verification requests for another device", () => {
beforeEach(async () => {
// pretend that we have another device, which we will verify
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
});

// test with (1) the default verification method list, (2) a custom verification method list.
Expand Down Expand Up @@ -626,7 +626,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
describe("cancellation", () => {
beforeEach(async () => {
// pretend that we have another device, which we will start verifying
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);

aliceClient = await startTestClient();
Expand Down Expand Up @@ -743,7 +743,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st

describe("Incoming verification from another device", () => {
beforeEach(async () => {
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);

aliceClient = await startTestClient();
await waitForDeviceList();
Expand Down
6 changes: 2 additions & 4 deletions spec/test-utils/E2EKeyResponder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,10 @@ export class E2EKeyResponder {
/**
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
*
* @param userId - user the keys belong to
* @param deviceId - device the keys belong to
* @param keys - device keys for this device.
*/
public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) {
this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys);
public addDeviceKeys(keys: IDeviceKeys) {
this.deviceKeysByUserByDevice.getOrCreate(keys.user_id).set(keys.device_id, keys);
}

/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
Expand Down
49 changes: 38 additions & 11 deletions spec/test-utils/test-data/generate-test-data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import json

from canonicaljson import encode_canonical_json
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

# input data
Expand All @@ -41,6 +41,8 @@
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"

# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
B64_BACKUP_DECRYPTION_KEY = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="

def main() -> None:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
Expand Down Expand Up @@ -71,30 +73,48 @@ def main() -> None:
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
b64_master_private_key = encode_base64(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_master_private_key = encode_base64(MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES)

self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_self_signing_public_key = encode_base64(
self_signing_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
b64_self_signing_private_key = encode_base64(
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
self_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
b64_self_signing_private_key = encode_base64(SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES)

user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_user_signing_public_key = encode_base64(
user_signing_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
user_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
b64_user_signing_private_key = encode_base64(
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
b64_user_signing_private_key = encode_base64(USER_CROSS_SIGNING_PRIVATE_KEY_BYTES)

backup_decryption_key = x25519.X25519PrivateKey.from_private_bytes(
base64.b64decode(B64_BACKUP_DECRYPTION_KEY)
)
b64_backup_public_key = encode_base64(
backup_decryption_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)

backup_data = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": b64_backup_public_key,
},
}
# sign with our device key
sig = sign_json(backup_data["auth_data"], private_key)
backup_data["auth_data"]["signatures"] = {
TEST_USER_ID: {f"ed25519:{TEST_DEVICE_ID}": sig}
}

print(
f"""\
/* Test data for cryptography tests
Expand All @@ -104,6 +124,7 @@ def main() -> None:

import {{ IDeviceKeys }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult }} from "../../../src";
import {{ KeyBackupInfo }} from "../../../src/crypto-api";

/* eslint-disable comma-dangle */

Expand Down Expand Up @@ -138,6 +159,12 @@ def main() -> None:
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
json.dumps(build_cross_signing_keys_data(), indent=4)
};

/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "{ B64_BACKUP_DECRYPTION_KEY }";

/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
""",
end="",
)
Expand Down
Loading
Loading