mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-06-08 15:21:53 +03:00
* disable key backup when both trust via signatures and private key fail * test for enabling backup with decryption key * enable backup with decryption key in legacy crypto * fix formmating * fix typo * add local variable for backup trust in legacy crypto * Update spec/integ/crypto/megolm-backup.spec.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update spec/integ/crypto/megolm-backup.spec.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update spec/integ/crypto/megolm-backup.spec.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update src/rust-crypto/backup.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * fix white space formatting * remove redundant test * fix trust check while receiving backup secret * mock room key version request before storing backup key * fix decryption key gossip test for untrusted backup info * rename version to latestBackupVersion to match the doc comments * Update src/rust-crypto/backup.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * remove test to stop key gossip when signature mismatch * remove misleading checkKeyBackupAndEnable doc return comment * Update src/rust-crypto/backup.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * use requestKeyBackupVersion to get latest version instead of checkKeyBackupAndEnable * remove comment * test for backup key gossip when no backup found * test for backup key gossip when backup request error * fix lint error * fix test message typo Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * refactor repeated test logic into a single reusable function * improve exceptBackup param and docs * fix: expect private key inside test * fix linting * add return type for backup key retrieve function Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * improve doc for retrieveBackupPrivateKeyWithDelay Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * improve expectBackup param description Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * fix status code and formatting --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
1227 lines
51 KiB
TypeScript
1227 lines
51 KiB
TypeScript
/*
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import fetchMock from "fetch-mock-jest";
|
|
import "fake-indexeddb/auto";
|
|
import { IDBFactory } from "fake-indexeddb";
|
|
import { type Mocked } from "jest-mock";
|
|
|
|
import {
|
|
createClient,
|
|
encodeBase64,
|
|
type ICreateClientOpts,
|
|
type IEvent,
|
|
type IMegolmSessionData,
|
|
type MatrixClient,
|
|
TypedEventEmitter,
|
|
} 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 { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-utils/test-utils";
|
|
import * as testData from "../../test-utils/test-data";
|
|
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
|
import { flushPromises } from "../../test-utils/flushPromises";
|
|
import { defer, type IDeferred } from "../../../src/utils";
|
|
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
|
|
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
|
|
|
|
const ROOM_ID = testData.TEST_ROOM_ID;
|
|
|
|
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
|
|
const TEST_HOMESERVER_URL = "https://alice-server.com";
|
|
|
|
const TEST_USER_ID = "@alice:localhost";
|
|
const TEST_DEVICE_ID = "xzcvb";
|
|
|
|
afterEach(() => {
|
|
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
|
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
|
// eslint-disable-next-line no-global-assign
|
|
indexedDB = new IDBFactory();
|
|
});
|
|
|
|
enum MockKeyUploadEvent {
|
|
KeyUploaded = "KeyUploaded",
|
|
}
|
|
|
|
type MockKeyUploadEventHandlerMap = {
|
|
[MockKeyUploadEvent.KeyUploaded]: (roomId: string, sessionId: string, backupVersion: string) => void;
|
|
};
|
|
|
|
/*
|
|
* Test helper. Returns an event emitter that will emit an event every time fetchmock sees a request to backup a key.
|
|
*/
|
|
function mockUploadEmitter(
|
|
expectedVersion: string,
|
|
): TypedEventEmitter<MockKeyUploadEvent, MockKeyUploadEventHandlerMap> {
|
|
const emitter = new TypedEventEmitter();
|
|
fetchMock.put(
|
|
"path:/_matrix/client/v3/room_keys/keys",
|
|
(url, request) => {
|
|
const version = new URLSearchParams(new URL(url).search).get("version");
|
|
if (version != expectedVersion) {
|
|
return {
|
|
status: 403,
|
|
body: {
|
|
current_version: expectedVersion,
|
|
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
|
error: "Wrong backup version.",
|
|
},
|
|
};
|
|
}
|
|
const uploadPayload: KeyBackup = JSON.parse((request.body as string) ?? "{}");
|
|
let count = 0;
|
|
for (const [roomId, value] of Object.entries(uploadPayload.rooms)) {
|
|
for (const sessionId of Object.keys(value.sessions)) {
|
|
emitter.emit(MockKeyUploadEvent.KeyUploaded, roomId, sessionId, version);
|
|
count++;
|
|
}
|
|
}
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
count: count,
|
|
etag: "abcdefg",
|
|
},
|
|
};
|
|
},
|
|
{
|
|
overwriteRoutes: true,
|
|
},
|
|
);
|
|
return emitter;
|
|
}
|
|
|
|
describe("megolm-keys backup", () => {
|
|
let aliceClient: MatrixClient;
|
|
/** an object which intercepts `/sync` requests on the test homeserver */
|
|
let syncResponder: SyncResponder;
|
|
|
|
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
|
let e2eKeyReceiver: E2EKeyReceiver;
|
|
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
|
let e2eKeyResponder: E2EKeyResponder;
|
|
|
|
beforeEach(async () => {
|
|
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
|
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
|
|
|
// anything that we don't have a specific matcher for silently returns a 404
|
|
fetchMock.catch(404);
|
|
fetchMock.config.warnOnFallback = false;
|
|
|
|
mockInitialApiRequests(TEST_HOMESERVER_URL);
|
|
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);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (aliceClient !== undefined) {
|
|
await aliceClient.stopClient();
|
|
}
|
|
|
|
// Allow in-flight things to complete before we tear down the test
|
|
await jest.runAllTimersAsync();
|
|
|
|
fetchMock.mockReset();
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
|
const client = createClient({
|
|
baseUrl: TEST_HOMESERVER_URL,
|
|
userId: TEST_USER_ID,
|
|
accessToken: "akjgkrgjs",
|
|
deviceId: TEST_DEVICE_ID,
|
|
...opts,
|
|
});
|
|
await client.initRustCrypto();
|
|
|
|
return client;
|
|
}
|
|
|
|
describe("Key backup check on UTD message", () => {
|
|
// sync response which contains an encrypted event
|
|
const SYNC_RESPONSE = {
|
|
next_batch: 1,
|
|
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
|
};
|
|
|
|
const EXPECTED_URL =
|
|
[
|
|
"https://alice-server.com/_matrix/client/v3/room_keys/keys",
|
|
encodeURIComponent(testData.TEST_ROOM_ID),
|
|
encodeURIComponent(testData.MEGOLM_SESSION_DATA.session_id),
|
|
].join("/") + "?version=1";
|
|
|
|
/** Flush promises enough times to get the crypto stacks to make the backup request */
|
|
async function flushBackupRequest() {
|
|
// we have to run flushPromises lots of times. It seems like each time the rust code touches indexeddb,
|
|
// it needs another round of flushPromises to progress, or something.
|
|
for (let i = 0; i < 10; i++) {
|
|
await flushPromises();
|
|
}
|
|
}
|
|
|
|
beforeEach(
|
|
async () => {
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
// ignore requests to send room key requests
|
|
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
|
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
testData.SIGNED_BACKUP_DATA.version!,
|
|
);
|
|
|
|
// start after saving the private key
|
|
await aliceClient.startClient();
|
|
|
|
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
|
// XXX: should we automatically re-check after a device becomes verified?
|
|
await waitForDeviceList();
|
|
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
|
} /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */,
|
|
10000,
|
|
);
|
|
|
|
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
|
|
// check that the version is correct
|
|
const version = new URLSearchParams(new URL(url).search).get("version");
|
|
if (version == "1") {
|
|
return testData.CURVE25519_KEY_BACKUP_DATA;
|
|
} else {
|
|
return {
|
|
status: 403,
|
|
body: {
|
|
current_version: "1",
|
|
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
|
error: "Wrong backup version.",
|
|
},
|
|
};
|
|
}
|
|
});
|
|
|
|
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
|
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
|
await syncPromise(aliceClient);
|
|
|
|
const room = aliceClient.getRoom(ROOM_ID)!;
|
|
const event = room.getLiveTimeline().getEvents()[0];
|
|
|
|
// On the first decryption attempt, decryption fails.
|
|
await awaitDecryption(event);
|
|
expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
|
|
|
|
// Eventually, decryption succeeds.
|
|
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
|
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
|
});
|
|
|
|
it("handles error on backup query gracefully", async () => {
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
fetchMock.get(
|
|
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
|
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
|
{ name: "getKey" },
|
|
);
|
|
|
|
// Send Alice a message that she won't be able to decrypt
|
|
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
|
await flushBackupRequest();
|
|
|
|
const calls = fetchMock.calls("getKey");
|
|
expect(calls.length).toEqual(1);
|
|
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
|
|
|
await flushBackupRequest();
|
|
|
|
// we should not have logged an error.
|
|
// eslint-disable-next-line no-console
|
|
expect(console.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Only queries once", async () => {
|
|
fetchMock.get(
|
|
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
|
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
|
{ name: "getKey" },
|
|
);
|
|
|
|
// Send Alice a message that she won't be able to decrypt
|
|
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
|
await flushBackupRequest();
|
|
const calls = fetchMock.calls("getKey");
|
|
expect(calls.length).toEqual(1);
|
|
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
|
|
|
fetchMock.resetHistory();
|
|
|
|
// another message
|
|
const event2 = { ...testData.ENCRYPTED_EVENT, event_id: "$event2" };
|
|
const syncResponse2 = {
|
|
next_batch: 1,
|
|
rooms: { join: { [ROOM_ID]: { timeline: { events: [event2] } } } },
|
|
};
|
|
syncResponder.sendOrQueueSyncResponse(syncResponse2);
|
|
await flushBackupRequest();
|
|
expect(fetchMock.calls("getKey").length).toEqual(0);
|
|
});
|
|
});
|
|
|
|
describe("recover from backup", () => {
|
|
let aliceCrypto: CryptoApi;
|
|
|
|
beforeEach(async () => {
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
fetchMock.get(
|
|
`path:/_matrix/client/v3/room_keys/version/${testData.SIGNED_BACKUP_DATA.version}`,
|
|
testData.SIGNED_BACKUP_DATA,
|
|
);
|
|
|
|
aliceClient = await initTestClient();
|
|
aliceCrypto = aliceClient.getCrypto()!;
|
|
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);
|
|
});
|
|
|
|
it("can restore from backup (Curve25519 version)", async function () {
|
|
const fullBackup = {
|
|
rooms: {
|
|
[ROOM_ID]: {
|
|
sessions: {
|
|
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
|
|
|
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58),
|
|
check!.backupInfo!.version!,
|
|
);
|
|
|
|
const result = await advanceTimersUntil(aliceCrypto.restoreKeyBackup());
|
|
|
|
expect(result.imported).toStrictEqual(1);
|
|
});
|
|
|
|
/**
|
|
* Creates a mock backup response of a GET `room_keys/keys` with a given number of keys per room.
|
|
* @param keysPerRoom The number of keys per room
|
|
*/
|
|
function createBackupDownloadResponse(keysPerRoom: number[]) {
|
|
const response: {
|
|
rooms: {
|
|
[roomId: string]: {
|
|
sessions: {
|
|
[sessionId: string]: KeyBackupSession;
|
|
};
|
|
};
|
|
};
|
|
} = { rooms: {} };
|
|
|
|
const expectedTotal = keysPerRoom.reduce((a, b) => a + b, 0);
|
|
for (let i = 0; i < keysPerRoom.length; i++) {
|
|
const roomId = `!room${i}:example.com`;
|
|
response.rooms[roomId] = { sessions: {} };
|
|
for (let j = 0; j < keysPerRoom[i]; j++) {
|
|
const sessionId = `session${j}`;
|
|
// Put the same fake session data, not important for that test
|
|
response.rooms[roomId].sessions[sessionId] = testData.CURVE25519_KEY_BACKUP_DATA;
|
|
}
|
|
}
|
|
return { response, expectedTotal };
|
|
}
|
|
|
|
it("Should import full backup in chunks", async function () {
|
|
const importMockImpl = jest.fn();
|
|
// @ts-ignore - mock a private method for testing purpose
|
|
jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
|
|
|
|
// We need several rooms with several sessions to test chunking
|
|
const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]);
|
|
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
|
|
|
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
|
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58),
|
|
check!.backupInfo!.version!,
|
|
);
|
|
|
|
const progressCallback = jest.fn();
|
|
const result = await aliceCrypto.restoreKeyBackup({
|
|
progressCallback,
|
|
});
|
|
|
|
expect(result.imported).toStrictEqual(expectedTotal);
|
|
// Should be called 5 times: 200*4 plus one chunk with the remaining 32
|
|
expect(importMockImpl).toHaveBeenCalledTimes(5);
|
|
for (let i = 0; i < 4; i++) {
|
|
expect(importMockImpl.mock.calls[i][0].length).toEqual(200);
|
|
}
|
|
expect(importMockImpl.mock.calls[4][0].length).toEqual(32);
|
|
|
|
expect(progressCallback).toHaveBeenCalledWith({
|
|
stage: "fetch",
|
|
});
|
|
|
|
// Should be called 4 times and report 200/400/600/800
|
|
for (let i = 0; i < 4; i++) {
|
|
expect(progressCallback).toHaveBeenCalledWith({
|
|
total: expectedTotal,
|
|
successes: (i + 1) * 200,
|
|
stage: "load_keys",
|
|
failures: 0,
|
|
});
|
|
}
|
|
|
|
// The last chunk
|
|
expect(progressCallback).toHaveBeenCalledWith({
|
|
total: expectedTotal,
|
|
successes: 832,
|
|
stage: "load_keys",
|
|
failures: 0,
|
|
});
|
|
});
|
|
|
|
it("Should continue to process backup if a chunk import fails and report failures", async function () {
|
|
const importMockImpl = jest
|
|
.fn()
|
|
.mockImplementationOnce(() => {
|
|
// Fail to import first chunk
|
|
throw new Error("test error");
|
|
})
|
|
// Ok for other chunks
|
|
.mockResolvedValue(undefined);
|
|
|
|
// @ts-ignore - mock a private method for testing purpose
|
|
jest.spyOn(aliceCrypto.backupManager, "importBackedUpRoomKeys").mockImplementation(importMockImpl);
|
|
|
|
const { response, expectedTotal } = createBackupDownloadResponse([100, 300]);
|
|
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
|
|
|
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58),
|
|
check!.backupInfo!.version!,
|
|
);
|
|
|
|
const progressCallback = jest.fn();
|
|
const result = await aliceCrypto.restoreKeyBackup({ progressCallback });
|
|
|
|
expect(result.total).toStrictEqual(expectedTotal);
|
|
// A chunk failed to import
|
|
expect(result.imported).toStrictEqual(200);
|
|
|
|
expect(progressCallback).toHaveBeenCalledWith({
|
|
total: expectedTotal,
|
|
successes: 0,
|
|
stage: "load_keys",
|
|
failures: 200,
|
|
});
|
|
|
|
expect(progressCallback).toHaveBeenCalledWith({
|
|
total: expectedTotal,
|
|
successes: 200,
|
|
stage: "load_keys",
|
|
failures: 200,
|
|
});
|
|
});
|
|
|
|
it("Should continue if some keys fails to decrypt", async function () {
|
|
// @ts-ignore - mock a private method for testing purpose
|
|
aliceCrypto.importBackedUpRoomKeys = jest.fn();
|
|
|
|
const decryptionFailureCount = 2;
|
|
|
|
const mockDecryptor = {
|
|
// DecryptSessions does not reject on decryption failure, but just skip the key
|
|
decryptSessions: jest.fn().mockImplementation((sessions) => {
|
|
// simulate fail to decrypt 2 keys out of all
|
|
const decrypted = [];
|
|
const keys = Object.keys(sessions);
|
|
for (let i = 0; i < keys.length - decryptionFailureCount; i++) {
|
|
decrypted.push({
|
|
session_id: keys[i],
|
|
} as unknown as Mocked<IMegolmSessionData>);
|
|
}
|
|
return decrypted;
|
|
}),
|
|
free: jest.fn(),
|
|
};
|
|
|
|
// @ts-ignore - mock a private method for testing purpose
|
|
aliceCrypto.getBackupDecryptor = jest.fn().mockResolvedValue(mockDecryptor);
|
|
|
|
const { response, expectedTotal } = createBackupDownloadResponse([100]);
|
|
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
|
|
|
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
decodeRecoveryKey(testData.BACKUP_DECRYPTION_KEY_BASE58),
|
|
check!.backupInfo!.version!,
|
|
);
|
|
|
|
const result = await aliceCrypto.restoreKeyBackup();
|
|
|
|
expect(result.total).toStrictEqual(expectedTotal);
|
|
// A chunk failed to import
|
|
expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount);
|
|
});
|
|
|
|
it("Should get the decryption key from the secret storage and restore the key backup", async function () {
|
|
// @ts-ignore - mock a private method for testing purpose
|
|
jest.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64);
|
|
|
|
const fullBackup = {
|
|
rooms: {
|
|
[ROOM_ID]: {
|
|
sessions: {
|
|
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
|
|
|
await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
|
const decryptionKey = await aliceCrypto.getSessionBackupPrivateKey();
|
|
expect(encodeBase64(decryptionKey!)).toStrictEqual(testData.BACKUP_DECRYPTION_KEY_BASE64);
|
|
|
|
const result = await aliceCrypto.restoreKeyBackup();
|
|
expect(result.imported).toStrictEqual(1);
|
|
});
|
|
|
|
it("Should throw an error if the decryption key is not found in cache", async () => {
|
|
await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store");
|
|
});
|
|
});
|
|
|
|
describe("backupLoop", () => {
|
|
it("Alice should upload known keys when backup is enabled", async function () {
|
|
// 404 means that there is no active backup
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
|
|
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
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);
|
|
|
|
// check that signalling is working
|
|
const remainingZeroPromise = new Promise<void>((resolve, reject) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
|
if (remaining == 0) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
|
|
|
const uploadMockEmitter = mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
|
|
|
|
const uploadPromises = someRoomKeys.map((data) => {
|
|
new Promise<void>((resolve) => {
|
|
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
|
if (
|
|
data.room_id == roomId &&
|
|
data.session_id == sessionId &&
|
|
version == testData.SIGNED_BACKUP_DATA.version
|
|
) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
|
|
await aliceCrypto.importRoomKeys(someRoomKeys);
|
|
|
|
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
|
|
jest.runAllTimers();
|
|
|
|
await Promise.all(uploadPromises);
|
|
|
|
// Wait until all keys are backed up to ensure that when a new key is received the loop is restarted
|
|
await remainingZeroPromise;
|
|
|
|
// A new key import should trigger a new upload.
|
|
const newKey = testData.MEGOLM_SESSION_DATA;
|
|
|
|
const newKeyUploadPromise = new Promise<void>((resolve) => {
|
|
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
|
if (
|
|
newKey.room_id == roomId &&
|
|
newKey.session_id == sessionId &&
|
|
version == testData.SIGNED_BACKUP_DATA.version
|
|
) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
await aliceCrypto.importRoomKeys([newKey]);
|
|
|
|
jest.runAllTimers();
|
|
await newKeyUploadPromise;
|
|
});
|
|
|
|
it("Alice should re-upload all keys if a new trusted backup is available", async function () {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
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);
|
|
|
|
// check that signalling is working
|
|
const remainingZeroPromise = new Promise<void>((resolve) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
|
if (remaining == 0) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
|
|
mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
|
|
await aliceCrypto.importRoomKeys(someRoomKeys);
|
|
|
|
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
|
|
jest.runAllTimers();
|
|
|
|
// wait for all keys to be backed up
|
|
await remainingZeroPromise;
|
|
|
|
const newBackupVersion = "2";
|
|
const uploadMockEmitter = mockUploadEmitter(newBackupVersion);
|
|
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
|
newBackup.version = newBackupVersion;
|
|
|
|
// Let's simulate that a new backup is available by returning error code on key upload
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
// If we import a new key the loop will try to upload to old version, it will
|
|
// fail then check the current version and switch if trusted
|
|
const uploadPromises = someRoomKeys.map((data) => {
|
|
new Promise<void>((resolve) => {
|
|
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
|
if (data.room_id == roomId && data.session_id == sessionId && version == newBackupVersion) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const disableOldBackup = new Promise<void>((resolve) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupFailed, (errCode) => {
|
|
if (errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
const enableNewBackup = new Promise<void>((resolve) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
|
if (enabled) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
// A new key import should trigger a new upload.
|
|
const newKey = testData.MEGOLM_SESSION_DATA;
|
|
|
|
const newKeyUploadPromise = new Promise<void>((resolve) => {
|
|
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
|
if (newKey.room_id == roomId && newKey.session_id == sessionId && version == newBackupVersion) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
await aliceCrypto.importRoomKeys([newKey]);
|
|
|
|
jest.runAllTimers();
|
|
|
|
await disableOldBackup;
|
|
await enableNewBackup;
|
|
|
|
jest.runAllTimers();
|
|
|
|
await Promise.all(uploadPromises);
|
|
await newKeyUploadPromise;
|
|
});
|
|
|
|
it("Backup loop should be resistant to network failures", async function () {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
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);
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
// on the first key upload attempt, simulate a network failure
|
|
const failurePromise = new Promise((resolve) => {
|
|
fetchMock.put(
|
|
"path:/_matrix/client/v3/room_keys/keys",
|
|
() => {
|
|
resolve(undefined);
|
|
throw new TypeError(`Failed to fetch`);
|
|
},
|
|
{
|
|
overwriteRoutes: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
// kick the import loop off and wait for the failed request
|
|
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
|
await aliceCrypto.importRoomKeys(someRoomKeys);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
jest.advanceTimersByTime(10 * 60 * 1000);
|
|
await failurePromise;
|
|
|
|
// Fix the endpoint to do successful uploads
|
|
const successPromise = new Promise((resolve) => {
|
|
fetchMock.put(
|
|
"path:/_matrix/client/v3/room_keys/keys",
|
|
() => {
|
|
resolve(undefined);
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
count: 2,
|
|
etag: "abcdefg",
|
|
},
|
|
};
|
|
},
|
|
{
|
|
overwriteRoutes: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
// check that a `KeyBackupSessionsRemaining` event is emitted with `remaining == 0`
|
|
const allKeysUploadedPromise = new Promise((resolve) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
|
if (remaining == 0) {
|
|
resolve(undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
// run the timers, which will make the backup loop redo the request
|
|
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
|
|
await successPromise;
|
|
await allKeysUploadedPromise;
|
|
});
|
|
});
|
|
|
|
it("getActiveSessionBackupVersion() should give correct result", 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()!;
|
|
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 aliceCrypto.checkKeyBackupAndEnable();
|
|
|
|
// 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 aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version);
|
|
expect(checked?.trustInfo?.trusted).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 signalling is working
|
|
const backupPromise = new Promise<void>((resolve, reject) => {
|
|
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
|
if (enabled) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
const validCheck = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(validCheck?.trustInfo?.trusted).toStrictEqual(true);
|
|
|
|
await backupPromise;
|
|
|
|
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
|
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
});
|
|
|
|
it("getKeyBackupInfo() should not return a backup if the active backup has been deleted", async () => {
|
|
// 404 means that there is no active backup
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404);
|
|
fetchMock.delete(`express:/_matrix/client/v3/room_keys/version/${testData.SIGNED_BACKUP_DATA.version}`, {});
|
|
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
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 aliceCrypto.checkKeyBackupAndEnable();
|
|
|
|
// At this point there is no backup
|
|
expect(await aliceCrypto.getKeyBackupInfo()).toBeNull();
|
|
|
|
// Return now the backup
|
|
fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
expect(await aliceCrypto.getKeyBackupInfo()).toStrictEqual(testData.SIGNED_BACKUP_DATA);
|
|
|
|
// Delete the backup and we are expecting the key backup to be disabled
|
|
const keyBackupStatus = defer<boolean>();
|
|
aliceClient.once(CryptoEvent.KeyBackupStatus, (enabled) => keyBackupStatus.resolve(enabled));
|
|
await aliceCrypto.deleteKeyBackupVersion(testData.SIGNED_BACKUP_DATA.version!);
|
|
expect(await keyBackupStatus.promise).toBe(false);
|
|
|
|
// The backup info should not be available anymore
|
|
expect(await aliceCrypto.getKeyBackupInfo()).toBeNull();
|
|
});
|
|
|
|
describe("isKeyBackupTrusted", () => {
|
|
it("does not trust a backup signed by an untrusted device", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// download the device list, to match the trusted case
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
|
|
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
|
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
|
});
|
|
|
|
it("trusts a backup signed by a trusted device", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// tell Alice to trust the dummy device that signed the backup
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
|
|
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
|
expect(result).toEqual({ trusted: true, matchesDecryptionKey: false });
|
|
});
|
|
|
|
it("recognises a backup which matches the decryption key", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
await aliceClient.startClient();
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
testData.SIGNED_BACKUP_DATA.version!,
|
|
);
|
|
|
|
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
|
expect(result).toEqual({ trusted: false, matchesDecryptionKey: true });
|
|
});
|
|
|
|
it("is not fooled by a backup which matches the decryption key but uses a different algorithm", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
await aliceClient.startClient();
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
testData.SIGNED_BACKUP_DATA.version!,
|
|
);
|
|
|
|
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
|
backup.algorithm = "m.megolm_backup.v1.aes-hmac-sha2";
|
|
const result = await aliceCrypto.isKeyBackupTrusted(backup);
|
|
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
|
});
|
|
});
|
|
|
|
describe("checkKeyBackupAndEnable", () => {
|
|
it("enables a backup signed by a trusted device", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// tell Alice to trust the dummy device that signed the backup
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(result!.trustInfo).toEqual({ trusted: true, matchesDecryptionKey: false });
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
});
|
|
|
|
it("enables a backup not signed by a trusted device, when we have the decryption key", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// download the device list, to match the trusted-device case
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
// Alice does *not* trust the device that signed the backup, but *does* have the decryption key.
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
testData.SIGNED_BACKUP_DATA.version!,
|
|
);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(result!.trustInfo).toEqual({ trusted: false, matchesDecryptionKey: true });
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
});
|
|
|
|
it("does not enable a backup signed by an untrusted device when we do not have the decryption key", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// download the device list, to match the trusted case
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(result!.trustInfo).toEqual({ trusted: false, matchesDecryptionKey: false });
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
|
});
|
|
|
|
it("disables backup when a new untrusted backup is available", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// tell Alice to trust the dummy device that signed the backup
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
|
|
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
|
delete unsignedBackup.auth_data.signatures;
|
|
unsignedBackup.version = "2";
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", unsignedBackup, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
|
});
|
|
|
|
it("switches backup when a new trusted backup is available", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// tell Alice to trust the dummy device that signed the backup
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
|
|
const newBackupVersion = "2";
|
|
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
|
newBackup.version = newBackupVersion;
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
|
|
overwriteRoutes: true,
|
|
});
|
|
|
|
await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(newBackupVersion);
|
|
});
|
|
|
|
it("Disables when backup is deleted", async () => {
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
|
|
// tell Alice to trust the dummy device that signed the backup
|
|
await aliceClient.startClient();
|
|
await waitForDeviceList();
|
|
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(result).toBeTruthy();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
|
|
|
fetchMock.get(
|
|
"path:/_matrix/client/v3/room_keys/version",
|
|
{
|
|
status: 404,
|
|
body: {
|
|
errcode: "M_NOT_FOUND",
|
|
error: "No backup found",
|
|
},
|
|
},
|
|
{
|
|
overwriteRoutes: true,
|
|
},
|
|
);
|
|
const noResult = await aliceCrypto.checkKeyBackupAndEnable();
|
|
expect(noResult).toBeNull();
|
|
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Backup Changed from other sessions", () => {
|
|
beforeEach(async () => {
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
|
|
|
// ignore requests to send room key requests
|
|
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
|
|
|
aliceClient = await initTestClient();
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
testData.SIGNED_BACKUP_DATA.version!,
|
|
);
|
|
|
|
// start after saving the private key
|
|
await aliceClient.startClient();
|
|
|
|
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
|
// XXX: should we automatically re-check after a device becomes verified?
|
|
await waitForDeviceList();
|
|
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
|
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
|
});
|
|
|
|
// let aliceClient: MatrixClient;
|
|
|
|
const SYNC_RESPONSE = {
|
|
next_batch: 1,
|
|
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
|
};
|
|
|
|
it("If current backup has changed, the manager should switch to the new one on UTD", async () => {
|
|
// =====
|
|
// First ensure that the client checks for keys using the backup version 1
|
|
/// =====
|
|
|
|
fetchMock.get(
|
|
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
|
(url, request) => {
|
|
// check that the version is correct
|
|
const version = new URLSearchParams(new URL(url).search).get("version");
|
|
if (version == "1") {
|
|
return testData.CURVE25519_KEY_BACKUP_DATA;
|
|
} else {
|
|
return {
|
|
status: 403,
|
|
body: {
|
|
current_version: "1",
|
|
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
|
error: "Wrong backup version.",
|
|
},
|
|
};
|
|
}
|
|
},
|
|
{ overwriteRoutes: true },
|
|
);
|
|
|
|
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
|
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
|
await syncPromise(aliceClient);
|
|
|
|
const room = aliceClient.getRoom(ROOM_ID)!;
|
|
const event = room.getLiveTimeline().getEvents()[0];
|
|
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
|
|
|
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
|
|
|
// =====
|
|
// Second suppose now that the backup has changed to version 2
|
|
/// =====
|
|
|
|
const newBackup = {
|
|
...testData.SIGNED_BACKUP_DATA,
|
|
version: "2",
|
|
};
|
|
|
|
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, { overwriteRoutes: true });
|
|
// suppose the new key is now known
|
|
const aliceCrypto = aliceClient.getCrypto()!;
|
|
await aliceCrypto.storeSessionBackupPrivateKey(
|
|
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
|
newBackup.version,
|
|
);
|
|
|
|
// A check backup should happen at some point
|
|
await aliceCrypto.checkKeyBackupAndEnable();
|
|
|
|
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
|
|
|
fetchMock.get(
|
|
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
|
(url, request) => {
|
|
// check that the version is correct
|
|
const version = new URLSearchParams(new URL(url).search).get("version");
|
|
if (version == newBackup.version) {
|
|
awaitHasQueriedNewBackup.resolve();
|
|
return testData.CURVE25519_KEY_BACKUP_DATA;
|
|
} else {
|
|
// awaitHasQueriedOldBackup.resolve();
|
|
return {
|
|
status: 403,
|
|
body: {
|
|
current_version: "2",
|
|
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
|
error: "Wrong backup version.",
|
|
},
|
|
};
|
|
}
|
|
},
|
|
{ overwriteRoutes: true },
|
|
);
|
|
|
|
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the new backup.
|
|
const newMessage: Partial<IEvent> = {
|
|
type: "m.room.encrypted",
|
|
room_id: "!room:id",
|
|
sender: "@alice:localhost",
|
|
content: {
|
|
algorithm: "m.megolm.v1.aes-sha2",
|
|
ciphertext:
|
|
"AwgAEpABKvf9FqPW52zeHfeVTn90a3jlBLlx7g6VDEkc2089RQUJoWpSJRiK13E83rN41wgGFJccyfoCr7ZDGJeuGYMGETTrgnLQhLs6JmyPf37JYkzxW8uS8rGUKEqTFQriKhibHVLvVacOlSIObUiKU/V3r176XuixqZF/4eyK9A22JNpInbgI10ZUT6LnApH9LR3FpZbE2zImf1uNPuvp7r0xQbW7CcJjqpH+qTPBD5zFdFnMkc2SnbXCsIOaX11Dm0krWfQz7iA26ZnI1nyZnyh7XPrCnJCRsuQH",
|
|
device_id: "WVMJGTSSVB",
|
|
sender_key: "E5RiY/YCIrHWaF4u416CqvblC6udK2jt9SJ/h1QeLS0",
|
|
session_id: "ybnW+LGdUhoS4fHm1DAEphukO3sZ1GCqZD7UQz7L+GA",
|
|
},
|
|
event_id: "$event2",
|
|
origin_server_ts: 1507753887000,
|
|
};
|
|
|
|
const nextSyncResponse = {
|
|
next_batch: 2,
|
|
rooms: { join: { [ROOM_ID]: { timeline: { events: [newMessage] } } } },
|
|
};
|
|
syncResponder.sendOrQueueSyncResponse(nextSyncResponse);
|
|
await syncPromise(aliceClient);
|
|
|
|
await awaitHasQueriedNewBackup.promise;
|
|
});
|
|
});
|
|
|
|
/** 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);
|
|
}
|
|
});
|